12 Commits
main ... main

47 changed files with 4859 additions and 244 deletions

View File

@@ -0,0 +1,25 @@
robots.txt,1780149196204,c42e958aa717ecf11e70c927ebe348b7cfdeb317d164463dde44a6245edc25bf
_app/version.json,1781597180494,6853c009f37c9a6a3302f90db12c20e34d6b7862ad1c315714eb1044fbd7afe0
index.html,1781597182000,eb5a215dbb0a7539d036698b300e289b4291f76b78f497c0da6c17d844196b73
_app/env.js,1781597181277,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638
_app/immutable/nodes/3.Da4ilo9V.js,1781597180475,8b7f71618cbb9481eae50bcab3557e1a18579ca05397504926155dcd34b273c5
_app/immutable/nodes/4.sF5B4qoA.js,1781597180476,0a444727ed27e504edd46e8b7e936eaac7e790d274ffe5f58ad7aceaad76a971
_app/immutable/nodes/0.CF0HerLL.js,1781597180473,92cfe635ac64a808680b6dbe6bb1ce4a3abae984f22c2a7896c27bc0a7476ead
_app/immutable/nodes/1.9IAeRu3w.js,1781597180474,7cbbfc75a88d77614bbdef3fc48e034cdee3958e893959707398dab9ed1d4c93
_app/immutable/entry/start.CqQaJc09.js,1781597180472,a84dddebd82a60e91841e49f5312f68fda4ac1cbf93cc66883c2b8a991c4e8a4
_app/immutable/chunks/kNaey6uv.js,1781597180482,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610
_app/immutable/chunks/dYw4s8FK.js,1781597180481,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb
_app/immutable/chunks/dYDITFvm.js,1781597180480,5625d4bf7cca09d6005c0f12c974e1c33550d5704bcc8fc9651fd68207b3c241
_app/immutable/chunks/BXRdFrf6.js,1781597180477,b5ad6763a83bf8db5fa75951cfef48c9bd596a3b411a24b624cf1a4ffddd2a7a
_app/immutable/chunks/BT5Sv--u.js,1781597180477,1d71f3354e874a35e79c8aeda2248dd684ce1654178bcfa35dff0851a0d15a33
_app/immutable/chunks/FljZ3G7a.js,1781597180479,7e10d1e9a617bdf3f461d4061dbf4b00049c48e43c7b66a819ee0775cdaa2898
_app/immutable/assets/4.DNmkdUsl.css,1781597180492,bf20a1cecff78d2c2566cb760593b117110eeacc701b4c79ee9b897bbdcc2ea5
_app/immutable/chunks/BqvGMMFS.js,1781597180478,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999
_app/immutable/chunks/xihTtKlq.js,1781597180483,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e
_app/immutable/assets/2.BAt0MrfH.css,1781597180489,7b585e41d74917eac70caacaddcfe19f9760dba8b270cb860b0ca31e485ebb11
_app/immutable/entry/app.Mng5z0iy.js,1781597180472,a3b058ffac49561b3c78b9fc7acc407483787e55bb45712059f5c5dfd5299f34
_app/immutable/assets/0.DB_w-Orf.css,1781597180487,65816fda749a1c0cca0e0d336ab5ce2e8072226d1011391e0fa3577e8f8d7cd2
_app/immutable/assets/3.RINX6wbk.css,1781597180491,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d
_app/immutable/chunks/z5owE3AB.js,1781597180484,a3cbca6624018231c584f76781735d69f12b229ce8858cbcd219da801737e265
_app/immutable/nodes/2.Bah3jasp.js,1781597180474,46e17d937aad72f59d8818941abee3cc7d6d02df294692e5850f1dbe2e0abe0b
_app/immutable/chunks/CGbwVvmT.js,1781597180478,f6685558a62443dfe58d97ae38ba1c9c4a9da6db8d2629f634e64a164cb164d3

5
.firebaserc Normal file
View File

@@ -0,0 +1,5 @@
{
"projects": {
"default": "overheard-d7396"
}
}

278
README.md
View File

@@ -1,42 +1,260 @@
# sv
**Name:** Samantha Lopez
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
**ID:** 20266142
## Creating a project
**Email:** samantha@kaist.ac.kr
If you're seeing this, you've probably already done this step. Congrats!
**Gittea Repo:** [https://git.prototyping.id/20266142/Overheard.git](https://git.prototyping.id/20266142/Overheard.git)
```sh
# create a new project
npx sv create my-app
**Video Demo:** [https://youtu.be/pI6eU5PSqRY](https://youtu.be/pI6eU5PSqRY)
**Live site URL** [https://overheard-d7396.web.app](https://overheard-d7396.web.app)
# App Description
Overheard is a location based message board. Its premise is simple and intentionally constrained, you are only able to read what others have left at a place if you are physically in the same location. You can only leave a message if you are standing there too. Overheard functions on anonymity It strays away from becoming a "social media" by eliminating profiles, feeds, and online identities. The app utilizes firebase's anonymous authenticatioin to store activity on devices, enough to link interactions across sessions but not enough to create anythings that ties your messages back to you.
Overheard works like this; upon opening, the app gets your location and translates coordinates into a geohash, a short string representative of your geogrpahical location. It then takes that string to look for messages whose geohash contain an identical prefix thus returning messages left around you. The messages appear as pins on the map. You can tap on any pin to read what was left there. When reding a message you are presented with two choices: to echo it which restarts its 30 day life span and thus extends its visibility on the map, or let it go, which simply closes the read view of the messages. You can also leave your own messages compromised of text and optionally an image for others to read within the next 30 days, you can even choose a display pin color for your message(optional).
## Echoing messages
one of my favorite features of the entire app is the 30 day decay feature that each message lives under. This makes it so messages don't accumulate forever, they face, a message which goes unechoed dissapears within 30 days, not because it gets deleted from the database but because it was either undiscovered or because no one made the decision to extend its lifespan. Echoing a message resets its lifespan it signifies that someone cared enough to keep it alive. This interaction allows messages to become curated over time, messages that survive are those who kept being encountered by people who wanted those messages to survive. This si what makes the app interesting. Individual messages are fleeting, echoing is what makes something as close to permanent as you can get within this app and physical presence is required for all of these interactions.
## Features
**Geoloacation based message visiibility:** Overheard uses the browser Geolocation API to get user coordinates then turns them into a geohash (using `ngeohash`). The geohash is then used to look for messages in firebase within the desired area (location scope is calculated based on geohash prefix similarity, currently the prefix is 7 characters which is about a city blocks worth of distance). This feature makes physical presence a requirement to interact with the app in any meaningful way.
**Message pins on the map:** Each nearby message is visible as a pastel colored circle on the map. This is done via Google Maps legacy Marker API with the icon being handled by `pins.js`. Pin color can be chosen at the time of leaving a message otherwise a randomly generated color will be used. Opted for this view over a feed to reinforce spacial memory, messages live in the locations where they were left not in a social media like feed.
**30-day Decay:** Messages had a `lastEchoAt` timestamp. Messages whose last echo was more than 30 days ago get filtered out and not rendered thus making them dissapear entirely from user view.
**Echo:** Increments the messages `echoCount` and sets the messages `lastEchoAt` to the current timestamp, reseting their 30 day decay clock. Plays a small arpeggio like sound as feeback when pressed.
**Let go:** Pressing this simpley closes the detailed message view. The alternative to echoing somethings should be equally as effortless and framed as letting a memory go.
**Composing a message:** `ComposeSheet.svelte` calls `addMessage` with the text, image, and pin color. Maximum 240 characters are accepted. Images are analyzed to ensure that no selfies are uploaded to preserve anonymity.
**Share with link and QR code:** `SharePopover.svelte` creates a URL and a QR code (via a qrcode package). `+page.svelte` is able to read the the `?message` parameter on the url once it opens and shows that message (if it is still alive). I decided to add this feature as it brings some of the sharing and allows for even more physical interactions. For instance the QR code can be posted in places both sharing a message and inviting people to share their own.
**location trail:** Every time the app loads, `addTrailPoint` writes the coordinates to localStorage. In `MapView.svelte`, the full history is drawn as a polyline on the map (toggleable with the small button in the top-left). A visualizer of all the places where you have shared and received memories within Overheard.
**Archive page:** Shows a list of every message that has been left on the device and shows their decay status.`getMyMessages()` looks for messages in Firestore by authorId matching the anonymous UID. This allows you to be able to see what you've left behind and whether it's still alive.
**Stamps:** Every time a message is posted from a new geohash-4 region (4 char prefix location precision), `checkAndAwardStamp` reverse-geocodes the coordinates and writes a stamp to `users/{uid}/stamps/{geohash4}`. The stamp page shows all the earned stamps like a sticker-album layout. Each stamp is a circle with a predetermined icon and the place name. This serves as a track of all the places you've been in and in which you choose to partake in the sharing of anonymous memories.
**Face detection for photo uploads:** When you select a photo `hadFace(file)` from `faceDetection.js` block the upload of a picture if any face is detected. The check occurs before the image even gets to be previwed for upload. Since overheard is anonymous its pictures should also be.
## App summary; TLDR
Overheard was not built ot be a social app, it purposely erased the ability to create a public identity, you cannot follow anyone, see anyones profile, or even know who left a message in the first place. Its has no guarantee of permanence, participating is accepting that the memories you share and receive might fade. It reinforces physical presence, you cannot sit and scroll through messages but you can only experience the memories of others by crating some yourself in the same physical location. Overheard is designed to feel like you are reading something somene left for you in a particular place, not scrolling for content.
# Code Organization
## File Organization
Overheard is an application built using SvelteKit and which was deployed to firebase for hosting. The file structure it functions under is as illustrated below.
![File organization diagram](src/lib/assets/File_organization_overheard.png)
## Data/Application Flows
**App opens**
![App opens diagram](src/lib/assets/open%20app.png)
**Message pins render**
![Message pins render diagram](src/lib/assets/Message%20pins%20load.png)
**Tapping a pin**
![Tapping a pin diagram](src/lib/assets/Tapping%20a%20pin.png)
**Leaving a message**
![Leaving a message diagram](src/lib/assets/posting%20message.png)
**New message is added during active session**
![New Message added during active session diagram](src/lib/assets/New%20pin%20dropped.png)
**View the diagrams on Figma with the following link:**
[https://www.figma.com/board/zJCrMvicQFHpyX9eqeCY2W/Final-Project-Diagrams?node-id=2008-3984&t=NF572Ti6bMLwkJB1-1](https://www.figma.com/board/zJCrMvicQFHpyX9eqeCY2W/Final-Project-Diagrams?node-id=2008-3984&t=NF572Ti6bMLwkJB1-1)
## Svelte stores (shared states)
**messagesStore.js**
|Variable/Function|Purpose|
|-----------------|-------|
|`messageStore`|array of all the nearby active messages|
|`livePinIdsStore`| set of message ID's that arrive bia the real time listener|
|`setMessages(newMessages)`|setter that compares against previous list and plays `playNewPinchime()` if any new IDs appear|
**mapStore.js**
|Variable/Function|Purpose|
|-----------------|-------|
|`mapStore`| help figure out what to render|
|`selectedMessage`|the message tapped on by the user (null if none)|
|`composing`| `true` when the compose sheet is open|
**userStore.js**
|Variable/Function|Purpose|
|-----------------|-------|
|`userStore`|holds the anonymous Firebase identity for the device|
|`uid`|the anonymous Firebase UID (needed before stamps or archive can load)|
|`ready`|true once auth has resolved|
|`initAuth()`|calls signInAnonymously and subscribes to onAuthStateChanged to keep the store in sync|
**stampStore.js**
|Variable/Function|Purpose|
|-----------------|-------|
|`stampsStore`| array of earned passport stamps (populated on load, appended to live when a new stamp is earned)|
**globalCountStore.js**
|Variable/Function|Purpose|
|-----------------|-------|
|`globalCount`| total messages ever posted worldwide|
|`refreshGlobalCount()`|fetches from meta/stats in Firestore and updates the store|
## How different data is stored in Firestore
**Messages**
```
authorId: "T9R85su4FvUII22QMUUljK0jyy33" //(string)
createdAt: May 17, 2026 at 7:34:03PM UTC+9 //(timestamp)
echoCount: 0 //(int64)
geohash: "wy6wfhcqd" //(string)
imageUrl: "" //(string)
lastEchoAt: May 17, 2026 at 7:34:03PM UTC+9 //(timestamp)
lat: 36.370018 //(double)
lng: 127.355324 //(double)
moodColor: "hsl(40, 60%, 72%)" //(string)
text: "cocumentation example" //(string)
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.15.3 create --template minimal --no-types --install npm overheard
**Meta Stats**
```
id: "" //(string)
totalMessagesEverPosted: 0 //(int64)
```
**Users**
```
uid: "" //(string)
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
**Stamps**
```
## Building
To create a production version of your app:
```sh
npm run build
geohash4: "precision-4 geohash" //(string)
city: "" //(string)
country: "" //(string)
iconId: "" //(string)
color: "hsl(...)" //(string)
earnedAt: May 17, 2026 at 7:34:03PM UTC+9 //(timestamp)
```
## Whats in the .env file
You can preview the production build with `npm run preview`.
| Variable | Description | Where I found |
| --- | --- | --- |
| `PUBLIC_FIREBASE_API_KEY` | Firebase Web API key | Firebase Console → Project Settings → Your apps |
| `PUBLIC_FIREBASE_AUTH_DOMAIN` | Firebase Auth domain | Same — `{projectId}.firebaseapp.com` |
| `PUBLIC_FIREBASE_PROJECT_ID` | Firebase project ID | Same |
| `PUBLIC_FIREBASE_STORAGE_BUCKET` | Firebase Storage bucket | Same — `{projectId}.appspot.com` |
| `PUBLIC_FIREBASE_MESSAGING_SENDER_ID` | Firebase messaging sender ID | Same |
| `PUBLIC_FIREBASE_APP_ID` | Firebase app ID | Same |
| `PUBLIC_MAPS_KEY` | Google Maps JavaScript API key (also used for the Geocoding REST API) | Google Cloud Console → APIs & Services → Credentials |
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
# Opportunities for improvement
- The current decay logic works by simply not rendering the messages which are past the 30 day threshold after their last echo. However, these messaages never get deleted from the Firestore which means they are never truly gone.
- The map does not update as the user is moving. Currently the app only calculates messages and location on open or on refresh. Making it so the app updates location as the user is moving could add to the interacton significantly.
- The current face detection was utilized using a model trained on close range faces and works best on selfies, thus currently some mid to far range pictures with faces can bypass this check.
# Resource auknoledgement
Below is a comprhensive list of everything utilized to being Overheard to life (including links and usage notes)
**Google Maps JavaScript API**
[https://developers.google.com/maps/documentation/javascript/overview](https://developers.google.com/maps/documentation/javascript/overview)
renders the interactive map, places custom pins (via `google.maps.Marker` with SVG icons), handles click interactions, and applies the custom desaturated/parchment visual styling via the `styles` array.
**Google Maps Geocoding API**
[https://developers.google.com/maps/documentation/geocoding/overview]([https://developers.google.com/maps/documentation/geocoding/overview])
Used for the stamps feature, converts a message's lat/lng coordinates into a city and country name when a user posts from a new region.
**@googlemaps/js-api-loader**
[https://www.npmjs.com/package/@googlemaps/js-api-loader]([https://www.npmjs.com/package/@googlemaps/js-api-loader])
A library used to cleanly load the Google Maps JavaScript API script asynchronously inside Svelte's `onMount`.
**Firebase Firestore**
[https://firebase.google.com/docs/firestore](https://firebase.google.com/docs/firestore)
Stores all message documents (text, coordinates, geohash, timestamps, echo data, mood colors), the global message counter, and per-user stamp collections.
**Firebase Storage**
[https://firebase.google.com/docs/storage](https://firebase.google.com/docs/storage)
Stores uploaded images attached to messages. Files are uploaded here and the resulting download URLs are saved in Firestore message documents.
**Firebase Hosting**
[https://firebase.google.com/docs/hosting]([https://firebase.google.com/docs/hosting])
Hosts the built static SvelteKit app, providing the live `.web.app` deployment URL.
**Firebase Authentication (Anonymous)**
[https://firebase.google.com/docs/auth/web/anonymous-auth](https://firebase.google.com/docs/auth/web/anonymous-auth)
Gives every device a persistent, anonymous user ID without requiring registration. Used to link messages to a device for the archive page and stamps, preserving the app's anonymous design.
**ngeohash**
[https://www.npmjs.com/package/ngeohash](https://www.npmjs.com/package/ngeohash)
Converts coordinates to geohash strings and back (`encode`, `decode_bbox`). This powers the proximity-based "nearby messages" queries.
**@mediapipe/tasks-vision (BlazeFace)**
[https://ai.google.dev/edge/mediapipe/solutions/vision/face_detector/web_js](https://ai.google.dev/edge/mediapipe/solutions/vision/face_detector/web_js)
Google's face detection model. Used to scan images before upload and block any containing faces.
**lucide-react**
[https://lucide.dev/](https://lucide.dev/)
Outline icon set used throughout the UI( navigation bar icons, stamp icons, and other interface elements).
**qrcode**
[https://www.npmjs.com/package/qrcode](https://www.npmjs.com/package/qrcode)
Generates QR codes for the share feature, allowing any message to be turned into a scannable code that links back to that specific message.
**SvelteKit / Svelte 5**
[https://svelte.dev/docs/kit/introduction](https://svelte.dev/docs/kit/introduction)
The application framework, Svelte 5's runes mode (`$state`, `$derived`, `$effect`, `$props`) is used throughout for reactive state management, and SvelteKit provides routing (map page, archive page) and the build pipeline.
**@sveltejs/adapter-static**
[https://www.npmjs.com/package/@sveltejs/adapter-static](https://www.npmjs.com/package/@sveltejs/adapter-static)
Configures SvelteKit to build as a fully static site, which is what allows the app to be deployed to Firebase Hosting.
- Claude (coding assistance, debugging, feature implementation)
- Copilot (coding assistance)
- Further documentation on AI Prompts along with additional work documentation can be found in the following notion link [https://quilted-recorder-3b4.notion.site/Final-Project-ongoing-documentation-370f941b5e518013a8a7dd703d7dbe22?source=copy_link](https://quilted-recorder-3b4.notion.site/Final-Project-ongoing-documentation-370f941b5e518013a8a7dd703d7dbe22?source=copy_link)
- Course notes (ID 30011)
- Referenced code and materials from my own Individual project from earlier in the semester

26
firebase.json Normal file
View File

@@ -0,0 +1,26 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"storage": [
{
"bucket": "overheard-d7396.firebasestorage.app",
"rules": "storage.rules"
}
],
"hosting": {
"public": "build",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

4
firestore.indexes.json Normal file
View File

@@ -0,0 +1,4 @@
{
"indexes": [],
"fieldOverrides": []
}

69
firestore.rules Normal file
View File

@@ -0,0 +1,69 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /messages/{messageId} {
// anyone can read messages
allow read: if true;
// anyone can create a message
// text must exist and be under 240 characters
allow create: if
request.resource.data.text is string &&
request.resource.data.text.size() <= 240;
// the only update allowed is an echo
// echoCount and lastEchoAt fields can be changed
// this prevents anyone from editing the text of someone else's message
allow update: if
request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['echoCount', 'lastEchoAt']);
// nobody can delete messages through the client
allow delete: if false;
}
// --- "passport stamps" --------------------------------------------------
// each anonymous-auth user has their own users/{uid}/stamps/{geohash4}
// subcollection (see firebase/stamps.js) - request.auth.uid == userId
// means a device can only ever read/write its OWN stamps, never anyone
// else's. Stamps are permanent once earned: no update/delete from the
// client, and create is validated the same way the messages create rule
// above validates its fields.
match /users/{userId}/stamps/{stampId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow create: if
request.auth != null && request.auth.uid == userId &&
request.resource.data.keys().hasAll(['geohash4', 'city', 'country', 'iconId', 'color', 'earnedAt']) &&
request.resource.data.geohash4 is string &&
request.resource.data.city is string &&
request.resource.data.country is string;
allow update: if false;
allow delete: if false;
}
// --- global "memory counter" (GlobalCountPill) -------------------------
// a single shared doc, meta/stats, holding totalMessagesEverPosted.
// anyone can read it (it's shown to every visitor); writes are limited to
// just that one field, same "only these fields can change" pattern as the
// echoCount update rule above, so this doc can't be hijacked to store
// arbitrary data.
match /meta/stats {
allow read: if true;
// first-ever write: only the counter field, and it must be a number
allow create: if
request.resource.data.keys().hasOnly(['totalMessagesEverPosted']) &&
request.resource.data.totalMessagesEverPosted is number;
// every later write (increment(1)) may only touch that same field
allow update: if
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['totalMessagesEverPosted']);
// nobody can delete the counter through the client
allow delete: if false;
}
}
}

225
package-lock.json generated
View File

@@ -9,11 +9,14 @@
"version": "0.0.1",
"dependencies": {
"@googlemaps/js-api-loader": "^2.1.0",
"@mediapipe/tasks-vision": "^0.10.35",
"firebase": "^12.14.0",
"ngeohash": "^0.6.3"
"ngeohash": "^0.6.3",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.55.2",
@@ -755,6 +758,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -1145,6 +1154,16 @@
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "2.61.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz",
@@ -1311,6 +1330,15 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1363,6 +1391,15 @@
"node": ">= 0.6"
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1390,6 +1427,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -1460,6 +1503,19 @@
}
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/firebase": {
"version": "12.14.0",
"resolved": "https://registry.npmjs.org/firebase/-/firebase-12.14.0.tgz",
@@ -1829,6 +1885,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
@@ -1900,6 +1968,51 @@
],
"license": "MIT"
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1920,6 +2033,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -1973,6 +2095,89 @@
"node": ">=12.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1982,6 +2187,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
@@ -2036,6 +2247,12 @@
],
"license": "MIT"
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
@@ -2288,6 +2505,12 @@
"node": ">=0.8.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -11,6 +11,7 @@
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.55.2",
@@ -18,7 +19,9 @@
},
"dependencies": {
"@googlemaps/js-api-loader": "^2.1.0",
"@mediapipe/tasks-vision": "^0.10.35",
"firebase": "^12.14.0",
"ngeohash": "^0.6.3"
"ngeohash": "^0.6.3",
"qrcode": "^1.5.4"
}
}

89
public/index.html Normal file
View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome to Firebase Hosting</title>
<!-- update the version number as needed -->
<script defer src="/__/firebase/12.14.0/firebase-app-compat.js"></script>
<!-- include only the Firebase features as you need -->
<script defer src="/__/firebase/12.14.0/firebase-auth-compat.js"></script>
<script defer src="/__/firebase/12.14.0/firebase-database-compat.js"></script>
<script defer src="/__/firebase/12.14.0/firebase-firestore-compat.js"></script>
<script defer src="/__/firebase/12.14.0/firebase-functions-compat.js"></script>
<script defer src="/__/firebase/12.14.0/firebase-messaging-compat.js"></script>
<script defer src="/__/firebase/12.14.0/firebase-storage-compat.js"></script>
<script defer src="/__/firebase/12.14.0/firebase-analytics-compat.js"></script>
<script defer src="/__/firebase/12.14.0/firebase-remote-config-compat.js"></script>
<script defer src="/__/firebase/12.14.0/firebase-performance-compat.js"></script>
<!--
initialize the SDK after all desired features are loaded, set useEmulator to false
to avoid connecting the SDK to running emulators.
-->
<script defer src="/__/firebase/init.js?useEmulator=true"></script>
<style media="screen">
body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
#message { background: white; max-width: 360px; margin: 100px auto 16px; padding: 32px 24px; border-radius: 3px; }
#message h2 { color: #ffa100; font-weight: bold; font-size: 16px; margin: 0 0 8px; }
#message h1 { font-size: 22px; font-weight: 300; color: rgba(0,0,0,0.6); margin: 0 0 16px;}
#message p { line-height: 140%; margin: 16px 0 24px; font-size: 14px; }
#message a { display: block; text-align: center; background: #039be5; text-transform: uppercase; text-decoration: none; color: white; padding: 16px; border-radius: 4px; }
#message, #message a { box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); }
#load { color: rgba(0,0,0,0.4); text-align: center; font-size: 13px; }
@media (max-width: 600px) {
body, #message { margin-top: 0; background: white; box-shadow: none; }
body { border-top: 16px solid #ffa100; }
}
</style>
</head>
<body>
<div id="message">
<h2>Welcome</h2>
<h1>Firebase Hosting Setup Complete</h1>
<p>You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!</p>
<a target="_blank" href="https://firebase.google.com/docs/hosting/">Open Hosting Documentation</a>
</div>
<p id="load">Firebase SDK Loading&hellip;</p>
<script>
document.addEventListener('DOMContentLoaded', function() {
const loadEl = document.querySelector('#load');
// // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
// // The Firebase SDK is initialized and available here!
//
// firebase.auth().onAuthStateChanged(user => { });
// firebase.database().ref('/path/to/ref').on('value', snapshot => { });
// firebase.firestore().doc('/foo/bar').get().then(() => { });
// firebase.functions().httpsCallable('yourFunction')().then(() => { });
// firebase.messaging().requestPermission().then(() => { });
// firebase.storage().ref('/path/to/ref').getDownloadURL().then(() => { });
// firebase.analytics(); // call to activate
// firebase.analytics().logEvent('tutorial_completed');
// firebase.performance(); // call to activate
//
// // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
try {
let app = firebase.app();
let features = [
'auth',
'database',
'firestore',
'functions',
'messaging',
'storage',
'analytics',
'remoteConfig',
'performance',
].filter(feature => typeof app[feature] === 'function');
loadEl.textContent = `Firebase SDK loaded with ${features.join(', ')}`;
} catch (e) {
console.error(e);
loadEl.textContent = 'Error loading the Firebase SDK, check the console.';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,85 @@
// scripts/migrate-geohash-precision.js
//
// One-time migration: re-encodes every existing message's `geohash` field at
// precision 9 (previously 6), matching the precision addMessage() now writes
// for new messages (see src/lib/firebase/messages.js). lat/lng/text/etc are
// read from each doc but never written back - updateDoc() below is only ever
// called with { geohash: newGeohash }, so this cannot touch any other field.
//
// Firestore's security rules normally only allow `echoCount`/`lastEchoAt` to
// change via update() (see firestore.rules). Before running this script,
// temporarily add 'geohash' to that allowlist and deploy:
//
// firebase deploy --only firestore:rules
//
// Then run this script:
//
// node scripts/migrate-geohash-precision.js
//
// Then revert firestore.rules back to its original allowlist and redeploy.
import { initializeApp } from 'firebase/app';
import { getFirestore, collection, getDocs, doc, updateDoc } from 'firebase/firestore';
import ngeohash from 'ngeohash';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
// --- load PUBLIC_FIREBASE_* values from .env (same file SvelteKit reads via
// $env/static/public, which only works inside SvelteKit - this script is a
// plain Node script, so .env is parsed by hand here instead) --------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const envPath = join(__dirname, '..', '.env');
const env = {};
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
const firebaseConfig = {
apiKey: env.PUBLIC_FIREBASE_API_KEY,
authDomain: env.PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: env.PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: env.PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: env.PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: env.PUBLIC_FIREBASE_APP_ID
};
const NEW_PRECISION = 9; // matches addMessage() in src/lib/firebase/messages.js
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const snapshot = await getDocs(collection(db, 'messages'));
console.log(`found ${snapshot.docs.length} message(s)`);
let updated = 0;
let skipped = 0;
for (const docSnap of snapshot.docs) {
const data = docSnap.data();
if (typeof data.lat !== 'number' || typeof data.lng !== 'number') {
console.warn(`skipping ${docSnap.id}: missing lat/lng`);
skipped++;
continue;
}
const newGeohash = ngeohash.encode(data.lat, data.lng, NEW_PRECISION);
if (data.geohash === newGeohash) {
skipped++;
continue;
}
// ONLY the geohash field is written - everything else on the doc is
// left exactly as it was.
await updateDoc(doc(db, 'messages', docSnap.id), { geohash: newGeohash });
console.log(`${docSnap.id}: ${data.geohash} -> ${newGeohash}`);
updated++;
}
console.log(`done. updated ${updated}, skipped ${skipped}`);

View File

@@ -5,6 +5,7 @@
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
import { getAuth } from 'firebase/auth';
import {
PUBLIC_FIREBASE_API_KEY,
PUBLIC_FIREBASE_AUTH_DOMAIN,
@@ -25,4 +26,5 @@ const firebaseConfig = {
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const storage = getStorage(app);
export const storage = getStorage(app);
export const auth = getAuth(app); // anonymous auth, gives each device a persistent UID

View File

@@ -1,22 +1,148 @@
import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries
import { db } from './config'; // database connection
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
import { doc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
import { collection, query, where, getDocs, addDoc, getCountFromServer, onSnapshot } from 'firebase/firestore'; // tools for building and running db queries
import { db, auth } from './config'; // database connection + anonymous auth
import { getNearbyGeohashCells } from '$lib/utils/geohash'; // convert coordinates into a 3x3 grid of geohash cell prefixes (handles cell-boundary edge cases - see geohash.js)
import { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
import ngeohash from 'ngeohash';
import { checkAndAwardStamp } from './stamps.js'; // "passport stamps" - see stamps.js
// --- global "memory counter" -----------------------------------------------
// A single document (meta/stats) holds `totalMessagesEverPosted`: a running
// total of every message ever created in the whole app, across all areas -
// including ones that have since expired/decayed and dropped out of
// getNearbyMessages's "active" results. It's deliberately a single shared
// doc, separate from individual message documents, because it represents a
// different thing: not "what's currently visible/active" (which shrinks as
// messages decay) but "everything that has ever been said here" (which only
// grows). See addMessage() and getTotalMessageCount() below.
const statsRef = doc(db, 'meta', 'stats');
export async function getNearbyMessages(lat, lng) {
const prefix = getQueryPrefix(lat, lng);
// query the user's geohash cell AND its 8 neighbors (3x3 grid) rather
// than a single prefix range - see getNearbyGeohashCells in geohash.js
// for why a single cell isn't reliable enough across devices.
const cells = getNearbyGeohashCells(lat, lng);
const q = query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
const snapshots = await Promise.all(
cells.map(prefix => getDocs(query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + '')
)))
);
const snapshot = await getDocs(q);
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
// merge the 9 result sets into one list. Neighboring geohash cells never
// overlap, so each message can only appear in one snapshot - but we
// dedupe by doc id anyway as a defensive measure, in case that ever changes.
const seen = new Map();
for (const snapshot of snapshots) {
for (const docSnap of snapshot.docs) {
if (!seen.has(docSnap.id)) {
seen.set(docSnap.id, { id: docSnap.id, ...docSnap.data() });
}
}
}
const all = [...seen.values()];
// we filter out messages which have already expired (past their echo date)
const now = Date.now();
const active = all.filter(message => {
const echoTime = message.lastEchoAt?.toMillis() ?? message.createdAt.toMillis();
const daysSinceEcho = (now - echoTime) / (1000 * 60 * 60 * 24);
return daysSinceEcho < 30; // less than 30 means it lives / is active
});
return active;
}
// --- live "pins pop into existence" listener --------------------------
// Real-time counterpart to getNearbyMessages() above. Instead of one-time
// getDocs() calls, this opens 9 onSnapshot listeners (the same 3x3 geohash
// cell grid) that keep firing for as long as the subscription is open -
// whenever a message in this area is created, echoed, or ages past the
// 30-day "active" window, onChange() is called again with the freshly
// merged + re-filtered list. +page.svelte uses this to detect messages
// that arrive WHILE the user is actively looking at the map (as opposed
// to the one-time initial load from getNearbyMessages above), so it can
// play the "pop" sound and bounce-animate just those new pins.
//
// The active/expired filter below intentionally mirrors getNearbyMessages
// - kept as its own copy (rather than a shared helper) so this listener's
// merge-on-every-snapshot logic stays self-contained.
//
// Returns an unsubscribe function that tears down all 9 listeners - call
// it when the map/page unmounts.
export function subscribeToNearbyMessages(lat, lng, onChange) {
const cells = getNearbyGeohashCells(lat, lng);
// each cell's listener writes its latest batch of docs into its own
// slot here; every listener re-merges all 9 slots whenever ANY of them
// fires, so onChange always receives the full up-to-date "nearby" set.
const cellDocs = cells.map(() => []);
const unsubscribes = cells.map((prefix, i) =>
onSnapshot(
query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + '\uF8FF')
),
(snapshot) => {
cellDocs[i] = snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
// merge the 9 cells the same way getNearbyMessages does -
// neighboring cells never overlap, dedupe by id defensively
const seen = new Map();
for (const docs of cellDocs) {
for (const message of docs) {
seen.set(message.id, message);
}
}
const now = Date.now();
const active = [...seen.values()].filter(message => {
const echoTime = message.lastEchoAt?.toMillis() ?? message.createdAt.toMillis();
const daysSinceEcho = (now - echoTime) / (1000 * 60 * 60 * 24);
return daysSinceEcho < 30;
});
onChange(active);
}
)
);
return () => unsubscribes.forEach(unsub => unsub());
}
// --- "you're the first here" check (+page.svelte) -------------------------
// getNearbyMessages() above already fetches every doc matching the geohash
// prefix into `all`, then filters out anything past its 30-day decay into
// `active`. So `active.length === 0` alone can't tell apart two very
// different situations: "nobody has EVER posted in this ~1.2km area" vs
// "people posted here before, but everything since faded/expired".
// Rather than re-fetching and re-filtering all those documents again, this
// runs the SAME geohash range query through getCountFromServer - a
// Firestore aggregation query that returns just a number without
// transferring any document data, so it stays cheap even though it repeats
// the query. Returns true if at least one message (active or expired) has
// ever existed in this area.
export async function hasAnyMessagesEverNearby(lat, lng) {
// same 3x3 cell grid as getNearbyMessages, for the same boundary reason -
// neighboring cells never overlap, so the counts can simply be summed.
const cells = getNearbyGeohashCells(lat, lng);
const counts = await Promise.all(
cells.map(prefix => getCountFromServer(query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + '')
)))
);
return counts.some(snapshot => snapshot.data().count > 0);
}
// update the echo counter
export async function echoMessage(messageId) {
const ref = doc(db, 'messages', messageId);
await updateDoc(ref, {
@@ -25,27 +151,110 @@ export async function echoMessage(messageId) {
});
}
export async function addMessage(lat, lng, text, imageUrl = ''){
const geohash = ngeohash.encode(lat, lng, 6);
// adding the message location
// moodColor (optional): the author's picked pastel swatch from ComposeSheet's
// mood-color row, as an hsl(...) string - or null if they didn't pick one.
// stored as-is on the message doc; pins.js falls back to a random pastel
// when this is null (see pinColor() in pins.js).
export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null){
// stored at max precision (9, ~5m cells) so getQueryPrefix() in
// geohash.js can use any precision up to 9 without breaking the
// >= / < prefix-range query in getNearbyMessages (a shorter query
// prefix can match a longer stored geohash, but not vice versa).
const geohash = ngeohash.encode(lat, lng, 9);
await addDoc(collection(db, 'messages'), {
text,
imageUrl,
lat,
text,
imageUrl,
lat,
lng,
geohash,
geohash,
moodColor,
createdAt: serverTimestamp(),
lastEchoAt: serverTimestamp(),
echoCount: 0,
sessionId: getSessionId()
// links the message to this device's persistent anonymous Firebase UID
authorId: auth.currentUser?.uid ?? 'anon'
});
// --- global "memory counter": increment-only ----------------------------
// Same increment(1) pattern as echoCount above, but on the shared
// meta/stats doc instead of this message's own doc. setDoc with
// { merge: true } both creates meta/stats on the very first call (if it
// doesn't exist yet - increment() treats a missing field as starting from
// 0) and increments the existing field on every call after that. This
// counter never decrements: when a message later expires/decays out of
// getNearbyMessages's active results, nothing here is touched, so the
// total keeps representing everything ever posted, not just what's
// currently visible.
// Wrapped in try/catch so a failure here (e.g. security rules not yet
// deployed for meta/stats) never breaks the actual posting of the
// message above - the counter is a nice-to-have, not core functionality.
try {
await setDoc(statsRef, { totalMessagesEverPosted: increment(1) }, { merge: true });
} catch (err) {
console.error('[memory counter] failed to increment meta/stats:', err);
}
// --- "passport stamps": award a stamp if this is a new geohash-4 region -
// checkAndAwardStamp (stamps.js) reverse-geocodes lat/lng and writes a
// new stamp doc only if this user has never posted from this ~city-sized
// region before. Wrapped in try/catch like the memory counter above - a
// failed geocode or permission error here should never prevent the
// message itself from posting.
try {
await checkAndAwardStamp(lat, lng);
} catch (err) {
console.error('[stamps] failed to check/award stamp:', err);
}
}
function getSessionId() {
let id = localStorage.getItem('overheard_session');
if (!id) {
id = crypto.randomUUID(); // created random useer id
localStorage.setItem('oveheard_session', id);
// fetch the current value of the global "memory counter" (meta/stats), for
// the GlobalCountPill. Returns 0 if no message has ever been posted yet (the
// doc won't exist until the very first addMessage() call above), or if the
// read fails for any reason (e.g. security rules not yet deployed) - the pill
// simply won't render in that case (see GlobalCountPill's $globalCount check).
export async function getTotalMessageCount() {
try {
const snap = await getDoc(statsRef);
return snap.exists() ? (snap.data().totalMessagesEverPosted ?? 0) : 0;
} catch (err) {
console.error('[memory counter] failed to read meta/stats:', err);
return 0;
}
return id;
}
// --- direct by-ID lookup, for shared links/QR codes (SharePopover.svelte,
// +page.svelte's auto-open-on-load logic) ----------------------------------
// getNearbyMessages() above can only ever find messages inside the current
// device's ~1.2km geohash prefix - a message shared as a link/QR code may
// have been left anywhere in the world, so opening it can't go through that
// geohash filter at all. getDoc-by-ID is a direct document lookup that
// works regardless of geohash, at the cost of needing the exact message ID
// (which is exactly what the shared link/QR encodes). Returns null (rather
// than throwing) if the doc doesn't exist or the read fails, so callers can
// show a graceful "this one has already faded" message instead of an error.
export async function getMessageById(id) {
try {
const ref = doc(db, 'messages', id);
const snap = await getDoc(ref);
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
} catch (err) {
console.error('[share link] failed to fetch message by id:', err);
return null;
}
}
// fetch every message this device has authored, for the archive page
export async function getMyMessages() {
const uid = auth.currentUser?.uid;
if (!uid) return []; // not signed in yet, nothing to show
const q = query(
collection(db, 'messages'),
where('authorId', '==', uid)
);
const snapshot = await getDocs(q);
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}

View File

@@ -0,0 +1,79 @@
import { db, auth } from './config';
import { doc, getDoc, setDoc, getDocs, collection, serverTimestamp } from 'firebase/firestore';
import { getRegionPrefix } from '$lib/utils/geohash.js';
import { reverseGeocode } from '$lib/utils/geocode.js';
import { pickStampIcon } from '$lib/utils/stampIcons.js';
import { stampsStore } from '$lib/stores/stampsStore.js';
// same pastel formula as messagePin()'s randomPastel() in pins.js, so stamp
// colors feel consistent with the pin palette - random per stamp (doesn't
// need to be deterministic, unlike the icon below)
function randomStampColor() {
const hue = Math.floor(Math.random() * 360);
return `hsl(${hue}, 60%, 72%)`;
}
// --- "passport stamps" ------------------------------------------------------
// Each user/device (anonymous Firebase UID, see userStore.js) earns one
// stamp per distinct geohash-4 region (~20-40km, roughly city-sized - see
// getRegionPrefix in geohash.js) they've ever posted a message from. Stamps
// live at users/{uid}/stamps/{geohash4} - using the geohash-4 prefix itself
// as the document ID means "does this user already have a stamp for this
// region" is a single getDoc by ID, and re-posting from the same region can
// never create a duplicate stamp.
//
// Called from addMessage() (messages.js) after a message successfully posts.
export async function checkAndAwardStamp(lat, lng) {
const uid = auth.currentUser?.uid;
if (!uid) return; // not signed in yet - shouldn't happen since initAuth() runs on app load, but bail safely
const geohash4 = getRegionPrefix(lat, lng);
const ref = doc(db, 'users', uid, 'stamps', geohash4);
const existing = await getDoc(ref);
if (existing.exists()) return; // already have a stamp for this region
// --- reverse geocode: turn these coordinates into a place name ---------
// reverseGeocode() (geocode.js) calls the Google Geocoding API and
// returns { city, country } - falling back to 'Unknown'/'Unknown' if the
// lookup fails, so a failed geocode still earns a (generically-labeled)
// stamp rather than blocking.
const { city, country } = await reverseGeocode(lat, lng);
// --- deterministic icon, random color -----------------------------------
// pickStampIcon hashes "City, Country" so the same place always gets the
// same icon; the color is a fresh random pastel per stamp.
const icon = pickStampIcon(city, country);
const stamp = {
geohash4,
city,
country,
iconId: icon.id,
color: randomStampColor(),
earnedAt: serverTimestamp()
};
await setDoc(ref, stamp);
// keep stampsStore (drives the bottom-nav/desktop stamp-book icons) in
// sync immediately, without a full getStamps() refetch
stampsStore.update(stamps => [...stamps, stamp]);
}
// fetch every stamp this user has earned, for the stamp book page - also
// refreshes stampsStore so the nav icons reflect the latest count
export async function getStamps() {
const uid = auth.currentUser?.uid;
if (!uid) return [];
try {
const snapshot = await getDocs(collection(db, 'users', uid, 'stamps'));
const stamps = snapshot.docs.map(d => d.data());
stampsStore.set(stamps);
return stamps;
} catch (err) {
console.error('[stamps] failed to fetch stamps:', err);
return [];
}
}

View File

@@ -0,0 +1,11 @@
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'
import { storage } from './config.js';
export async function uploadImage(file) {
//create unique file name and stores them in one folder in firebase
const filename = `messages/${Date.now()}_${file.name}`;
const storageRef = ref(storage, filename);
await uploadBytes(storageRef, file);
return getDownloadURL(storageRef);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

BIN
src/lib/assets/open app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

View File

@@ -2,6 +2,15 @@
import { mapStore } from '$lib/stores/mapStore.js';
import { getDecayInfo } from '$lib/utils/time.js'
import { echoMessage } from '$lib/firebase/messages.js'
import { playEchoTone } from '$lib/utils/sound.js';
import SharePopover from '$lib/components/SharePopover.svelte';
import {
incrementRead,
incrementEchoed,
incrementLetGo,
hasSeenLastChancePrompt,
markLastChancePromptSeen
} from '$lib/utils/stats.js';
let { message } = $props();
@@ -9,34 +18,141 @@
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
);
// --- "last chance" state ----------------------------------------------
// A message is on its genuinely LAST day if it has 1 (or 0) days left
// AND isn't already expired/faded - daysLeft <= 1 && !isExpired.
// getDecayInfo() derives daysLeft from lastEchoAt, so a message that was
// recently echoed already has a much higher daysLeft and won't trigger
// this - "hasn't been echoed recently enough to have more time" is
// automatically satisfied whenever this condition is true.
let isLastChance = $derived(
decay ? (decay.daysLeft <= 1 && !decay.isExpired) : false
);
let echoed = $state(false);
// one-time "last chance" prompt for the currently-open message
let showLastChancePrompt = $state(false);
// tracks which message was open last time this effect ran, so the
// read-count + prompt logic below only fires once per "open" (not on
// every reactive re-run while the same message stays selected)
let previousMessageId = null;
$effect(() => {
const currentId = message?.id ?? null;
if (currentId && currentId !== previousMessageId) {
// --- engagement stat: a detail view was just opened ----------
incrementRead();
// --- one-time "last chance" prompt ----------------------------
// only show it if this message is CURRENTLY in its last-chance
// state AND this device hasn't already seen the prompt for this
// message ID. Marking it seen immediately (rather than waiting
// for an explicit dismiss) means it won't reappear even if the
// user navigates away without closing it.
if (isLastChance && !hasSeenLastChancePrompt(currentId)) {
showLastChancePrompt = true;
markLastChancePromptSeen(currentId);
} else {
showLastChancePrompt = false;
}
}
if (!currentId) {
showLastChancePrompt = false; // closed - reset for the next open
}
previousMessageId = currentId;
});
async function handleEcho() {
await echoMessage(message.id);
echoed = true;
// engagement stat: Echo pressed
incrementEchoed();
// taking action dismisses the last-chance prompt for this message
showLastChancePrompt = false;
// gentle ascending "thank you" tone confirming the echo went through
playEchoTone();
}
// Let go's behavior is unchanged (just closes the sheet) - this only
// adds the engagement-stat increment and prompt dismissal alongside it
function handleLetGo() {
incrementLetGo();
showLastChancePrompt = false;
mapStore.set({ selectedMessage: null, composing: false });
}
let startY = 0; // where the swipe started
function startDrag(e) {
startY = e.clientY; // remember the starting y position
window.addEventListener('pointerup', endDrag, { once: true }); // wait for them to let go
}
function endDrag(e) {
const diff = e.clientY - startY; // how far down they dragged
// if they dragged down more than 60px, close the sheet
if (diff > 60) {
mapStore.set({ selectedMessage: null, composing: false });
}
}
</script>
<!-- if message exists, sheet is visible -->
<div class="sheet" class:visible={!!message}>
{#if message}
<div class="handle"> </div>
<div class="content">
<!-- drag this down to close the sheet -->
<!-- bigger invisible area so the bar is actually easy to grab -->
<div
class="handle-area"
role="button"
tabindex="0"
aria-label="drag down to close"
onpointerdown={startDrag}
>
<div class="handle"></div>
</div>
<div class="content" class:last-chance={isLastChance}>
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if}
<p class="message-text">{message.text}</p>
{#if decay}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{#if isLastChance}
<!-- "last chance" framing replaces the normal countdown text -->
<p class="meta last-chance-text">this is the last chance to keep this alive</p>
{:else}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{/if}
{/if}
{#if showLastChancePrompt}
<!-- one-time prompt (see stats.js) - gently frames Echo as
the way to give this message more time, without being
pushy. Dismissible on its own, or implicitly dismissed
by pressing Echo/Let go below. -->
<div class="last-chance-prompt">
<p>if this one means something, an echo will keep it here a little longer.</p>
<button class="dismiss-prompt" onclick={() => showLastChancePrompt = false} aria-label="dismiss">×</button>
</div>
{/if}
<div class="actions">
<button class="echo-button"
class:echoed={echoed}
onclick={handleEcho}
<button class="echo-button"
class:echoed={echoed}
onclick={handleEcho}
disabled={echoed}>
{echoed ? 'Echoed' : 'Echo'}
</button>
<button class="letgo-button" onclick={() => mapStore.set(
{ selectedMessage: null, composing: false })}>
<button class="letgo-button" onclick={handleLetGo}>
let go
</button>
<!-- share link + QR code popover (see SharePopover.svelte) -->
<SharePopover {message} />
</div>
</div>
{/if}
@@ -50,8 +166,13 @@
right: 0;
background: white;
border-radius: 20px 20px 0 0;
padding: 1rem 1.5rem 2rem;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
/* extra 64px of bottom padding = the height of .bottom-nav in
+page.svelte (z-index 200, above this sheet's 100), which sits on
top of the sheet's bottom edge and was covering the Echo/Let go
buttons. pushing the buttons up by that much keeps them clear of
the nav bar instead of hidden behind it. */
padding: 1rem 1.5rem calc(2rem + 64px);
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
z-index: 100;
@@ -61,12 +182,20 @@
transform: translateY(0);
}
.handle-area {
display: flex;
justify-content: center;
padding: 12px 0;
margin-bottom: 0.25rem;
touch-action: none; /* stop the browser from treating the drag as a page scroll */
}
.handle {
width: 40px;
height: 4px;
background: #ddd;
border-radius: 2px;
margin: 0 auto 1rem;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.message-text {
@@ -82,6 +211,58 @@
margin-bottom: 1.2rem;
}
/* "last chance" treatment - a soft warm peach tint + border, distinct
from the normal white card but staying within the existing pastel/
parchment palette. A faint glow (box-shadow) adds a touch of warmth
without being a jarring "alert" color like red. */
.content.last-chance {
background: #fff6ea;
border: 1px solid #f3dcb8;
border-radius: 14px;
padding: 0.9rem;
margin: -0.25rem -0.25rem 0;
box-shadow: 0 0 0 4px rgba(243, 184, 92, 0.08);
}
/* replaces the normal "fading in X days" meta line when isLastChance */
.last-chance-text {
color: #b08a5a;
font-weight: 500;
}
/* one-time prompt shown above the action buttons - same warm palette as
.content.last-chance, slightly more saturated so it reads as the
"active" element within the card */
.last-chance-prompt {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
background: #fdf0db;
border: 1px solid #f3dcb8;
border-radius: 10px;
padding: 0.6rem 0.75rem;
margin-bottom: 0.9rem;
font-size: 0.8rem;
color: #9c7240;
line-height: 1.5;
}
.last-chance-prompt p {
margin: 0;
}
.dismiss-prompt {
background: none;
border: none;
color: #c4a37a;
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.actions {
display: flex;
gap: 0.75rem;
@@ -96,6 +277,7 @@
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.letgo-button {
@@ -107,6 +289,7 @@
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.echo-button.echoed {
@@ -114,6 +297,15 @@
animation: pulsate 1.5s ease-in-out 3;
}
.message-img{
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 12px;
margin-bottom: 0.75rem;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
@keyframes pulsate {
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}

View File

@@ -1,49 +1,297 @@
<script>
import { mapStore } from '$lib/stores/mapStore.js';
import { messagesStore } from '$lib/stores/messagesStore.js';
import { setMessages } from '$lib/stores/messagesStore.js';
import { refreshGlobalCount } from '$lib/stores/globalCountStore.js';
import { addMessage } from '$lib/firebase/messages.js'
import { hasFace } from '$lib/utils/faceDetection.js';
import { uploadImage } from '$lib/Firebase/storage.js';
let {lat, lng} = $props();
// isMobile drives whether this renders as the original bottom sheet
// (mobile, unchanged) or a centered popup with a dimmed backdrop (desktop)
let {lat, lng, isMobile} = $props();
// shared close/cancel handler - used by the Cancel button and, on
// desktop, by clicking the backdrop outside the popup
function close() {
mapStore.set({selectedMessage: null, composing: false});
}
let text = $state('');
let submitting = $state(false);
let remaining = $derived(240-text.length);
async function handleSubmit() {
if (!text.trim() || remaining < 0) return;
submitting = true;
// --- rotating placeholder prompts -------------------------------------
// A small set of incomplete, contemplative fragments that cycle through
// as a placeholder for the textarea while it's empty - a gentle nudge
// toward a certain kind of writing, without dictating what gets written.
const placeholderPrompts = [
"today I noticed...",
"if you're reading this...",
"this place...",
"I wish someone knew...",
"the air smells like...",
"before I forget..."
];
await addMessage(lat, lng, text.trim());
// index of the prompt currently shown, and its crossfade opacity
let promptIndex = $state(0);
let promptOpacity = $state(1);
//refresh the messages store so pin is there
const { getNearbyMessages } = await import('$lib/firebase/messages.js');
const updated = await getNearbyMessages(lat, lng);
messagesStore.set(updated);
// whether the textarea currently has focus - tracked via onfocus/onblur
// on the textarea below, since this overlay's visibility needs to react
// to focus in JS, not just CSS.
let textareaFocused = $state(false);
//reset and close
text = '';
submitting = false;
mapStore.set({selectedMessage: null, composing: false});
// The overlay (see markup below) only exists while the textarea is both
// empty and unfocused. The moment either condition flips - the user
// starts typing, or just taps/clicks into the field - the prompts get
// out of the way entirely, per the feature spec.
let showPromptOverlay = $derived(text.length === 0 && !textareaFocused);
// Cycles through placeholderPrompts on a timer, crossfading between them
// via promptOpacity. This effect only runs while showPromptOverlay is
// true; the moment it becomes false (typing starts, or the field gains
// focus), the cleanup below clears any pending timers and the cycle
// simply stops - clearing the textarea (and leaving it unfocused) makes
// showPromptOverlay true again, re-running this effect and starting a
// fresh cycle.
$effect(() => {
if (!showPromptOverlay) return;
// Timing mirrors the fade conventions used elsewhere in the app
// (the opening ritual / "first here" bubble): a multi-second hold
// per prompt, then a ~1s crossfade into the next one.
const holdMs = 3000;
const fadeMs = 1000;
let fadeOutTimer;
let advanceTimer;
// Schedules one hold -> fade-out -> advance -> fade-in step, then
// schedules itself again. An $effect only re-runs when the values it
// READS change, and this step only WRITES promptIndex/promptOpacity,
// so the cycle has to keep itself going via setTimeout rather than
// relying on the effect re-running on its own.
function scheduleStep() {
fadeOutTimer = setTimeout(() => {
promptOpacity = 0; // begin crossfade out
advanceTimer = setTimeout(() => {
promptIndex = (promptIndex + 1) % placeholderPrompts.length;
promptOpacity = 1; // crossfade the new prompt in
scheduleStep();
}, fadeMs);
}, holdMs);
}
scheduleStep();
// cleanup: runs when showPromptOverlay flips to false (typing starts
// or the field is focused) and on component unmount - clears
// whichever of the two timers is currently pending so the cycle
// stops cleanly with no stray callbacks.
return () => {
clearTimeout(fadeOutTimer);
clearTimeout(advanceTimer);
};
});
let selectedFile = $state(null);
let imagePreview = $state(null); // what <img> will read
let imageError = $state(null);
let checkingFace = $state(false); // this will show the checking image... message
// --- mood color (intentionally unexplained feature) -------------------
// 5 preset pastel swatches, same hue/saturation/lightness formula as
// pins.js's randomPastel() (hsl(hue, 60%, 72%)) so a picked color sits
// naturally alongside the existing pin palette. Picking one is optional:
// selectedMoodColor stays null until tapped, and tapping the already-
// selected swatch again clears it back to null. null flows through to
// addMessage() -> Firestore as moodColor: null, and pins.js falls back to
// its existing random pastel for any pin with no moodColor.
const moodColors = [
'hsl(340, 60%, 72%)', // soft pink
'hsl(210, 60%, 72%)', // soft blue
'hsl(140, 60%, 72%)', // soft green
'hsl(265, 60%, 72%)', // soft purple
'hsl(40, 60%, 72%)' // soft peach/yellow
];
let selectedMoodColor = $state(null);
function toggleMoodColor(color) {
// tapping the selected swatch again deselects it (back to "no opinion")
selectedMoodColor = selectedMoodColor === color ? null : color;
}
async function handleImageSelect(event) {
const file = event.target.files[0]; // only one file will be allowed so always index 0
if (!file) return; // no file do nothing
checkingFace = true; // show loading state
imageError = null; // clear errors
try {
// run face detection
const faceFound = await hasFace(file);
if (faceFound) {
// no upload & error
imageError = 'Images may not contain faces. Please choose another photo.';
//reset file input for other attempt
event.target.value = '';
selectedFile= null;
imagePreview = null;
} else {
// no face we move on
selectedFile = file;
// create preview
imagePreview = URL.createObjectURL(file);
}
} catch (err) {
// fixed: previously allowed the upload on any detection error — wrong for a
// face-blocking feature. Now we surface the error and block the upload instead.
console.error('[faceDetection] error:', err);
imageError = 'Could not verify the image. Please try a different photo.';
event.target.value = '';
selectedFile = null;
imagePreview = null;
} finally {
// no matter what turn the loading state off
checkingFace = false;
}
}
async function handleSubmit() {
// no submit with no text or text over limit
if (!text.trim() || remaining < 0) return;
submitting = true;
let imageUrl = ''; // stays empty if no image
if (selectedFile) {
imageUrl = await uploadImage(selectedFile);
}
await addMessage(lat, lng, text.trim(), imageUrl, selectedMoodColor);
// the global "memory counter" (meta/stats) just incremented inside
// addMessage() above - refetch it so the pill's count updates
// immediately for this device, without waiting on a page reload
refreshGlobalCount();
// reload pins so that they show up with new message
const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
const updated = await getNearbyMessages(lat, lng);
// setMessages (instead of messagesStore.set) compares against the
// previous list and plays a soft chime for any newly-appeared pins
setMessages(updated);
// reset compose state
text = '';
selectedFile = null;
imagePreview = null;
selectedMoodColor = null;
submitting = false;
// close the sheet
mapStore.set({selectedMessage: null, composing:false});
}
</script>
<div class="compose" class:visible={true}>
<!-- desktop-only dimmed backdrop behind the centered popup; clicking it
cancels, like a typical modal dialog. mobile keeps the plain bottom
sheet with no backdrop, so this is skipped entirely there -->
{#if !isMobile}
<!-- a plain <button> (not a <div>) so it's keyboard-focusable/operable
by default, satisfying a11y click-handler rules without extra
role/tabindex/onkeydown wiring -->
<button class="backdrop" onclick={close} aria-label="Close compose popup"></button>
{/if}
<!-- class:desktop switches between the original bottom-sheet styles
(mobile, untouched) and the centered popup styles (desktop) -->
<div class="compose" class:visible={true} class:desktop={!isMobile}>
<div class="compose-header">
<button class="cancel" onclick={() => mapStore.set(
{selectedMessage: null, composing: false}
)}>
<button class="cancel" onclick={close}>
Cancel
</button>
<span class="title">Leave a message</span>
<span class="title">Leave a message</span>
<span class="counter" class:over={remaining < 0}>{remaining}</span>
</div>
<textarea
bind:value={text}
placeholder="Share a moment for someone else to overhear"
rows="5"
></textarea>
<!-- wrapper gives the rotating-placeholder overlay below a positioning context -->
<div class="textarea-wrap">
<textarea
bind:value={text}
aria-label="Leave a message"
onfocus={() => textareaFocused = true}
onblur={() => textareaFocused = false}
rows="5"
></textarea>
<!-- Rotating placeholder overlay: the native placeholder attribute
can't crossfade between values, so this is a plain <p>
absolutely positioned over the textarea instead, styled to look
like placeholder text. It only exists in the DOM while the
field is empty and unfocused (showPromptOverlay above) -
aria-hidden since it's an ambient visual cue rather than real
content, and pointer-events: none so it never blocks clicks
into the textarea underneath. -->
{#if showPromptOverlay}
<p class="prompt-overlay" style="opacity: {promptOpacity}" aria-hidden="true">
{placeholderPrompts[promptIndex]}
</p>
{/if}
</div>
<!-- mood color swatches: 5 preset pastel colors, no labels or explanation
by design (see project brief / pins.js). selecting one is optional -
a subtle ring + scale-up marks the selected swatch; tapping it again
deselects. -->
<div class="mood-row">
{#each moodColors as color}
<button
type="button"
class="mood-swatch"
class:selected={selectedMoodColor === color}
style="background-color: {color}"
aria-label="color"
onclick={() => toggleMoodColor(color)}
></button>
{/each}
</div>
<div class = "image-section">
{#if checkingFace}
<p class="checking">Checking image...</p>
{:else if imagePreview}
<div class="preview-wrap">
<img class="preview" src={imagePreview} alt="preview" />
<button class="remove-image" onclick={() => { selectedFile = null; imagePreview = null; }}>
X
</button>
</div>
{:else}
{#if imageError}
<p class="image-error">{imageError}</p>
{/if}
<label class="image-label">
+ add photo
<input
type="file"
accept="image/*"
onchange={handleImageSelect}
style="display:none"
/>
</label>
{/if}
</div>
<button
class="submit"
@@ -55,6 +303,8 @@
</div>
<style>
/* mobile (default): unchanged bottom sheet, full-width, anchored to
the bottom edge with only the top corners rounded */
.compose {
position: fixed;
bottom: 0;
@@ -63,10 +313,66 @@
background: white;
border-radius: 20px 20px 0 0;
padding: 1.2rem 1.5rem 2.5rem;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
z-index: 200;
}
/* border-box on the popup and everything inside it - without this,
elements like textarea/.submit (width: 100% + their own padding/border)
added their padding/border ON TOP of that 100%, so they rendered
wider than .compose and stuck out past its rounded edges */
.compose, .compose * {
box-sizing: border-box;
}
/* desktop: centered popup instead of a stretched bottom sheet.
overrides position/sizing/rounding from .compose above */
.compose.desktop {
bottom: auto;
left: 50%;
right: auto;
top: 50%;
transform: translate(-50%, -50%);
width: 650px; /* slightly smaller than the previous 900px, but... */
aspect-ratio: 1; /* ...paired with this, makes height = width -> a big square popup */
max-width: 95vw;
/* no max-height/overflow-y - the flex layout below lets the textarea
grow/shrink to fill the square instead of causing a scrollbar */
display: flex;
flex-direction: column;
border-radius: 20px; /* round all corners, not just the top */
padding: 1.2rem 1.5rem 1.5rem; /* less bottom padding needed without the bottom-sheet handle area */
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
/* sit above the backdrop below (350) - was relying on the inherited
.compose z-index of 200, which the backdrop now exceeds */
z-index: 360;
}
/* on desktop, .compose-header/.image-section/.submit keep their natural
height and the textarea (flex: 1 below) grows/shrinks to fill the
remaining square space. flex props are no-ops on mobile since
.compose there isn't a flex container */
.compose-header,
.image-section,
.submit {
flex-shrink: 0;
}
/* dimmed full-screen overlay behind the desktop popup; clicking it
cancels. raised above .legend (200) and the global .archive-link
(300, in +layout.svelte) so those dim along with everything else
instead of floating above the overlay - this only ever renders on
desktop (!isMobile), so mobile is unaffected */
.backdrop {
/* reset <button> defaults so it behaves like the plain overlay div it replaced */
border: none;
cursor: default;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 350;
}
.compose-header {
display: flex;
align-items: center;
@@ -100,6 +406,27 @@
font-weight: 600;
}
/* positioning context for the rotating-placeholder overlay (see
.prompt-overlay below). Plain block on mobile, sized by the
textarea's natural height; .compose.desktop below gives it the
flex-growing role textarea itself used to have. */
.textarea-wrap {
position: relative;
}
/* on desktop, .textarea-wrap takes over the flex-growing role that
textarea used to have on its own, so the wrapper - and the textarea
inside it - still fill the remaining square space. Scoped to desktop
only: on mobile .compose isn't a flex container, and an unscoped
display: flex here would make .textarea-wrap a flex column with no
defined height, collapsing the textarea inside it to 0 height. */
.compose.desktop .textarea-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
textarea {
width: 100%;
border: 1.5px solid #eee;
@@ -111,12 +438,40 @@
outline: none;
font-family: Georgia, 'Times New Roman', Times, serif;
color: #111;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
/* fill the remaining square space on desktop (no-op on mobile,
where .compose isn't a flex container); min-height: 0 lets it
shrink below its content size instead of overflowing the flex column */
flex: 1;
min-height: 0;
}
textarea:focus {
border-color: #111;
}
/* visually mimics the textarea's placeholder text - same font, size,
line-height and padding as the textarea (the extra 1.5px on padding
accounts for the textarea's border under box-sizing: border-box, so
the overlay text lines up with where typed text would actually
start), but lighter in color. Absolutely positioned over the textarea
so promptOpacity (set in the $effect above) can crossfade it between
prompts - something a native placeholder attribute can't do. */
.prompt-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
margin: 0;
padding: calc(0.9rem + 1.5px);
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 0.95rem;
line-height: 1.6;
color: #aaa;
pointer-events: none;
transition: opacity 1s ease;
}
.submit {
width: 100%;
margin-top: 0.75rem;
@@ -128,11 +483,100 @@
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.submit:disabled {
background: #ccc;
cursor: not-allowed;
}
/* mood color swatch row - small circles, no text/labels by design */
.mood-row {
display: flex;
gap: 0.6rem;
margin-top: 0.75rem;
}
.mood-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
/* outline (not box-shadow, for the flat 2D look) starts transparent
so the ring only appears once .selected adds a visible color -
transitioning it avoids a layout jump */
outline: 2px solid transparent;
outline-offset: 2px;
transition: outline-color 0.15s, transform 0.15s;
}
/* selected swatch: dark ring + slight scale-up, the only visual cue
that a color is picked */
.mood-swatch.selected {
outline-color: #111;
transform: scale(1.15);
}
.image-section {
margin-top: 0.75rem;
}
.image-label {
display: inline-block;
/* fixed pre-existing typo ("paddingL" -> "padding:") that was throwing a CSS error */
padding: 0.5rem 1rem;
border: 1.5px dashed #ddd;
border-radius: 8px;
font-size: 0.85rem;
color: #999;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.image-label:hover {
border-color: #111;
color: #111;
}
.preview-wrap {
position: relative;
display: inline-block;
}
.preview {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 10px;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.remove-image {
position: absolute;
top: 6px;
right: 6px;
background: rgba(0,0,0,0.5);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 0.7rem;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.checking {
font-size: 0.85rem;
color: #999;
}
.image-error {
font-size: 0.85rem;
color: #e74c3c;
}
</style>

View File

@@ -0,0 +1,137 @@
<script>
import { onMount } from 'svelte';
import { globalCount, refreshGlobalCount } from '$lib/stores/globalCountStore.js';
// --- mobile tap/fade easter egg ----------------------------------------
// On desktop, the secondary "places remember..." line is always visible
// (see .secondary-desktop in the stylesheet below, shown via the
// min-width media query). On mobile it's hidden by default and only
// fades in when the pill is tapped, holds for a few seconds, then fades
// back out on its own - an easter egg rather than a persistent UI
// element. `secondaryVisible` is local Svelte 5 rune state driving the
// opacity/max-height transition on .secondary-mobile; .secondary-desktop
// is unaffected by it.
let secondaryVisible = $state(false);
let fadeOutTimer;
onMount(() => {
refreshGlobalCount();
});
function handleTap() {
secondaryVisible = true;
// restart the hold timer on every tap, so a second tap while the
// text is already visible just extends how long it stays up rather
// than racing with the previous fade-out
clearTimeout(fadeOutTimer);
fadeOutTimer = setTimeout(() => {
secondaryVisible = false;
}, 3000);
}
</script>
<!-- only render once the first fetch resolves, so the pill never briefly
shows a misleading "0 messages have been left worldwide" before the
real count arrives -->
{#if $globalCount !== null}
<button type="button" class="count-pill" onclick={handleTap}>
<span class="count-main">{$globalCount.toLocaleString()} messages have been left worldwide</span>
<!-- desktop: always-visible secondary line (hidden on mobile via the
same media query that reveals it - see <style> below) -->
<span class="count-secondary secondary-desktop">
while not all of them are visible to you or have been echoed long enough to reach you, places remember and memories live on in intangible ways.
</span>
<!-- mobile: same text, but only fades in on tap (handleTap above)
and is hidden entirely on desktop -->
<span class="count-secondary secondary-mobile" class:visible={secondaryVisible}>
while not all of them are visible to you or have been echoed long enough to reach you, places remember and memories live on in intangible ways.
</span>
</button>
{/if}
<style>
/* small rounded pill, top-right of the app on every page - cream
background + subtle shadow + muted text, matching the "first here"
speech bubble and opening ritual overlay's palette */
.count-pill {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 150;
max-width: 60vw;
background: #f9f7f4;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px;
padding: 0.5rem 0.9rem;
text-align: left;
font-family: Georgia, 'Times New Roman', Times, serif;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
cursor: pointer;
}
.count-main {
display: block;
font-size: 0.7rem;
color: #888;
line-height: 1.4;
letter-spacing: 0.02em;
}
.count-secondary {
display: block;
font-size: 0.65rem;
color: #aaa;
line-height: 1.5;
}
/* desktop-only secondary line: hidden on mobile by default */
.secondary-desktop {
display: none;
}
/* mobile-only secondary line: hidden + collapsed until tapped, then
fades in/out via .visible (toggled by handleTap above). max-height
(rather than display) is what allows the opacity transition to
actually animate instead of snapping. */
.secondary-mobile {
opacity: 0;
max-height: 0;
overflow: hidden;
margin-top: 0;
transition: opacity 0.6s ease, max-height 0.6s ease;
}
.secondary-mobile.visible {
opacity: 1;
max-height: 6rem;
margin-top: 0.35rem;
}
@media (min-width: 768px) {
.count-pill {
max-width: 320px;
padding: 0.65rem 1rem;
/* tap-to-reveal is mobile-only; desktop already shows everything,
so the pointer cursor would be misleading here */
cursor: default;
}
.count-main {
font-size: 0.8rem;
}
.secondary-desktop {
display: block;
margin-top: 0.35rem;
}
/* desktop already shows .secondary-desktop, so the mobile
tap-to-reveal copy is never shown here regardless of state */
.secondary-mobile {
display: none;
}
}
</style>

View File

@@ -1,18 +1,77 @@
<script>
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { env } from '$env/dynamic/public';
import { messagesStore } from '$lib/stores/messagesStore.js'; // pass the messages store here
import { mapStore } from '$lib/stores/mapStore.js'; // use this to track interactions with da map
import { messagesStore, livePinIdsStore } from '$lib/stores/messagesStore.js'; // messages store, plus live-arrival ids for the "pop" animation below
import { mapStore } from '$lib/stores/mapStore.js'; // use this to track interactions with da map
import { messagePin, locationPin } from '$lib/utils/pins.js'; // custom SVG pin generators
import { mapStyles } from '$lib/utils/mapStyles.js'; // desaturated/atmospheric map styling - only works now that mapId is gone
import { getTrail, addTrailPoint } from '$lib/utils/trail.js'; // private, on-device-only location history
// export let latitude;
// export let longitude;
// ^ this didn't work for some reason, so instead we get the props like this: (based on internet research this is the fix)
let { lat, lng } = $props();
// firstHereMounted/firstHereVisible: "you're the first here" bubble state,
// computed in +page.svelte (zero-results check + fade timing) - rendered
// here so it can be positioned near the user's location marker below.
let { lat, lng, firstHereMounted = false, firstHereVisible = false } = $props();
let mapDiv;
let map = $state(null);
let markers = []; // keep track of pins on map
// Marker/Size/Point/Polyline are only available once their libraries
// have loaded (assigned in onMount below) - declared here so
// toMarkerIcon, addUserLocationMarker, renderPins, and the trail effect
// can all use them.
let Marker, Size, Point, Polyline;
// --- personal location trail (private, on-device only) ----------------
// `trailPoints` holds this device's full visit history (see trail.js),
// read/appended once in onMount. `trailPolyline` is the Polyline
// instance once created (lazily, inside the $effect below) - plain
// variables, not runes, since they're written once and don't need to
// trigger re-renders themselves.
let trailPoints = [];
let trailPolyline;
// `showTrail` is the only piece of UI state for this feature - toggled
// by the small button in the markup below. Defaults to ON: the trail is
// styled to be faint enough (see the $effect below) that it doesn't
// compete with message pins, so showing it by default lets people
// notice this quiet "personal map of presence" without it being loud -
// the toggle just lets them hide it if they'd rather not see it.
let showTrail = $state(true);
// converts a { url, size } pin (from pins.js) into an Icon object for
// the legacy Marker API - centers the icon on its coordinate (anchor at
// size/2, size/2) the same way the old AdvancedMarkerElement content's
// `translateY(-50%)` did, but for both axes since Marker's default
// anchor is bottom-center rather than center-center.
function toMarkerIcon({ url, size }) {
return {
url,
scaledSize: new Size(size, size),
anchor: new Point(size / 2, size / 2)
};
}
let userMarker;
/** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
function addUserLocationMarker(centerLat, centerLng) {
// use the shared locationPin() so the user's marker matches the
// style of the message pins, instead of building a one-off dot here
userMarker = new Marker({
position: { lat: centerLat, lng: centerLng },
map,
title: 'Your location',
zIndex: 1000,
icon: toMarkerIcon(locationPin())
});
}
onMount (async () => {
const centerLat = Number(lat);
const centerLng = Number(lng);
@@ -23,50 +82,314 @@
version: 'weekly',
});
const { Map } = await importLibrary('maps');
// switched from AdvancedMarkerElement back to the legacy Marker API
// (with SVG data-URL icons from pins.js) so a JS `styles` array can
// take effect on this map again - Google ignores any `styles` array
// on maps that have a mapId set, and AdvancedMarkerElement requires
// one. NOTE: the legacy `Marker` class (plus `Size`/`Point`) live in
// the 'marker'/'core' libraries, NOT 'maps' - both are still needed.
// Polyline (used by the location trail below) lives in 'maps'
// alongside Map.
const { Map, Polyline: PolylineCtor } = await importLibrary('maps');
Polyline = PolylineCtor;
({ Marker } = await importLibrary('marker'));
({ Size, Point } = await importLibrary('core'));
mapDiv = new Map(mapDiv, {
// --- personal location trail: log this visit ----------------------
// addTrailPoint() writes { lat, lng, timestamp: Date.now() } to
// localStorage and returns the FULL updated history (oldest first),
// including this visit - so the line drawn below is always
// up to date. This runs unconditionally on every successful
// geolocation fetch (MapView only mounts once lat/lng are known),
// regardless of `showTrail` - the history keeps growing even while
// the trail is hidden. Nothing here touches Firestore; it's purely
// local to this device/browser (see trail.js).
trailPoints = addTrailPoint(centerLat, centerLng);
map = new Map(mapDiv, {
center: { lat: centerLat, lng: centerLng },
zoom: 15,
// bumped from 15 -> 17: at 15 the initial view was wider than the
// ~150m-per-side geohash cells getNearbyMessages queries, so
// nearby pins opened tiny/clustered near the center. 17 frames
// roughly that query area on open, so pins are visible without
// the user needing to zoom in first.
zoom: 18,
disableDefaultUI: true,
gestureHandling: 'greedy'
gestureHandling: 'greedy',
// mapId removed - legacy Markers don't need it, and removing it
// is what lets this `styles` array be respected (see comment above) -
// desaturated/atmospheric look defined in mapStyles.js
styles: mapStyles
});
addUserLocationMarker(centerLat, centerLng);
});
// function to rended pins
// --- "pins pop into existence" bounce animation ------------------------
// google.maps.Marker (legacy API) has no built-in entrance animation, and
// its icon is a static data-URL image rather than a DOM element, so a
// CSS transition/keyframe animation isn't an option here. Instead, this
// swaps the marker's icon through a few different `size` values over
// ~300ms via setIcon(): tiny -> overshoot (115%) -> normal (100%).
// toMarkerIcon() rebuilds the Size/anchor for each size, so the icon
// stays centered on its coordinate throughout - the net effect reads as
// a quick "pop in with a little bounce", the same shape as a CSS
// `scale(0) -> scale(1.15) -> scale(1)` keyframe animation.
function animatePinBounce(marker, icon) {
const steps = [
{ scale: 0.01, delay: 0 }, // start effectively invisible
{ scale: 1.15, delay: 90 }, // overshoot past full size
{ scale: 1.0, delay: 220 } // settle to normal size
];
steps.forEach(({ scale, delay }) => {
setTimeout(() => {
const size = Math.max(1, Math.round(icon.size * scale));
marker.setIcon(toMarkerIcon({ url: icon.url, size }));
}, delay);
});
}
// function to render pins
function renderPins(messages) {
// clear current pins
markers.forEach(marker => marker.setMap(null)); // make them not show up
markers = []; // reset the array
markers.forEach(marker => marker.setMap(null));
markers = [];
// ids of pins that just arrived via the real-time listener (see
// subscribeToNearbyMessages, messages.js) since the last render -
// these get the "pop" bounce below. Everything else (initial load,
// refresh, moving to a new area) appears at full size immediately,
// same as before.
const newLiveIds = get(livePinIdsStore);
messages.forEach(message => {
const marker = new google.maps.Marker({
position: { lat: message.lat, lng: message.lng}, // lat and lng is what is called in the firestore documents
map: mapDiv,
title: message.text // firestore field for messages
// every message gets the same plain circle pin (previously this
// varied - star/circle/heart - based on read/unread/echoed state,
// swapped on click; that's gone now, so clicking a pin never
// changes its appearance, only opens the detail view below).
// message.moodColor (optional, set by ComposeSheet's swatch row and
// stored in Firestore via addMessage) overrides the random pastel
// color inside messagePin() when present - see pins.js's pinColor()
const icon = messagePin(message.moodColor);
const isLivePin = newLiveIds.has(message.id);
const marker = new Marker({
position: { lat: message.lat, lng: message.lng },
map,
title: message.text,
// live-arrival pins start at near-zero size so
// animatePinBounce below has somewhere to animate FROM -
// everything else renders at full size immediately
icon: toMarkerIcon(isLivePin ? { ...icon, size: 1 } : icon),
// higher than addUserLocationMarker's zIndex (1000) - a
// message posted from exactly where you're standing sits at
// the SAME coordinates as your location dot, and without
// this, the larger (32px) location marker draws on top and
// completely hides the smaller (28px) message pin beneath it.
zIndex: 1001
});
// legacy Marker uses the Maps JS event system (addListener), not
// DOM events - this also avoids the AdvancedMarkerElement
// gmp-click deprecation warning entirely
marker.addListener('click', () => {
mapStore.set({ selectedMessage: message, composing: false}); //it updated the message object
mapStore.set({ selectedMessage: message, composing: false });
});
markers.push(marker); // add the new pin to the array
if (isLivePin) {
animatePinBounce(marker, icon);
}
markers.push(marker);
});
// consume the live-pin set now that we've rendered it, so a later
// render triggered by an unrelated change (e.g. someone echoing an
// existing message) doesn't re-bounce these same pins again
if (newLiveIds.size > 0) {
livePinIdsStore.set(new Set());
}
}
// this is a reactive statement so anytime the store changes it updates
$effect(() => {
if (mapDiv && $messagesStore.length > 0 ){ // if they both exist
if (map && $messagesStore.length > 0 ){ // if they both exist
renderPins($messagesStore); // we put pins on the map
}
})
// --- personal location trail: draw + toggle ---------------------------
// Re-runs whenever `map` (becomes available once, in onMount) or
// `showTrail` (toggled by the button below) changes. `trailPoints` and
// `Polyline` are plain variables, not runes - they're both assigned in
// onMount BEFORE `map` is, in the same synchronous block, so by the time
// `map` becomes non-null and triggers this effect, they're already set
// (same pattern the renderPins effect above relies on for Marker/etc).
$effect(() => {
if (!map || !Polyline) return;
// lazily create the Polyline once, the first time the map is ready.
// Edge case: a single point (first-ever visit) has nothing to
// connect, so we simply don't create a line - trailPolyline stays
// undefined and the toggle below has nothing to show/hide.
if (!trailPolyline && trailPoints.length >= 2) {
const path = trailPoints.map(p => ({ lat: p.lat, lng: p.lng }));
trailPolyline = new Polyline({
path,
clickable: false, // purely decorative - shouldn't intercept clicks meant for the map/pins
zIndex: 1, // sit beneath message/location pins (which default to higher stacking)
// the solid stroke is fully transparent - only the dash
// `icons` below are actually drawn, which is how dashed
// lines are done with google.maps.Polyline (it has no
// native dash-pattern option)
strokeOpacity: 0,
icons: [{
icon: {
path: 'M 0,-1 0,1', // a short vertical dash
strokeOpacity: 0.35, // faint - shouldn't compete visually with message pins
strokeColor: '#999', // neutral grey, fits the desaturated/parchment map palette
scale: 3
},
offset: '0',
repeat: '14px' // gap between dashes
}]
});
}
// show/hide based on the toggle - setMap(null) removes it from the
// map without destroying the instance, so toggling back on doesn't
// need to rebuild the path.
if (trailPolyline) {
trailPolyline.setMap(showTrail ? map : null);
}
})
</script>
<div class="map" bind:this={mapDiv}></div>
<div class="map-wrapper">
<div class="map" bind:this={mapDiv}></div>
<!-- small, unobtrusive toggle for the personal location trail (private,
on-device only - see trail.js). Placed top-left so it's discoverable
without competing with the FAB (bottom-right) or top bars elsewhere. -->
<button
class="trail-toggle"
class:active={showTrail}
onclick={() => showTrail = !showTrail}
aria-label={showTrail ? 'Hide your location trail' : 'Show your location trail'}
title={showTrail ? 'Hide your location trail' : 'Show your location trail'}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M1.5 8 H5 M7.5 8 H9.5 M12 8 H14.5" stroke-dasharray="2 2"/>
</svg>
</button>
<!-- "you're the first here" speech bubble. The map is created with
center: {lat, lng} (see onMount) and the user-location marker is
drawn at that same center point, so positioning this bubble at
50%/50% of .map-wrapper places it directly above the marker. -->
{#if firstHereMounted}
<p class="first-here" class:visible={firstHereVisible} aria-hidden="true">
no one has been here before — or at least, no one who said so.
</p>
{/if}
</div>
<style>
/* positioning context for .trail-toggle, which is absolutely placed
within the map area regardless of the desktop/mobile layout in
+page.svelte */
.map-wrapper {
position: relative;
width: 100%;
height: 100%;
}
/* fill the .map-container parent (height: 100% instead of min-height: 100vh)
so that the parent's padding-bottom on mobile actually shrinks the
visible map instead of the map overflowing past it */
.map {
width: 100%;
min-height: 100vh;
height: 100%;
}
/* small circular toggle, top-left of the map - flat 2D style (no
shadow) matching the rest of the app's current look. Muted/translucent
so it doesn't draw attention away from the map itself; .active just
darkens it slightly to show the trail is currently on. */
.trail-toggle {
position: absolute;
top: 1rem;
left: 1rem;
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.7);
color: #aaa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 120;
transition: color 0.15s, border-color 0.15s;
}
.trail-toggle.active {
color: #666;
border-color: rgba(0, 0, 0, 0.15);
}
/* "you're the first here" speech bubble - "cute speech bubble" look:
soft cream background (same #f9f7f4 as the opening ritual overlay in
+layout.svelte) with rounded corners, so the text reads clearly
against the desaturated map. Positioned at the center of .map-wrapper
(= the user-location marker's position, see comment in the markup
above) and shifted up + left by its own size/2 plus the marker's
16px radius, so the bubble sits just above the marker with its tail
(::after below) pointing down at it. Starts invisible; .visible fades
it in/out over 1.2s (timing controlled by the $effects in +page.svelte). */
.first-here {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, calc(-100% - 16px));
margin: 0;
padding: 0.85rem 1.4rem;
max-width: 80%;
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 0.95rem;
color: #666;
letter-spacing: 0.05em;
text-align: center;
line-height: 1.6;
opacity: 0;
transition: opacity 1.2s ease;
pointer-events: none;
z-index: 50;
background: #f9f7f4;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px;
}
.first-here.visible {
opacity: 1;
}
/* small triangular "tail" pointing down from the bubble toward the
location marker beneath it */
.first-here::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #f9f7f4;
}
</style>

View File

@@ -0,0 +1,230 @@
<script>
// Share button + popover for the message detail view (BottomSheet on
// mobile, SidePanel on desktop) - shared between both so the link/QR
// logic and styling only live in one place.
import QRCode from 'qrcode';
// --- portal action --------------------------------------------------------
// On mobile, this component is rendered inside BottomSheet's `.sheet`,
// which has a `transform` (its slide-up/down animation). A `transform` on
// an ancestor creates its own containing block for `position: fixed`
// descendants, so the popover's `top: 50%`/`left: 50%` were being measured
// against `.sheet`'s box (anchored to the bottom of the screen) instead of
// the actual viewport - centering it too low and cutting it off at the
// bottom. Moving the popover/backdrop to a direct child of <body> (which
// has no transform) restores normal viewport-relative `position: fixed`
// centering on both mobile and desktop.
function portal(node) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
}
let { message } = $props();
// popover open/closed, the generated QR code image, and "copied" feedback
let open = $state(false);
let qrDataUrl = $state(null);
let copied = $state(false);
// --- share URL ----------------------------------------------------------
// `?message=<id>` on the root route, e.g. https://yourapp.web.app/?message=abc123.
// A query param on `/` (rather than a new /message/[id] route) was chosen
// because `/` already does everything opening a shared message needs - it
// loads the map, gets the user's location, etc. +page.svelte's auto-open
// logic just reads this param once the map has loaded (see its onMount/
// $effect) and sets mapStore.selectedMessage, then clears the param.
// window.location.origin is only available in the browser, but this is
// only ever read after a click (openShare below), so it's never evaluated
// during SSR.
let shareUrl = $derived(
message ? `${window.location.origin}/?message=${message.id}` : ''
);
// Generates the QR code as soon as the popover opens, rather than eagerly
// for every message (most messages are never shared, so there's no point
// paying the encoding cost up front).
// QRCode.toDataURL renders the code as a PNG and returns it as a base64
// data: URL - the simplest way to get it into an <img src>, with no
// <canvas> element or ref to manage. `qrcode` is a small, dependency-free
// client-side library, so this works fully offline and never calls out to
// a third-party QR-generation API.
async function openShare() {
open = true;
copied = false;
qrDataUrl = await QRCode.toDataURL(shareUrl, { width: 180, margin: 1 });
}
function closeShare() {
open = false;
}
async function copyLink() {
await navigator.clipboard.writeText(shareUrl);
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
</script>
<!-- icon-only button, sized to sit alongside Echo/Let go in .actions -->
<button
type="button"
class="share-button"
onclick={openShare}
aria-label="Share this message"
title="Share this message"
>
<!-- "export"-style share icon: arrow up out of a tray -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 16V4" />
<path d="M7 8 L12 3 L17 8" />
<path d="M5 12v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
</svg>
</button>
{#if open}
<!-- use:portal moves this whole group to a direct child of <body>, so the
fixed-position backdrop/popover below center on the real viewport
instead of BottomSheet's transformed `.sheet` (see the portal action
above) -->
<div use:portal>
<!-- full-screen button (not a div) so it's keyboard-focusable, same
click-outside-to-close pattern as ComposeSheet's .backdrop -->
<button class="share-backdrop" onclick={closeShare} aria-label="Close share popover"></button>
<div class="share-popover">
<button class="share-close" onclick={closeShare} aria-label="Close">×</button>
<p class="share-title">Share this message</p>
{#if qrDataUrl}
<img class="share-qr" src={qrDataUrl} alt="QR code linking to this message" />
{/if}
<div class="share-link-row">
<!-- readonly input rather than plain text so the URL is easy to
select/copy manually too, as a fallback to the button below -->
<input
class="share-link-input"
type="text"
readonly
value={shareUrl}
onclick={(e) => e.target.select()}
/>
<button class="share-copy" onclick={copyLink}>
{copied ? 'Copied!' : 'Copy link'}
</button>
</div>
</div>
</div>
{/if}
<style>
/* small square icon button, matching the flat 2D style of
.echo-button/.letgo-button but not flex: 1 - it's a secondary action
alongside the two main ones */
.share-button {
flex: 0 0 auto;
width: 44px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: #111;
border: 1.5px solid #ddd;
border-radius: 10px;
cursor: pointer;
}
/* dimmed backdrop behind the popover, click to close - same approach as
ComposeSheet's .backdrop, raised above it (z-index 350) since the
popover can be opened from inside the compose-less message view but
should still sit above any other overlay */
.share-backdrop {
border: none;
cursor: default;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 400;
}
/* small centered card, styled consistently with the app's pastel/
parchment palette (white card, soft rounded corners, muted text) */
.share-popover {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 401;
background: #fdfaf5;
border-radius: 16px;
padding: 1.25rem;
width: 260px;
max-width: 90vw;
text-align: center;
font-family: Georgia, 'Times New Roman', Times, serif;
}
.share-close {
position: absolute;
top: 0.5rem;
right: 0.75rem;
background: none;
border: none;
font-size: 1.2rem;
line-height: 1;
color: #bbb;
cursor: pointer;
padding: 0.25rem;
}
.share-title {
margin: 0 0 0.9rem;
font-size: 0.95rem;
color: #555;
}
/* QR code rendered at a modest, readable size - not the full card width */
.share-qr {
width: 160px;
height: 160px;
border-radius: 8px;
margin: 0 auto 0.9rem;
display: block;
}
.share-link-row {
display: flex;
gap: 0.4rem;
}
.share-link-input {
flex: 1;
min-width: 0;
font-family: sans-serif;
font-size: 0.75rem;
color: #888;
background: #f5f1ea;
border: 1px solid #eee;
border-radius: 8px;
padding: 0.4rem 0.5rem;
}
.share-copy {
flex-shrink: 0;
font-family: sans-serif;
font-size: 0.75rem;
font-weight: 500;
background: #c4a8f5;
color: white;
border: none;
border-radius: 8px;
padding: 0.4rem 0.6rem;
cursor: pointer;
}
</style>

View File

@@ -1,119 +1,532 @@
<script>
import { mapStore } from '$lib/stores/mapStore.js';
import { getDecayInfo } from '$lib/utils/time.js';
import { echoMessage } from '$lib/firebase/messages.js'
// SidePanel is the desktop sidebar. It has two views in the same fixed
// panel: a list of nearby messages (default), and a message detail view
// (image/text/echo/let go) shown when a pin or list row is selected.
// Echo, Let go, and the back arrow all clear the selection, which
// switches the view back to the list.
import { mapStore } from '$lib/stores/mapStore.js';
import { messagesStore } from '$lib/stores/messagesStore.js';
import { getDecayInfo } from '$lib/utils/time.js';
import { echoMessage } from '$lib/firebase/messages.js';
import { playEchoTone } from '$lib/utils/sound.js';
import { getPresenceText } from '$lib/utils/presence.js';
import SharePopover from '$lib/components/SharePopover.svelte';
import {
incrementRead,
incrementEchoed,
incrementLetGo,
hasSeenLastChancePrompt,
markLastChancePromptSeen
} from '$lib/utils/stats.js';
let { message } = $props();
// currently-selected message; when set, the detail view is shown instead of the list
let { message } = $props();
let decay = $derived(
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
);
// fade-out timing for the selected message (detail view only)
let decay = $derived(
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
);
let echoed = $state(false);
// --- "last chance" state -------------------------------------------------
// A message is on its genuinely LAST day if it has 1 (or 0) days left AND
// isn't already expired/faded - daysLeft <= 1 && !isExpired. getDecayInfo()
// derives daysLeft from lastEchoAt, so a message that was recently echoed
// already has a much higher daysLeft and won't trigger this - "hasn't been
// echoed recently enough to have more time" is automatically satisfied
// whenever this condition is true.
let isLastChance = $derived(
decay ? (decay.daysLeft <= 1 && !decay.isExpired) : false
);
async function handleEcho() {
await echoMessage(message.id);
echoed = true;
// one-time "last chance" prompt for the currently-open message
let showLastChancePrompt = $state(false);
// tracks which message was open last time this effect ran, so the
// read-count + prompt logic below only fires once per "open" (not on
// every reactive re-run while the same message stays selected)
let previousMessageId = null;
$effect(() => {
const currentId = message?.id ?? null;
if (currentId && currentId !== previousMessageId) {
// --- engagement stat: a detail view was just opened -----------------
incrementRead();
// --- one-time "last chance" prompt -----------------------------------
// only show it if this message is CURRENTLY in its last-chance state
// AND this device hasn't already seen the prompt for this message ID.
// Marking it seen immediately (rather than waiting for an explicit
// dismiss) means it won't reappear even if the user navigates away
// without closing it.
if (isLastChance && !hasSeenLastChancePrompt(currentId)) {
showLastChancePrompt = true;
markLastChancePromptSeen(currentId);
} else {
showLastChancePrompt = false;
}
}
if (!currentId) {
showLastChancePrompt = false; // closed - reset for the next open
}
previousMessageId = currentId;
});
// only show the 3 most recent nearby messages, so the list view fits
// without scrolling and the hint card stays visible underneath it
let topMessages = $derived(
[...$messagesStore]
.sort((a, b) => b.createdAt.toMillis() - a.createdAt.toMillis())
.slice(0, 3)
);
// ambient presence indicator ("X people have been here today"), recomputed
// whenever the nearby-messages store updates (i.e. whenever
// getNearbyMessages refreshes the data)
let presenceText = $derived(getPresenceText($messagesStore));
// back to the list view
function close() {
mapStore.set({ selectedMessage: null, composing: false });
}
async function handleEcho() {
await echoMessage(message.id);
// engagement stat: Echo pressed
incrementEchoed();
// gentle ascending "thank you" tone confirming the echo went through
playEchoTone();
close(); // back to the list once echoed
}
// close()'s behavior is unchanged (back to the list) - this only adds the
// engagement-stat increment and prompt dismissal alongside it
function handleLetGo() {
incrementLetGo();
showLastChancePrompt = false;
close();
}
</script>
<div class = "panel">
{#if message}
<div class="content">
<p class="message-text">{message.text}</p>
{#if decay}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{/if}
<div class="actions">
<button class="echo-button"
class:echoed={echoed}
onclick={handleEcho}
disabled={echoed}>
{echoed ? 'Echoed' : 'Echo'}
</button>
<button class="letgo-button" onclick={() => mapStore.set(
{selectedMessage: null, composing: false})}>
Let go
</button>
<div class="panel">
{#if message}
<!-- detail view: shown for the selected message -->
<button class="back-btn" onclick={close}> Back</button>
<!-- wraps both the message content and the action buttons, so the
"last chance" gold/cream container can extend to include the
buttons too - mirrors BottomSheet.svelte's .content wrapper, which
already does this, for visual consistency between mobile and desktop -->
<div class="detail-wrap" class:last-chance={isLastChance}>
<div class="detail-content">
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if}
<p class="message-text">{message.text}</p>
{#if decay}
{#if isLastChance}
<!-- "last chance" framing replaces the normal countdown text -->
<p class="meta last-chance-text">this is the last chance to keep this alive</p>
{:else}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{/if}
{/if}
{#if showLastChancePrompt}
<!-- one-time prompt (see stats.js) - gently frames Echo as the way
to give this message more time, without being pushy. Dismissible
on its own, or implicitly dismissed by pressing Echo/Let go below. -->
<div class="last-chance-prompt">
<p>if this one means something, an echo will keep it here a little longer.</p>
<button class="dismiss-prompt" onclick={() => showLastChancePrompt = false} aria-label="dismiss">×</button>
</div>
{/if}
</div>
<div class="actions">
<button class="echo-button" onclick={handleEcho}>
Echo
</button>
<button class="letgo-button" onclick={handleLetGo}>
Let go
</button>
<!-- share link + QR code popover (see SharePopover.svelte) -->
<SharePopover {message} />
</div>
</div>
{:else}
<!-- list view: header, compose button, nearby messages -->
<div class="panel-header">
<h1>Overheard: Shared memories</h1>
<!-- ambient presence indicator replacing the old static "Messages near
you" text - styling/font-size unchanged, only the copy is dynamic -->
<p class="subtitle">{presenceText}</p>
</div>
<hr />
<!-- compose button: opens the compose sheet, deselecting any open message -->
<button
class="compose-btn"
onclick={() => mapStore.set({ selectedMessage: null, composing: true })}>
+ Leave a message here
</button>
<!-- message list -->
<p class="section-label">RECENT & NEARBY</p>
<div class="message-list">
{#if $messagesStore.length === 0}
<!-- shown while no messages have loaded for this area yet -->
<div class="empty-card">
<p>No messages nearby yet</p>
<p class="empty-sub">Be the first to leave one</p>
</div>
{:else}
{#each topMessages as msg}
<!-- compute fade-out timing per message -->
{@const itemDecay = getDecayInfo(msg.createdAt, msg.lastEchoAt)}
<div
class="message-item"
class:selected={message?.id === msg.id}
onclick={() => mapStore.set({ selectedMessage: msg, composing: false })}>
<p class="msg-text">{msg.text}</p>
<div class="msg-meta">
<span>🕐 {itemDecay.daysAgo}d ago</span>
<span></span>
<span>{itemDecay.daysLeft}d left</span>
<span></span>
<!-- show an echo/image indicator if applicable -->
<span>{msg.echoCount > 0 ? '🤍' : ''}{msg.imageUrl ? '🖼' : ''}</span>
</div>
</div>
{:else}
<div class="empty">
<p>Tap a pin to read a message</p>
</div>
{/if}
</div>
{/each}
{/if}
</div>
<!-- hint card, always pinned to the bottom of the panel -->
<div class="hint-card">
<p>Messages appear as you explore</p>
<p class="hint-sub">Tap the confetti on the map to read them</p>
</div>
<!-- replaces the old top-right floating archive button (desktop only -
mobile already has an Archive tab in the bottom nav). styled to
match .compose-btn so the two pill buttons feel like a pair -->
<a href="/archive" class="archive-btn">
Your archive
</a>
{/if}
</div>
<style>
.panel {
position: fixed;
top: 0;
left: 0;
width: 320px;
height: 100vh;
background: white;
box-shadow: 2px 0 12px rgba(0,0,0,0.1);
padding: 2rem 1.5rem;
z-index: 100;
overflow-y: auto;
}
.panel {
position: fixed;
top: 0;
left: 0;
width: 340px;
height: 100vh;
/* without this, the 1.5rem/1.2rem padding below was added ON TOP of the
100vh height, pushing the panel taller than the viewport - since the
panel is position:fixed, that extra height just got clipped off the
bottom, cutting off the Echo/Let go buttons */
box-sizing: border-box;
background: white;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
display: flex;
flex-direction: column;
padding: 1.5rem 1.2rem;
z-index: 100;
gap: 0.8rem;
font-family: sans-serif;
}
.message-text{
font-size: 1rem;
line-height: 1.6;
color: #111;
margin-bottom: 0.5rem;
}
.panel-header h1 {
font-size: 1.3rem;
font-weight: 700;
color: #111;
margin-bottom: 0.2rem;
}
.meta {
font-size: 1rem;
line-height: 1.6;
color: #111;
margin-bottom: 0.5rem;
}
/* detail view */
.actions {
display: flex;
gap: 0.75rem;
}
.back-btn {
align-self: flex-start;
background: none;
border: none;
padding: 0.4rem 0;
font-size: 0.9rem;
font-weight: 500;
color: #888;
cursor: pointer;
}
.echo-button {
flex: 1;
padding: 0.75rem;
background: #111;
color: white;
border: none;
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
}
.back-btn:hover {
color: #111;
}
.letgo-button {
flex: 1;
padding: 0..75rem;
background: transparent;
color: #111;
border: 1.5px solid #ddd;
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
}
.message-img {
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 12px;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.empty {
color: #999;
font-size: 0.9rem;
margin-top: 2rem;
text-align: center;
}
.message-text {
font-size: 0.95rem;
line-height: 1.6;
color: #111;
}
.echo-button.echoed {
background: #4ecdc4;
animation: pulsate 1.5s ease-in-out 3;
}
.meta {
font-size: 0.75rem;
color: #999;
}
@keyframes pulsate {
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}
}
/* wraps .detail-content and .actions together - lets the "last chance"
background below extend around the action buttons too, not just the
message text. flex: 1 fills the remaining height of .panel (taking over
the role .actions's margin-top: auto used to play directly inside
.panel), so .actions (still margin-top: auto, now relative to this
wrapper) stays pinned to the bottom either way. */
.detail-wrap {
display: flex;
flex-direction: column;
flex: 1;
/* neutral gray card (same palette as .hint-card below) wrapping the
message and its action buttons together, so they read as one
connected unit rather than floating separately - .last-chance below
overrides this with a warm gold treatment for the same purpose. */
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 14px;
padding: 0.9rem;
margin: -0.25rem -0.25rem 0;
}
/* "last chance" treatment - a soft warm peach tint + border, distinct from
the normal gray card above but staying within the existing pastel/
parchment palette. A faint glow (box-shadow) adds a touch of warmth
without being a jarring "alert" color like red. */
.detail-wrap.last-chance {
background: #fff6ea;
border: 1px solid #f3dcb8;
box-shadow: 0 0 0 4px rgba(243, 184, 92, 0.08);
}
/* replaces the normal "fading in X days" meta line when isLastChance */
.last-chance-text {
color: #b08a5a;
font-weight: 500;
}
/* one-time prompt shown above the action buttons - same warm palette as
.detail-wrap.last-chance, slightly more saturated so it reads as the
"active" element within the card */
.last-chance-prompt {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
background: #fdf0db;
border: 1px solid #f3dcb8;
border-radius: 10px;
padding: 0.6rem 0.75rem;
margin-top: 0.75rem;
font-size: 0.8rem;
color: #9c7240;
line-height: 1.5;
}
.last-chance-prompt p {
margin: 0;
}
.dismiss-prompt {
background: none;
border: none;
color: #c4a37a;
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: auto;
}
.echo-button {
flex: 1;
padding: 0.75rem;
background: #c4a8f5;
color: white;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.echo-button:hover {
background: #b090e8;
}
.letgo-button {
flex: 1;
padding: 0.75rem;
background: transparent;
color: #111;
border: 1.5px solid #ddd;
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.subtitle {
font-size: 0.8rem;
color: #aaa;
}
hr {
border: none;
border-top: 1px solid #eee;
margin: 0.2rem 0;
}
.compose-btn {
width: 100%;
padding: 0.75rem;
background: #c4a8f5;
color: white;
border: none;
border-radius: 50px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.compose-btn:hover {
background: #b090e8;
}
/* same pill-button look as .compose-btn, but on an <a> tag, so it needs
its own block/text-align/text-decoration resets to read as a button
rather than an inline link */
.archive-btn {
width: 100%;
box-sizing: border-box;
display: block;
text-align: center;
text-decoration: none;
padding: 0.75rem;
background: #c4a8f5;
color: white;
border-radius: 50px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.archive-btn:hover {
background: #b090e8;
}
.section-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
color: #bbb;
margin-top: 0.4rem;
}
.message-list {
display: flex;
flex-direction: column;
gap: 0;
}
.message-item {
padding: 0.9rem 0.5rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
border-radius: 8px;
transition: background 0.15s;
}
.message-item:hover {
background: #fafafa;
}
.message-item.selected {
background: #faf5ff;
border-left: 3px solid #c4a8f5;
padding-left: 0.8rem;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.msg-text {
font-size: 0.9rem;
color: #111;
line-height: 1.5;
margin-bottom: 0.3rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.msg-meta {
display: flex;
gap: 0.4rem;
font-size: 0.75rem;
color: #bbb;
align-items: center;
}
.hint-card {
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 12px;
padding: 1rem;
text-align: center;
font-size: 0.85rem;
color: #999;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.hint-sub {
font-size: 0.75rem;
color: #bbb;
margin-top: 0.3rem;
}
.empty-card {
text-align: center;
padding: 1.5rem;
color: #999;
font-size: 0.85rem;
}
.empty-sub {
font-size: 0.75rem;
color: #bbb;
margin-top: 0.3rem;
}
</style>

View File

@@ -0,0 +1,69 @@
<script>
import { STAMP_ICONS } from '$lib/utils/stampIcons.js';
// stamp: { city, country, iconId, color } - see stamps.js for the shape
// written to Firestore. iconId is looked up against STAMP_ICONS (falling
// back to the first icon if it's ever missing/renamed).
let { stamp } = $props();
let icon = $derived(STAMP_ICONS.find(i => i.id === stamp.iconId) ?? STAMP_ICONS[0]);
</script>
<!-- round "passport stamp" badge: icon in the upper portion, city/country
text below it, both inside the circle. Rotation/scatter positioning for
the stamp book is applied by the parent (see StampBook page), not here -
this component is just the badge itself. -->
<div class="stamp" style="background: {stamp.color}">
<svg class="stamp-icon" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
{@html icon.paths}
</svg>
<p class="stamp-place">
<span class="city">{stamp.city}</span>
<span class="country">{stamp.country}</span>
</p>
</div>
<style>
.stamp {
width: 120px;
height: 120px;
border-radius: 50%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 22px;
border: 2px solid rgba(255, 255, 255, 0.6);
text-align: center;
flex-shrink: 0;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
/* muted dark stroke so the icon reads clearly against any pastel fill */
.stamp-icon {
color: rgba(0, 0, 0, 0.35);
margin-bottom: 6px;
}
.stamp-place {
margin: 0;
padding: 0 10px;
line-height: 1.3;
font-family: Georgia, 'Times New Roman', Times, serif;
}
.city {
display: block;
font-size: 0.78rem;
font-weight: 700;
color: #333;
}
.country {
display: block;
font-size: 0.62rem;
color: #555;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>

View File

@@ -0,0 +1,17 @@
import { writable } from 'svelte/store';
import { getTotalMessageCount } from '$lib/firebase/messages.js';
// Shared, app-wide value for the "memory counter" pill (GlobalCountPill.svelte) -
// the live total from meta/stats (see messages.js for the increment side).
// Starts at `null` (not yet loaded) rather than 0, so the pill can wait to
// render until the first real value arrives instead of flashing
// "0 messages have been left worldwide" before the fetch resolves.
export const globalCount = writable(null);
// Re-fetches the counter from Firestore and updates the shared store. Called
// once on initial load (GlobalCountPill's onMount) and again after this
// device successfully posts a message (ComposeSheet), so the number "feels
// alive" without needing a full page reload.
export async function refreshGlobalCount() {
globalCount.set(await getTotalMessageCount());
}

View File

@@ -1,3 +1,32 @@
import { writable } from 'svelte/store';
import { writable, get } from 'svelte/store';
import { playNewPinChime } from '$lib/utils/sound.js';
export const messagesStore = writable([]); // the store will fill up when the page lloads and queries firestore
export const messagesStore = writable([]); // the store will fill up when the page lloads and queries firestore
// --- "pins pop into existence" live-arrival tracking -----------------------
// Holds the message IDs of pins that just arrived via the real-time listener
// (subscribeToNearbyMessages, messages.js) WHILE the user is actively viewing
// the map - as opposed to pins from the initial getNearbyMessages load/refresh
// (handled by setMessages below, which plays the softer ambient chime
// instead). MapView's renderPins checks this set: any message id in it gets
// the "pop" bounce animation instead of appearing instantly, then clears the
// set so the same pin doesn't re-bounce on the next render.
export const livePinIdsStore = writable(new Set());
// Replaces the store's contents with a fresh list of nearby messages, and
// plays a soft chime if any pin in the new list wasn't present before -
// covering both "someone else posted nearby" and "this device's own query
// refreshed with new results".
export function setMessages(newMessages) {
const previous = get(messagesStore);
const previousIds = new Set(previous.map((m) => m.id));
const hasNewPin = newMessages.some((m) => !previousIds.has(m.id));
// skip the chime on the very first load (previous list is empty) -
// otherwise every pin from the initial fetch would "ding" at once
if (hasNewPin && previous.length > 0) {
playNewPinChime();
}
messagesStore.set(newMessages);
}

View File

@@ -0,0 +1,9 @@
import { writable } from 'svelte/store';
// Holds this device/user's earned "passport stamps" (see firebase/stamps.js).
// Populated by getStamps() on load and appended to live by
// checkAndAwardStamp() whenever a new stamp is earned, so the bottom-nav
// (mobile) and stamp-book button (desktop) in +page.svelte can react to
// $stampsStore.length to switch between their empty/filled icon states
// without needing a refetch.
export const stampsStore = writable([]);

View File

@@ -0,0 +1,20 @@
import { writable } from 'svelte/store';
import { auth } from '$lib/firebase/config.js';
import { signInAnonymously, onAuthStateChanged } from 'firebase/auth';
// tracks the current Firebase auth user (anonymous, persists across reloads on this device)
export const userStore = writable({
uid: null,
ready: false
});
// signs the device in anonymously (if not already) and keeps userStore in sync
export function initAuth() {
signInAnonymously(auth);
onAuthStateChanged(auth, (user) => {
if (user) {
userStore.set({ uid: user.uid, ready: true });
}
});
}

View File

@@ -0,0 +1,41 @@
import { FaceDetector, FilesetResolver } from '@mediapipe/tasks-vision';
let faceDetector = null;
export async function loadFaceDetector() {
if (faceDetector) return;
const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm'
);
faceDetector = await FaceDetector.createFromOptions(vision, {
baseOptions: {
modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite'
},
runningMode: 'IMAGE'
});
}
// fixed: parameter was named 'File' (capital F) but used as 'file' below — caused ReferenceError
// that fell silently into the ComposeSheet catch block, allowing every upload through
export async function hasFace(file) {
await loadFaceDetector();
const objectUrl = URL.createObjectURL(file);
const img = document.createElement('img');
// fixed: onload/onerror must be assigned BEFORE setting img.src — if the object URL
// resolves synchronously the load event fires before the handler was attached and
// the Promise never settles, leaving detection running on an unloaded element
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = () => reject(new Error('Image failed to load'));
img.src = objectUrl;
});
const result = faceDetector.detect(img);
URL.revokeObjectURL(objectUrl);
return result.detections.length > 0;
}

33
src/lib/utils/geocode.js Normal file
View File

@@ -0,0 +1,33 @@
import { env } from '$env/dynamic/public';
// --- "passport stamps" reverse geocoding -----------------------------------
// Reverse-geocodes a lat/lng pair into a { city, country } pair using the
// Google Maps Geocoding API (REST), reusing the same PUBLIC_MAPS_KEY the
// Maps JavaScript API loader already uses (see MapView.svelte) - the
// Geocoding API must be enabled for this key in the Google Cloud Console for
// this to work.
//
// Walks the first result's address_components looking for the 'locality'
// type for the city name (falling back to 'administrative_area_level_1',
// e.g. a state/province, for rural areas with no locality) and the 'country'
// type for the country name. Returns { city: 'Unknown', country: 'Unknown' }
// if the request fails or no results come back, so a failed lookup never
// blocks stamp creation - it just produces a generically-labeled stamp.
export async function reverseGeocode(lat, lng) {
try {
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${env.PUBLIC_MAPS_KEY}`;
const res = await fetch(url);
const data = await res.json();
const components = data.results?.[0]?.address_components ?? [];
const findType = (type) => components.find(c => c.types.includes(type))?.long_name;
return {
city: findType('locality') ?? findType('administrative_area_level_1') ?? 'Unknown',
country: findType('country') ?? 'Unknown'
};
} catch (err) {
console.error('[stamps] reverse geocode failed:', err);
return { city: 'Unknown', country: 'Unknown' };
}
}

View File

@@ -1,15 +1,62 @@
import ngeohash from 'ngeohash'; // library that does the geohasing encoding/decoding yippee
// encodes the latitude/longitude pair to a 6 character string geohash (~1.2km radius)
// encodes the latitude/longitude pair to a 9 character string geohash
// (~5m precision) - this matches the precision addMessage() now stores on
// every message (see messages.js), which is the maximum precision this app
// ever needs, so getQueryPrefix() below always has room to use any shorter
// prefix without breaking the range query.
export function encode(lat, lng) {
return ngeohash.encode(lat, lng, 6);
return ngeohash.encode(lat, lng, 9);
}
// encodes a lat/lng pair to a 4 character string geohash (~40km radius) used as a Firestore query prefix
// basically like looking for all geohashes that start with this 4 characters
// it will include all geohashes in a ~40km radius of the given lat/lng pair
// maybe we lessen the radius later but for now is good for testing
// encodes a lat/lng pair to a string geohash used as a Firestore query
// prefix - the "nearby" area shrinks as this precision increases. Safe to
// tune anywhere from 1-9: a shorter prefix can always match a longer stored
// geohash via the >= / < range query in getNearbyMessages, just not the
// other way around (a prefix LONGER than the stored geohash's 9 characters
// would never match anything - same failure mode as before).
export function getQueryPrefix(lat, lng) {
return ngeohash.encode(lat, lng, 7);
}
// --- geohash cell-boundary problem ----------------------------------------
// A single precision-6 geohash cell is only ~1.2km x 0.6km. Two devices
// standing in the same real-world spot can still land in DIFFERENT cells if
// their reported coordinates fall on opposite sides of a cell edge - this is
// very common, since desktop browsers usually resolve location via WiFi/IP
// (often off by hundreds of meters) while phones use GPS (much tighter).
// Querying only getQueryPrefix(lat, lng)'s single cell means one device's
// query can miss messages that are physically just a few meters away, on the
// other side of that edge - which is exactly the "different pin counts on
// different devices" symptom.
//
// getNearbyGeohashCells returns the user's cell PLUS its 8 surrounding cells
// (a 3x3 grid, via ngeohash.neighbors). Querying all 9 means that as long as
// both devices' reported positions are within roughly one cell-width of each
// other (which covers normal GPS/WiFi error), their 3x3 grids overlap enough
// to cover the same messages - so both devices see the same "nearby" set
// even if they're each centered in a different cell.
//
// IMPORTANT for callers (messages.js): each cell returned here is used as a
// Firestore "starts with" prefix range, i.e.
// where('geohash', '>=', cell), where('geohash', '<', cell + '\uF8FF')
// '\uF8FF' (not 'z') must be the upper-bound suffix - 'z' is itself a valid
// geohash character (the highest one), so a stored geohash whose very next
// character is also 'z' (e.g. cell "xn774bg" + stored "xn774bgzf") would be
// >= cell + 'z' and get wrongly excluded. '\uF8FF' is far above any geohash
// character, so cell + '\uF8FF' always matches every geohash starting with cell.
export function getNearbyGeohashCells(lat, lng) {
const center = getQueryPrefix(lat, lng);
return [center, ...ngeohash.neighbors(center)];
}
// --- "passport stamps" region key ------------------------------------------
// encodes a lat/lng pair to a 4 character geohash prefix (~20-40km,
// roughly city-sized). Used by stamps.js to group messages into distinct
// "regions" a user has posted from - much coarser than getQueryPrefix's
// "nearby" cells above, which is the point: someone shouldn't earn a new
// stamp just for moving a few blocks, only for posting from a genuinely
// different area.
export function getRegionPrefix(lat, lng) {
return ngeohash.encode(lat, lng, 4);
}

View File

@@ -0,0 +1,98 @@
// Custom Google Maps style array. Desaturates the base map to near-
// grayscale so the only color on screen comes from the user-left pastel/
// mood-color pins - reinforcing the app's "memory and time" theme: the
// physical world fades to monochrome, and the messages people left behind
// are what still carry color.
//
// IMPORTANT: rules are applied in array order, and later rules override
// earlier ones for the same featureType/elementType. Several rules below
// rely on this - a broad rule (e.g. "hide all POIs") comes first, then a
// more specific rule for a subtype (e.g. "poi.park") comes after to
// override it just for that subtype. Reordering these can change the result.
export const mapStyles = [
// 1. Desaturate EVERYTHING by default - not just geometry (roads, land,
// water, parks, admin areas), but also labels and icons. This is what
// turns Google's default colored highway-shield icons (blue/yellow
// route badges) and green park icons/text grayscale too. Per-feature
// rules below layer on top of this - e.g. water gets an explicit
// color later, which overrides this for water specifically.
{
elementType: 'all',
stylers: [{ saturation: -100 }]
},
// 2. Hide ALL points-of-interest - icons AND labels - for generic
// businesses (restaurants, shops, etc). This is the "declutter"
// baseline; specific notable categories are turned back on below.
{
featureType: 'poi',
elementType: 'all',
stylers: [{ visibility: 'off' }]
},
// 3. Re-enable icons + labels for parks and schools specifically - these
// are "prominent"/notable landmarks that help with orientation, unlike
// generic businesses. Comes after rule 2 so it overrides it for just
// these two subtypes.
{
featureType: 'poi.park',
elementType: 'all',
stylers: [{ visibility: 'on' }]
},
{
featureType: 'poi.school',
elementType: 'all',
stylers: [{ visibility: 'on' }]
},
// 4. Hide transit (bus/subway/rail) icons and labels entirely - transit
// info isn't relevant to a map about messages left at a location.
{
featureType: 'transit',
elementType: 'all',
stylers: [{ visibility: 'off' }]
},
// 5. Hide labels on minor/local streets - at the app's default zoom
// level these cover the map in small text. Arterial/highway road
// labels are left at their default (visible) so major roads still
// read for orientation - no rule needed for those, default = visible.
{
featureType: 'road.local',
elementType: 'labels',
stylers: [{ visibility: 'off' }]
},
// 6. Water gets a soft, muted blue-grey instead of plain desaturated
// grayscale - just enough color to read as "water" against the land,
// without breaking the overall desaturated look or competing with
// the pastel pins. This explicit `color` overrides rule 1's
// saturation tweak for water's geometry specifically.
{
featureType: 'water',
elementType: 'geometry',
stylers: [{ color: '#cfd8dc' }]
},
// 7. Hide administrative boundary lines (country/province/county) -
// mostly visual clutter for a hyperlocal "messages near you" map.
{
featureType: 'administrative',
elementType: 'geometry',
stylers: [{ visibility: 'off' }]
},
// 8. Hide administrative labels generally...
{
featureType: 'administrative',
elementType: 'labels',
stylers: [{ visibility: 'off' }]
},
// ...but bring back locality (city/town) labels specifically - knowing
// what city/area you're in is basic orientation worth keeping, even
// though the surrounding boundary lines are hidden by rule 7.
{
featureType: 'administrative.locality',
elementType: 'labels',
stylers: [{ visibility: 'on' }]
}
];

64
src/lib/utils/pins.js Normal file
View File

@@ -0,0 +1,64 @@
// generates a random pastel hue, returning both a solid fill and a
// translucent version of the same color for the glow halo behind each pin
function randomPastel() {
const hue = Math.floor(Math.random() * 360);
return {
solid: `hsl(${hue}, 60%, 72%)`,
glow: `hsla(${hue}, 60%, 72%, 0.25)`
};
}
// picks the fill color for a pin: if the message has a moodColor (an
// optional pastel color the author picked in ComposeSheet's swatch row,
// stored as an hsl(...) string), use that directly so the pin matches what
// they chose. otherwise fall back to the existing random pastel exactly as
// before. `glow` isn't currently rendered by any pin shape below, but is
// still returned for shape consistency with randomPastel().
function pinColor(moodColor) {
if (moodColor) {
return { solid: moodColor, glow: moodColor };
}
return randomPastel();
}
// turns an SVG string into a { url, size } pair for google.maps.Marker's
// `icon` option. Markers (the legacy marker API this app now uses, switched
// back from AdvancedMarkerElement so a JS map `styles` array can take
// effect - AdvancedMarkerElement requires a mapId, which causes Google to
// ignore any `styles` array) take an Icon object - { url, scaledSize, anchor }
// - rather than a DOM node, so we just return the data-URL + size here and
// let MapView build the google.maps.Size/Point objects (those classes only
// exist once the Maps JS API has loaded).
// flat 2D style for now (no drop-shadow) - paper-style shadows may be added later
function svgToIcon(svg, size) {
return {
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg),
size
};
}
// pin for every message: solid pastel perfect circle, no glow halo.
// previously there were 3 shapes (star/circle/heart) for unread/read/echoed
// messages, swapped on click - now every message pin looks the same, so
// clicking a pin no longer needs to change its appearance.
// moodColor (optional): the author's picked swatch color, if any - see pinColor() above
export function messagePin(moodColor) {
const { solid } = pinColor(moodColor);
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20" fill="${solid}"/>
</svg>`;
// sized to match locationPin (32px) per latest request, so message pins
// are no longer larger than the current-location marker
return svgToIcon(svg, 28);
}
// fixed dark dot used for the user's own location (not randomized, no glow)
export function locationPin() {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
<circle cx="16" cy="16" r="5" fill="white"/>
</svg>`;
return svgToIcon(svg, 32);
}

69
src/lib/utils/presence.js Normal file
View File

@@ -0,0 +1,69 @@
// Ambient "X people have been here today" presence indicator.
//
// This is intentionally derived entirely from data getNearbyMessages()
// already returns (authorId, createdAt, lastEchoAt, echoCount) - no extra
// Firestore queries or schema changes are needed. The result is meant to
// feel like ambient atmosphere, not a precise analytic, so a reasonable
// approximation (explained below) is fine.
const DAY_MS = 24 * 60 * 60 * 1000;
// Counts "unique presences" in the last 24 hours across two activity types:
//
// 1. POSTS - any message whose `createdAt` is within the last 24h counts
// its `authorId` toward a Set, so one person posting several messages
// today still only counts as one presence.
//
// 2. ECHOES - the data model has no per-echo log (no list of who echoed or
// when each individual echo happened), only a running `echoCount` and a
// single `lastEchoAt` timestamp per message. We can't recover *who*
// echoed or *how many distinct people* echoed a given message from that.
//
// APPROXIMATION: a message whose `lastEchoAt` falls within the last 24h
// (and that wasn't itself posted in the last 24h - see below) is counted
// as ONE additional presence, representing "someone engaged with this
// message today". This undercounts cases where multiple different people
// echoed the same message today (they'd only add 1, not N), but avoids
// needing a new subcollection/security rules just for an ambient number.
// Given the feature is explicitly "a feeling, not a precise count", this
// tradeoff is acceptable.
//
// Double-counting guard: addMessage() sets `lastEchoAt = createdAt` on a
// brand new message, so a message posted today would otherwise satisfy both
// the "posted today" and "echoed today" checks. The `!postedToday` guard
// below ensures a fresh post is only counted once (as a post).
export function getPresenceCount(messages) {
const now = Date.now();
const postedTodayAuthorIds = new Set();
let echoedTodayCount = 0;
for (const message of messages) {
const createdAtMs = message.createdAt?.toMillis() ?? 0;
const lastEchoAtMs = message.lastEchoAt?.toMillis() ?? createdAtMs;
const postedToday = now - createdAtMs < DAY_MS;
const echoedToday = now - lastEchoAtMs < DAY_MS;
if (postedToday) {
// dedupe posters by authorId - same person posting multiple
// messages today is still just one "presence"
postedTodayAuthorIds.add(message.authorId ?? message.id);
} else if (echoedToday) {
// see APPROXIMATION above: counts as one presence regardless of
// how many times this message was echoed or by how many people
echoedTodayCount += 1;
}
}
return postedTodayAuthorIds.size + echoedTodayCount;
}
// Formats the count into the subtitle copy, handling the singular/zero cases.
export function getPresenceText(messages) {
const count = getPresenceCount(messages);
if (count === 0) return 'No one has been here today';
if (count === 1) return '1 person has been here today';
return `${count} people have been here today`;
}

224
src/lib/utils/sound.js Normal file
View File

@@ -0,0 +1,224 @@
// Ambient sound design for Overheard, built entirely with the Web Audio API.
// No audio files are loaded - every sound here is a sine wave generated on
// the fly by an OscillatorNode and shaped by a GainNode "envelope".
import { browser } from '$app/environment';
// ---------------------------------------------------------------------
// AudioContext setup + the "unlock on first interaction" workaround
// ---------------------------------------------------------------------
// Browsers (Chrome, Safari, Firefox) block audio from playing until the
// page has received at least one user gesture (click/tap/keypress). An
// AudioContext created before that gesture starts in a "suspended" state,
// and any sound scheduled on it is silently dropped.
//
// To handle this without requiring a manual "enable sound" button, we:
// 1. Lazily create a single shared AudioContext the first time it's needed
// (getAudioContext below), instead of one at module load time.
// 2. Attach one-time listeners for pointerdown/keydown/touchstart to the
// window. The very first time the user interacts with the page at all,
// we create/resume the shared AudioContext so it's "unlocked" before
// any chime needs to play.
// 3. Every play function also calls ctx.resume() defensively - resuming an
// already-running context is a harmless no-op, but if the context is
// still suspended (e.g. a chime fires before any interaction happened),
// this gives it another chance to start once a gesture has occurred.
//
// Net effect: sound is "on" by default with no toggle, and simply stays
// silent until the user has interacted with the page once - which matches
// what every browser requires anyway.
let audioCtx = null;
function getAudioContext() {
if (!browser) return null; // never run on the server during SSR
if (!audioCtx) {
// webkitAudioContext fallback covers older Safari
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
audioCtx = new AudioContextClass();
}
// no-op if already running; otherwise attempts to start playback -
// this only actually succeeds once a user gesture has occurred
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
return audioCtx;
}
if (browser) {
// One-time "unlock" listeners: the first tap/click/keypress anywhere on
// the page creates (or resumes) the shared AudioContext so it's ready
// before the user does anything that should make a sound (e.g. echoing
// a message). { once: true } removes each listener after it fires.
const unlockAudio = () => getAudioContext();
window.addEventListener('pointerdown', unlockAudio, { once: true });
window.addEventListener('keydown', unlockAudio, { once: true });
window.addEventListener('touchstart', unlockAudio, { once: true });
}
// ---------------------------------------------------------------------
// Shared helper: play a single sine-wave tone with an attack/decay envelope
// ---------------------------------------------------------------------
// A "click"-free tone needs its volume to ramp up and back down smoothly
// rather than switching on/off instantly (an instant on/off creates an
// audible pop). We do this with a GainNode whose gain value we schedule
// over time - this is the "envelope".
//
// ctx - the shared AudioContext
// frequency - pitch of the tone, in Hz
// startTime - when (in ctx.currentTime seconds) the tone should begin
// duration - total length of the tone, in seconds
// peakGain - maximum volume (0-1) reached during the attack
function playTone(ctx, frequency, startTime, duration, peakGain) {
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
// 'sine' is the smoothest, purest waveform Web Audio offers - no harsh
// overtones, which keeps the chime feeling soft/ambient rather than
// buzzy (a square or sawtooth wave would sound much more aggressive).
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
// Envelope: start silent, quickly fade in (attack), then fade back out
// to silence (decay) before the oscillator stops. The very short attack
// (10ms) avoids a click at the start, and the longer exponential decay
// gives the "ding" its natural-sounding tail.
const attackTime = 0.01; // 10ms fade-in - fast enough to feel instant, slow enough to avoid a click
gainNode.gain.setValueAtTime(0.0001, startTime); // start essentially silent
gainNode.gain.exponentialRampToValueAtTime(peakGain, startTime + attackTime); // quick fade in to peak volume
// exponential ramps can't target exactly 0, so we ramp down to a
// near-silent value (0.0001) by the end of the tone's duration
gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + duration);
// oscillator -> gain -> speakers
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
}
// ---------------------------------------------------------------------
// Feature 1: new pin chime
// ---------------------------------------------------------------------
// A single soft "ding" played whenever a new message pin appears nearby,
// whether posted by someone else or revealed by this device's own refresh.
export function playNewPinChime() {
const ctx = getAudioContext();
if (!ctx) return; // SSR or AudioContext unavailable - do nothing
const now = ctx.currentTime;
// A5 (880Hz) is high enough to feel light/bell-like without being shrill,
// and a single note keeps the chime unobtrusive - just a soft notice
// rather than a full melody.
const frequency = 880;
// 0.4s total: comfortably inside the requested 0.3-0.5s range. Most of
// that time is the decay tail, so it reads as one quick "ding" rather
// than a sustained tone.
const duration = 0.4;
// Peak volume of 0.06 (out of a possible 0-1) keeps this firmly in
// "ambient/background" territory - audible but never jarring, even if
// several pins load in quick succession.
const peakGain = 0.06;
playTone(ctx, frequency, now, duration, peakGain);
}
// ---------------------------------------------------------------------
// Feature 2: echo confirmation tone
// ---------------------------------------------------------------------
// A gentle three-note ascending arpeggio played when the user successfully
// echoes a message - a small musical "thank you".
export function playEchoTone() {
const ctx = getAudioContext();
if (!ctx) return; // SSR or AudioContext unavailable - do nothing
const now = ctx.currentTime;
// C5 -> E5 -> G5: a major triad arpeggio. Ascending pitches read as
// positive/affirming (vs. a descending run, which tends to sound like
// an error or "closing" cue), and a major chord feels warm rather than
// tense.
const notes = [523.25, 659.25, 783.99]; // C5, E5, G5 in Hz
// Each note is short and they overlap slightly (0.14s apart vs. each
// lasting 0.25s), which makes the arpeggio feel like one fluid gesture
// rather than three separate beeps. 2 gaps * 0.14s + final note's
// 0.25s duration = 0.53s total - comfortably under the 1-second limit.
const noteSpacing = 0.14; // seconds between each note's start time
const noteDuration = 0.25; // seconds each individual note lasts
// Slightly lower peak gain than the pin chime since three overlapping
// notes add up to more total energy than one - keeps the overall
// loudness in the same soft "ambient" range.
const peakGain = 0.05;
notes.forEach((frequency, index) => {
const startTime = now + index * noteSpacing;
playTone(ctx, frequency, startTime, noteDuration, peakGain);
});
}
// ---------------------------------------------------------------------
// Feature 3: live "pin pop" sound
// ---------------------------------------------------------------------
// A short, bright, whimsical "boop-beep" played when a pin appears in real
// time WHILE the user is actively looking at the map (see
// subscribeToNearbyMessages in messages.js + livePinIdsStore in
// messagesStore.js) - distinct from playNewPinChime()'s single soft ambient
// "ding" (page loads/refreshes/moving) and playEchoTone()'s slower 3-note
// rising arpeggio (echoing a message).
export function playPinPopSound() {
const ctx = getAudioContext();
if (!ctx) return; // SSR or AudioContext unavailable - do nothing
const now = ctx.currentTime;
// Two quick ascending notes (E6 -> G6, a rising minor third) read as a
// playful little "boop-beep" chime rather than a flat pop - the rising
// pitch is what gives it a cute/tactile "ta-da!" character.
const notes = [1318.51, 1567.98]; // E6, G6 in Hz
// The second note starts before the first has fully decayed (50ms apart,
// each lasting 100ms) - that overlap is what makes the pair feel like one
// light, bouncy gesture instead of two separate beeps. Total ~0.15s, well
// under playEchoTone's 0.53s arpeggio.
const noteSpacing = 0.05;
const noteDuration = 0.1;
// Near-instant attack (5ms) keeps each note feeling crisp/percussive
// rather than ringing, like a tiny bell tap.
const attackTime = 0.005;
// Slightly louder than the ambient chime's peak (0.06) so this registers
// as a distinct little "reward", but still short enough to never feel
// jarring even with two overlapping notes.
const peakGain = 0.07;
// 'triangle' has richer harmonics than playTone's 'sine', giving this a
// brighter, more sparkly/bell-like timbre than the pure ambient chime.
notes.forEach((frequency, index) => {
const startTime = now + index * noteSpacing;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = 'triangle';
oscillator.frequency.value = frequency;
gainNode.gain.setValueAtTime(0.0001, startTime);
gainNode.gain.exponentialRampToValueAtTime(peakGain, startTime + attackTime);
gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + noteDuration);
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start(startTime);
oscillator.stop(startTime + noteDuration);
});
}

View File

@@ -0,0 +1,37 @@
// --- "passport stamps" icon set ---------------------------------------------
// Curated set of simple outline icons (lucide-style: 24x24 viewBox,
// stroke-based, no fill) representing generic "places" - the centerpiece of
// each passport stamp badge (see Stamp.svelte). `paths` is the inner SVG
// markup, dropped directly into Stamp.svelte's <svg> wrapper via {@html}.
export const STAMP_ICONS = [
{ id: 'mountain', paths: '<path d="M3 20h18"/><path d="M5 20 11 6l4 7 2-3 3 6"/>' },
{ id: 'building', paths: '<rect x="5" y="3" width="14" height="18" rx="1"/><path d="M9 21v-4h6v4"/><path d="M9 7h1M14 7h1M9 11h1M14 11h1"/>' },
{ id: 'tree', paths: '<path d="M12 22v-6"/><path d="M12 2 7 10h3l-4 6h12l-4-6h3z"/>' },
{ id: 'wave', paths: '<path d="M2 11c1.5-2 3.5-2 5 0s3.5 2 5 0 3.5-2 5 0 3.5 2 5 0"/><path d="M2 17c1.5-2 3.5-2 5 0s3.5 2 5 0 3.5-2 5 0 3.5 2 5 0"/>' },
{ id: 'star', paths: '<path d="M12 2l2.9 6.5L22 9l-5 4.9 1.2 7.1L12 17.3 5.8 21 7 13.9 2 9l7.1-.5z"/>' },
{ id: 'leaf', paths: '<path d="M11 20A7 7 0 0 1 4 13c0-6 7-11 13-11 0 6-2 13-6 16-1 1-2 2-2 2z"/><path d="M5 17c5-5 9-9 12-12"/>' },
{ id: 'sun', paths: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>' },
{ id: 'moon', paths: '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>' },
{ id: 'landmark', paths: '<path d="M3 22h18"/><path d="M5 22V11M9 22V11M15 22V11M19 22V11"/><path d="M2 11 12 4l10 7z"/>' },
{ id: 'compass', paths: '<circle cx="12" cy="12" r="9"/><path d="M15.5 8.5 13 13l-4.5 2.5L11 11z"/>' }
];
// Deterministic string hash (DJB2 variant) - the same input string always
// produces the same number, which is what lets the same place always pick
// the same icon (see pickStampIcon below).
function hashString(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return Math.abs(hash);
}
// Picks an icon for a place deterministically: "City, Country" always maps
// to the same icon, so earning a stamp for the same city twice (hypothetically)
// would always show the same icon - without needing to store any extra
// city->icon mapping.
export function pickStampIcon(city, country) {
const index = hashString(`${city}, ${country}`) % STAMP_ICONS.length;
return STAMP_ICONS[index];
}

91
src/lib/utils/stats.js Normal file
View File

@@ -0,0 +1,91 @@
// --- Personal engagement stats --------------------------------------------
// Three simple action counters - messages read (detail view opened), echoed,
// and let go - stored only in localStorage on this device. Like trail.js,
// this is purely local: never sent to Firestore, never attached to a
// message, never shared. It's a private mirror of how THIS device has moved
// through the app (read vs. acted), shown as a summary line on the archive
// page.
const STATS_KEY = 'overheard_stats';
const DEFAULT_STATS = { read: 0, echoed: 0, letGo: 0 };
// Reads the stats object from localStorage, merged over the all-zero
// defaults so older/partial stored objects (or a first-ever read with
// nothing stored yet) still have all three keys. Falls back to defaults
// entirely if localStorage is unavailable (SSR) or the value can't be parsed.
export function getStats() {
if (typeof localStorage === 'undefined') return { ...DEFAULT_STATS };
try {
const raw = localStorage.getItem(STATS_KEY);
return raw ? { ...DEFAULT_STATS, ...JSON.parse(raw) } : { ...DEFAULT_STATS };
} catch {
return { ...DEFAULT_STATS };
}
}
// Increments one counter by 1 and persists the whole stats object back to
// localStorage. Internal helper - see the three named exports below.
function incrementStat(key) {
const stats = getStats();
stats[key] = (stats[key] ?? 0) + 1;
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(STATS_KEY, JSON.stringify(stats));
} catch {
// localStorage unavailable/full - nothing more we can do here
}
}
return stats;
}
// called when a message detail view is opened (BottomSheet/SidePanel)
export const incrementRead = () => incrementStat('read');
// called when the Echo button is pressed
export const incrementEchoed = () => incrementStat('echoed');
// called when the Let go button is pressed
export const incrementLetGo = () => incrementStat('letGo');
// --- "last chance" one-time prompt tracking --------------------------------
// Tracks which message IDs have already shown the "last chance" prompt (see
// BottomSheet.svelte / SidePanel.svelte) so it appears at most once per
// message, per device - reopening the same message later (while it's still
// on its last day) won't show it again.
const LAST_CHANCE_SEEN_KEY = 'overheard_last_chance_seen';
function getSeenLastChanceIds() {
if (typeof localStorage === 'undefined') return [];
try {
const raw = localStorage.getItem(LAST_CHANCE_SEEN_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
// has this message ID already shown the "last chance" prompt?
export function hasSeenLastChancePrompt(messageId) {
return getSeenLastChanceIds().includes(messageId);
}
// marks a message ID as having shown the prompt, so it won't show again
export function markLastChancePromptSeen(messageId) {
const seen = getSeenLastChanceIds();
if (seen.includes(messageId)) return;
seen.push(messageId);
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(LAST_CHANCE_SEEN_KEY, JSON.stringify(seen));
} catch {
// localStorage unavailable/full - worst case the prompt could
// reappear once more for this message, which is harmless
}
}
}

47
src/lib/utils/trail.js Normal file
View File

@@ -0,0 +1,47 @@
// --- Personal location trail ---------------------------------------------
// A private, on-device-only record of every place this device has ever
// opened Overheard. Stored solely in localStorage - this never gets sent to
// Firestore, attached to a message, or shared with anyone else in any way.
// Unlike the public messages (which decay after 30 days, see messages.js),
// this history is kept forever with no pruning or size limit - it's a
// permanent personal map of presence, for this device/browser only.
const STORAGE_KEY = 'overheard_location_trail';
// Reads the full trail array ([{ lat, lng, timestamp }, ...], oldest first)
// from localStorage. Returns [] if nothing has been recorded yet, if
// localStorage isn't available (e.g. during SSR), or if the stored value
// can't be parsed - any of those just means "no trail to draw" rather than
// an error worth surfacing.
export function getTrail() {
if (typeof localStorage === 'undefined') return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
// Appends a new { lat, lng, timestamp } point to the trail and writes the
// whole array back to localStorage, then returns the updated array so the
// caller can render it immediately without a second read. Intentionally
// has no size cap/pruning - every visit is kept. Called unconditionally on
// every successful geolocation fetch, regardless of whether the trail is
// currently shown - logging the visit and displaying it are independent.
export function addTrailPoint(lat, lng) {
const trail = getTrail();
trail.push({ lat, lng, timestamp: Date.now() });
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(trail));
} catch {
// localStorage unavailable/full - still return the in-memory
// array below so the trail renders for this session at least
}
}
return trail;
}

View File

@@ -1,11 +1,183 @@
<script>
import favicon from '$lib/assets/favicon.svg';
import { browser } from '$app/environment';
import { page } from '$app/stores'; // current route, used to hide the count pill on /archive
import GlobalCountPill from '$lib/components/GlobalCountPill.svelte';
let { children } = $props();
// --- one-time opening ritual ---------------------------------------
// `ritualMounted` controls whether the overlay exists in the DOM at all.
// Starting it at `false` means SSR renders nothing (avoiding a flash of
// the overlay on every page before we've even checked localStorage),
// and it only ever becomes `true` in the browser, once, for first-time
// visitors.
let ritualMounted = $state(false);
// `ritualVisible` drives the CSS opacity transition (fade in/out).
// Toggling this class is what actually animates the overlay; the timers
// below just flip it true -> false at the right moments.
let ritualVisible = $state(false);
$effect(() => {
if (!browser) return; // localStorage doesn't exist during SSR
// the flag that marks "this device has already seen the ritual"
const hasVisited = localStorage.getItem('overheard_has_visited');
if (hasVisited) return; // not the first visit - never mount the overlay
// Mark the device as visited immediately (rather than after the
// animation finishes). If we waited until the end, a user who closes
// the tab partway through the ritual would see it again on their
// next visit - setting the flag up front guarantees "only once ever".
localStorage.setItem('overheard_has_visited', 'true');
ritualMounted = true;
// Fade-in timing: wait one animation frame so the overlay first
// paints at opacity 0 (its initial CSS state), then flip
// `ritualVisible` to true so the opacity transition to 1 actually
// animates instead of snapping straight to visible.
const fadeInFrame = requestAnimationFrame(() => {
ritualVisible = true;
});
// Total sequence ~5s: 1.2s fade in + 2.6s hold + 1.2s fade out.
const fadeInMs = 1200;
const holdMs = 2600;
const fadeOutMs = 1200;
// After the fade-in finishes and the hold completes, start fading out.
const fadeOutTimer = setTimeout(() => {
ritualVisible = false;
}, fadeInMs + holdMs);
// Once the fade-out transition has finished, unmount the overlay
// entirely (rather than just leaving it hidden via CSS/opacity:0).
// A hidden-but-present full-screen element would still sit in the
// DOM at a high z-index and could intercept clicks/taps meant for
// the map underneath, so we remove it completely once it's invisible.
const removeTimer = setTimeout(() => {
ritualMounted = false;
}, fadeInMs + holdMs + fadeOutMs);
// cleanup if the component is destroyed mid-sequence
return () => {
cancelAnimationFrame(fadeInFrame);
clearTimeout(fadeOutTimer);
clearTimeout(removeTimer);
};
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<!-- Map/geolocation in {@render children()} starts loading immediately and
in parallel - this overlay just sits on top of it visually, it doesn't
block or delay anything underneath. -->
{@render children()}
<!-- global "memory counter" pill, top-right on every page EXCEPT /archive and
/stampbook - both are the user's own private views (their messages, their
earned stamps), so the global "everyone, everywhere" count would feel out
of place there (see GlobalCountPill.svelte for the pill itself). -->
{#if $page.url.pathname !== '/archive' && $page.url.pathname !== '/stampbook'}
<GlobalCountPill />
{/if}
<!-- one-time opening ritual overlay, first-ever visit only -->
{#if ritualMounted}
<div class="ritual-overlay" class:visible={ritualVisible} aria-hidden="true">
<p class="ritual-text">Some things are only true if you're standing here.</p>
</div>
{/if}
<!-- ===================================================================
GLOBAL TEXTURE OVERLAY (start)
A subtle parchment/paper grain over the ENTIRE app (was previously
just over the map - moved here per request). pointer-events: none
so it never blocks clicks/taps anywhere in the UI.
TO REMOVE: delete this <div> and the ".global-texture-overlay" CSS
block below - nothing else depends on either.
=================================================================== -->
<div class="global-texture-overlay" aria-hidden="true"></div>
<!-- GLOBAL TEXTURE OVERLAY (end) -->
<style>
/* full-screen opening ritual overlay - soft cream background matching
the app's pastel palette, sits above everything while it's visible */
.ritual-overlay {
position: fixed;
inset: 0;
background: #f9f7f4;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
/* starts invisible; .visible below fades it in/out over 1.2s.
the same transition is reused for fade-out since both are just
opacity changes between 0 and 1 */
opacity: 0;
transition: opacity 1.2s ease;
}
.ritual-overlay.visible {
opacity: 1;
}
.ritual-text {
/* same serif used for the loading/error text in +page.svelte, for a
calm, contemplative feel */
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 1.15rem;
color: #555; /* muted dark grey, not pure black */
letter-spacing: 0.08em;
text-align: center;
line-height: 1.6;
margin: 0;
padding: 0 2rem;
}
@media (min-width: 768px) {
.ritual-text {
font-size: 1.35rem;
}
}
/* =================================================================
GLOBAL TEXTURE OVERLAY (start) - see matching comment in the
markup above for removal instructions.
- position: fixed + inset: 0 covers the full viewport, regardless
of scroll position, on every page.
- z-index: 2000 sits above the opening ritual (1000) and every
other UI element (all of which use z-index 200 or less) - this
is intentional, since the texture is purely decorative and
pointer-events: none means it never intercepts input even when
it's the topmost element.
- mix-blend-mode: multiply + opacity: 0.08 + the warm #ecdfc4
background-color gently "ages" whatever's underneath (map,
white panels, sheets) without hiding any content. opacity
back down to 0.08 - the stronger feColorMatrix/tile-size
values below (tuned while testing at 0.10) keep the grain
from disappearing entirely at this lower opacity.
- background-image is the same inline SVG <feTurbulence> +
<feColorMatrix> grain used previously on just the map.
feColorMatrix scale 3.5 / offset -1.6 and background-size
110px give the grain a faint but present texture even at
0.08 opacity. */
.global-texture-overlay {
position: fixed;
inset: 0;
z-index: 2000;
pointer-events: none;
opacity: 0.08;
mix-blend-mode: multiply;
background-color: #ecdfc4;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch' result='noise'/%3E%3CfeColorMatrix in='noise' type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3.5 -1.6'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 110px 110px;
background-repeat: repeat;
}
/* GLOBAL TEXTURE OVERLAY (end) */
</style>

View File

@@ -2,9 +2,16 @@
import { onMount } from 'svelte';
import MapView from '$lib/components/MapView.svelte';
import { getNearbyMessages } from '$lib/firebase/messages.js';
import { messagesStore } from '$lib/stores/messagesStore.js';
import { getNearbyMessages, hasAnyMessagesEverNearby, getMessageById, subscribeToNearbyMessages } from '$lib/firebase/messages.js';
import { getDecayInfo } from '$lib/utils/time.js'; // checks whether a shared message has expired
import { messagesStore, setMessages, livePinIdsStore } from '$lib/stores/messagesStore.js';
import { playPinPopSound } from '$lib/utils/sound.js'; // "pin pop into existence" live-arrival sound
import { mapStore } from '$lib/stores/mapStore.js';
import { initAuth, userStore } from '$lib/stores/userStore.js';
import { getStamps } from '$lib/firebase/stamps.js'; // "passport stamps" - see stamps.js
import { stampsStore } from '$lib/stores/stampsStore.js'; // drives the empty/filled stamp-book icon below
import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab
import { replaceState } from '$app/navigation'; // clears the ?message= param after handling a shared link
import BottomSheet from '$lib/components/BottomSheet.svelte';
import SidePanel from '$lib/components/SidePanel.svelte';
@@ -15,11 +22,39 @@
let lng = $state();
let error = $state();
// handle for the real-time "pins pop into existence" listener
// (subscribeToNearbyMessages, messages.js) - assigned once the initial
// load finishes (see onMount below) and called on unmount to tear down
// its 9 onSnapshot listeners. Plain variable, not a rune: it's only ever
// read in the onMount cleanup function, never rendered.
let unsubscribeLive;
let windowWidth = $state(0);
let isMobile = $derived(windowWidth < 768);
// --- "you're the first here" ambient message --------------------------
// `firstHereMounted` controls whether the message exists in the DOM at
// all - it's set true at most once, right after the initial
// getNearbyMessages() call resolves (see onMount below), and never
// re-checked again for the rest of this page load ("once per page load").
// `firstHereVisible` drives the CSS opacity fade transition, the same
// mount/visible split used by the opening ritual overlay in +layout.svelte.
let firstHereMounted = $state(false);
let firstHereVisible = $state(false);
onMount(() => {
// sign this device in anonymously so messages can be linked to a persistent UID
initAuth();
// The map fills 100vh and all its controls (FAB, bottom nav, sheets)
// are position:fixed, so this page itself never needs to scroll.
// This used to be an `overflow: hidden` rule on :global(body) in the
// stylesheet below, but that leaked across client-side navigation and
// broke scrolling on /archive too - applying/removing it here scopes
// it to exactly this page's lifetime.
document.body.style.overflow = 'hidden';
if (!navigator.geolocation) {
error = "Your browser doesn't support geolocation :(";
return; // do nothing
@@ -33,43 +68,273 @@
error = "Location access denied. Please enable location to use Overheard.";
}
);
// populate the messages store
// populate the messages store
navigator.geolocation.getCurrentPosition(
async (position) => {
const messages = await getNearbyMessages(position.coords.latitude, position.coords.longitude);
messagesStore.set(messages);
console.log('messages loaded:', $messagesStore);
// setMessages (instead of messagesStore.set) compares against the
// previous list and plays a soft chime for any newly-appeared pins
setMessages(messages);
// --- "you're the first here" zero-results check --------
// getNearbyMessages() already filters out anything past its
// 30-day decay, so messages.length === 0 alone just means
// "nothing ACTIVE nearby right now" - it doesn't distinguish
// "no one has ever posted here" from "people posted here
// before, but it's all since faded". To make that
// distinction (per the feature spec, "truly never posted"
// interpretation), we only fall back to
// hasAnyMessagesEverNearby() - a cheap count-only query over
// the same geohash area, including expired messages - when
// the active list comes back empty. The ambient message only
// appears if THAT also comes back empty, i.e. literally
// nothing has ever been left in this ~1.2km area.
if (messages.length === 0) {
const everPosted = await hasAnyMessagesEverNearby(position.coords.latitude, position.coords.longitude);
if (!everPosted) {
firstHereMounted = true;
}
}
// --- "pins pop into existence": live listener -----------
// Now that the initial set has loaded, open a real-time
// listener for the same area. `knownIds` is every message id
// already on the map from the load above - it's the
// boundary between "already here" and "arrived while I was
// watching". Only ids NOT in `knownIds` get the pop sound +
// bounce animation (see livePinIdsStore, MapView.svelte's
// renderPins); `knownIds` then grows to include them, so
// they don't "arrive" a second time on the next snapshot.
const knownIds = new Set(messages.map((m) => m.id));
unsubscribeLive = subscribeToNearbyMessages(
position.coords.latitude,
position.coords.longitude,
(liveMessages) => {
const newIds = liveMessages
.filter((m) => !knownIds.has(m.id))
.map((m) => m.id);
if (newIds.length > 0) {
newIds.forEach((id) => knownIds.add(id));
playPinPopSound();
livePinIdsStore.set(new Set(newIds));
}
// replace wholesale (not setMessages) - this also
// picks up live updates to existing messages (e.g.
// echoCount from someone else echoing) without
// re-triggering the ambient "new pin" chime, which
// setMessages would do for the same ids
messagesStore.set(liveMessages);
}
);
}
);
// cleanup: undo the body overflow lock when this page unmounts
// (e.g. navigating to /archive), so other routes can scroll normally,
// and tear down the live listener above if it was ever set up.
return () => {
document.body.style.overflow = '';
unsubscribeLive?.();
};
});
// --- "passport stamps": load this user's earned stamps once signed in --
// initAuth() (called above) signs this device in anonymously and updates
// userStore asynchronously - this effect waits for userStore.ready and
// then fetches this user's stamps, populating stampsStore so the
// bottom-nav/desktop stamp-book icons below know whether to show their
// empty or filled state.
$effect(() => {
if ($userStore.ready) {
getStamps();
}
});
// Fade-in + auto-dismiss timing for the "first here" message - mirrors
// the opening ritual overlay's pattern in +layout.svelte: wait a frame so
// the element first paints at opacity 0, then fade in; after a short
// hold, fade out; then unmount entirely so it can't linger over the map.
$effect(() => {
if (!firstHereMounted) return;
const fadeInFrame = requestAnimationFrame(() => {
firstHereVisible = true;
});
const fadeInMs = 1200;
const holdMs = 4000; // "a few seconds" before it fades on its own
const fadeOutMs = 1200;
const fadeOutTimer = setTimeout(() => {
firstHereVisible = false;
}, fadeInMs + holdMs);
const removeTimer = setTimeout(() => {
firstHereMounted = false;
}, fadeInMs + holdMs + fadeOutMs);
return () => {
cancelAnimationFrame(fadeInFrame);
clearTimeout(fadeOutTimer);
clearTimeout(removeTimer);
};
});
// Early dismissal: as soon as the user starts interacting (opening the
// compose sheet), fade the message out immediately rather than waiting
// for the timers above - "disappear once the user starts interacting".
$effect(() => {
if (firstHereMounted && $mapStore.composing) {
firstHereVisible = false;
setTimeout(() => { firstHereMounted = false; }, 1200);
}
});
// --- auto-open a message shared via link/QR code (SharePopover.svelte) --
// `sharedLinkHandled` makes this run at most once per page load: the
// first time lat/lng are set (map has loaded) AND the URL has a
// `?message=<id>` param, it's flipped to true immediately, so later
// re-runs of this effect (e.g. lat/lng updating again) are no-ops.
let sharedLinkHandled = $state(false);
// "this one has already faded" ambient notice - same mount/visible split
// and fade timing as the "first here" message above, shown when a shared
// link points at a message that no longer exists or has expired.
let fadedNoticeMounted = $state(false);
let fadedNoticeVisible = $state(false);
$effect(() => {
if (sharedLinkHandled) return;
if (!(lat && lng)) return; // wait until the map has a location to center on
const sharedId = $page.url.searchParams.get('message');
sharedLinkHandled = true; // one-time entry behavior, regardless of outcome
if (!sharedId) return; // normal load, nothing to auto-open
(async () => {
// Direct Firestore lookup by document ID - bypasses
// getNearbyMessages's geohash filter entirely, since a shared
// message could have been left anywhere in the world, not just
// this device's ~1.2km area.
const shared = await getMessageById(sharedId);
const decay = shared ? getDecayInfo(shared.createdAt, shared.lastEchoAt) : null;
if (shared && decay && !decay.isExpired) {
mapStore.set({ selectedMessage: shared, composing: false });
} else {
// doesn't exist, or past its 30-day decay - graceful
// ambient notice instead of an error
fadedNoticeMounted = true;
}
// Clear the `message` param so this is a one-time entry
// behavior, not persistent state - reloading or sharing the
// current URL afterward won't re-trigger the auto-open.
// replaceState (rather than goto) swaps the URL without a
// navigation/history entry.
const url = new URL(window.location.href);
url.searchParams.delete('message');
replaceState(url, {});
})();
});
// fade-in + auto-dismiss timing for the "already faded" notice - mirrors
// the "first here" message's $effect above
$effect(() => {
if (!fadedNoticeMounted) return;
const fadeInFrame = requestAnimationFrame(() => {
fadedNoticeVisible = true;
});
const fadeInMs = 1200;
const holdMs = 4000;
const fadeOutMs = 1200;
const fadeOutTimer = setTimeout(() => {
fadedNoticeVisible = false;
}, fadeInMs + holdMs);
const removeTimer = setTimeout(() => {
fadedNoticeMounted = false;
}, fadeInMs + holdMs + fadeOutMs);
return () => {
cancelAnimationFrame(fadeInFrame);
clearTimeout(fadeOutTimer);
clearTimeout(removeTimer);
};
});
</script>
<svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker -->
<!-- "this one has already faded" notice for a shared link/QR code pointing
at a message that no longer exists or has expired (see the
sharedLinkHandled $effect above) - ambient and auto-dismissing, same
fade pattern as the "first here" bubble, rather than an error page -->
{#if fadedNoticeMounted}
<p class="faded-notice" class:visible={fadedNoticeVisible} aria-hidden="true">
this one has already faded.
</p>
{/if}
{#if error}
<p class="error">{error}</p>
{:else if lat && lng}
<MapView {lat} {lng} />
{:else}
{:else if !(lat && lng)}
<p class="loading">Looking for you...</p>
{/if}
<!-- map must fill the whole screen-->
{#if lat && lng}
<MapView {lat} {lng} />
<!-- map must fill the whole screen; wrapped in .map-container so desktop
can push it right of the side panel via the media query below.
(this also removes a duplicate <MapView> that was rendering two map instances)
on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav -->
{#if lat && lng}
<div class="map-container" style="padding-bottom: {windowWidth < 768 ? '64px' : '0'}">
<!-- firstHereMounted/firstHereVisible (state + fade timing computed
in onMount/$effects above) are passed down so MapView can render
the "you're the first here" bubble positioned near the user's
location marker, rather than floating independently of the map. -->
<MapView {lat} {lng} {firstHereMounted} {firstHereVisible} />
</div>
{/if}
<!-- show the right panel based on mobile or desktop-->
{#if windowWidth < 768}
<BottomSheet message={$mapStore.selectedMessage} />
{:else}
<!-- SidePanel itself swaps between the list view and a message detail
view depending on whether a message is selected -->
<SidePanel message={$mapStore.selectedMessage} />
{/if}
<!--floating action button for adding a message-->
{#if !$mapStore.composing}
<!-- desktop-only stamp book entry point, sits just above the FAB. mobile gets
a bottom-nav tab instead (see below). Outline icon when no stamps have
been earned yet, filled/decorated once at least one has - same icon pair
as the mobile nav-item below. -->
{#if windowWidth >= 768}
<a href="/stampbook" class="stamp-book-btn" class:has-stamps={$stampsStore.length > 0} aria-label="Your stamp book" title="Your stamp book">
<!-- "smiley face sticker" icon: circle with two eyes + a smile -
filled (decorated sticker) once at least one stamp is earned,
plain outline until then -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" fill="currentColor" fill-opacity={$stampsStore.length > 0 ? 0.15 : 0}/>
<circle cx="9" cy="10" r="0.9" fill="currentColor" stroke="none"/>
<circle cx="15" cy="10" r="0.9" fill="currentColor" stroke="none"/>
<path d="M8 14.5c1 1.5 2.5 2 4 2s3-0.5 4-2"/>
</svg>
</a>
{/if}
<!-- floating action button for adding a message; hidden while composing, and
hidden while a message is open on mobile (BottomSheet covers this area)
- but kept visible on desktop even with a message open, since SidePanel
is a fixed sidebar that doesn't cover the map/FAB -->
{#if !$mapStore.composing && (!$mapStore.selectedMessage || !isMobile)}
<button
class="fab"
onclick={() => mapStore.set(
@@ -78,9 +343,47 @@
</button>
{/if}
<!-- bottom nav, mobile only -->
{#if windowWidth < 768}
<nav class="bottom-nav">
<a href="/" class="nav-item" class:active={$page.url.pathname === '/'}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
<span>Map</span>
</a>
<a href="/archive" class="nav-item" class:active={$page.url.pathname === '/archive'}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M2 9h20"/>
<path d="M9 4v5"/>
<path d="M15 4v5"/>
</svg>
<span>Archive</span>
</a>
<!-- "passport stamps" tab - "smiley face sticker" icon (circle with
two eyes + a smile), outline with zero stamps, filled/decorated
once at least one has been earned (see stampsStore) -->
<a href="/stampbook" class="nav-item" class:active={$page.url.pathname === '/stampbook'}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" fill="currentColor" fill-opacity={$stampsStore.length > 0 ? 0.15 : 0}/>
<circle cx="9" cy="10" r="0.9" fill="currentColor" stroke="none"/>
<circle cx="15" cy="10" r="0.9" fill="currentColor" stroke="none"/>
<path d="M8 14.5c1 1.5 2.5 2 4 2s3-0.5 4-2"/>
</svg>
<span>Stamps</span>
</a>
</nav>
{/if}
<!--compose sheet (making a message)-->
{#if $mapStore.composing}
<ComposeSheet {lat} {lng} />
<!-- pass isMobile so ComposeSheet can render as a bottom sheet on mobile
(unchanged) vs. a centered popup with backdrop on desktop -->
<ComposeSheet {lat} {lng} {isMobile} />
{/if}
@@ -88,7 +391,8 @@
:global(body) {
margin: 0;
padding: 0;
overflow: hidden;
/* overflow: hidden moved into onMount above, scoped to this page's
lifetime - removed here so it doesn't leak into /archive. */
}
.error, .loading {
@@ -101,19 +405,46 @@
}
/* "this one has already faded" notice - small pastel pill, top-center,
fades in/out via .visible (same opacity transition as .ritual-overlay in
+layout.svelte and .first-here in MapView.svelte) */
.faded-notice {
position: fixed;
top: 1.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 250;
margin: 0;
background: #fdfaf5;
color: #999;
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 0.85rem;
padding: 0.6rem 1.2rem;
border-radius: 50px;
opacity: 0;
transition: opacity 1.2s ease;
pointer-events: none;
}
.faded-notice.visible {
opacity: 1;
}
/* purple FAB; only rendered when nothing is composing/selected, so no
"shifted" state is needed anymore (that rule has been removed) */
.fab {
position: fixed;
bottom: 2rem;
bottom: 5rem;
right: 1.5rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: #111;
background: #c4a8f5;
color: white;
font-size: 1.8rem;
border: none;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
z-index: 150;
display: flex;
align-items:center;
@@ -121,4 +452,91 @@
line-height: 1;
}
/* desktop has no bottom nav bar, so the 5rem bottom offset above (sized to
clear the mobile nav) leaves the FAB looking awkwardly high - bring it
down closer to the corner. mobile keeps the 5rem value from .fab above. */
@media (min-width: 768px) {
.fab {
bottom: 1.5rem;
}
}
/* desktop-only "passport stamps" entry point, stacked directly above the FAB
(56px tall + the FAB's 1.5rem bottom offset + a small gap) - smaller and
more muted than the FAB since it's a secondary action */
.stamp-book-btn {
position: fixed;
right: 1.5rem;
bottom: calc(1.5rem + 56px + 0.75rem);
width: 44px;
height: 44px;
border-radius: 50%;
background: white;
border: 1px solid rgba(0, 0, 0, 0.08);
color: #bbb;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
z-index: 150;
transition: color 0.15s, border-color 0.15s;
}
/* once at least one stamp is earned, tint the button the same purple used
for .nav-item.active / .echo-button elsewhere in the app */
.stamp-book-btn.has-stamps {
color: #c4a8f5;
border-color: rgba(196, 168, 245, 0.4);
}
/* mobile-only bottom nav bar with Map/Archive links */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: white;
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-around;
z-index: 200;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
text-decoration: none;
color: #ccc;
font-size: 0.7rem;
font-family: sans-serif;
transition: color 0.15s;
}
/* highlight the tab matching the current route */
.nav-item.active {
color: #c4a8f5;
}
/* map fills the screen on mobile; on desktop it's pushed right of the
fixed-width SidePanel (340px) so the panel doesn't sit on top of it.
box-sizing: border-box so the inline padding-bottom (reserved for the
bottom nav on mobile) shrinks the map instead of overflowing 100vh */
.map-container {
width: 100%;
height: 100vh;
box-sizing: border-box;
}
@media (min-width: 768px) {
.map-container {
margin-left: 340px;
width: calc(100% - 340px);
}
}
</style>

View File

@@ -0,0 +1,191 @@
<script>
// Archive page: lists every message this device (anonymous Firebase UID) has authored
import { onMount } from 'svelte';
import { getMyMessages } from '$lib/firebase/messages.js';
import { getDecayInfo } from '$lib/utils/time.js';
import { getStats } from '$lib/utils/stats.js';
let messages = $state([]);
let loading = $state(true);
// personal engagement stats (read/echoed/letGo) - purely local to this
// device (see stats.js), read once when the page mounts
let stats = $state({ read: 0, echoed: 0, letGo: 0 });
onMount(async () => {
messages = await getMyMessages();
loading = false;
stats = getStats();
});
</script>
<div class="archive">
<div class="archive-header">
<a href="/" class="back">← Back to map</a>
<h1>Your messages</h1>
<p class="subtitle">Everything you've left behind</p>
</div>
<!-- personal engagement stats summary - a private mirror of how this
device has moved through the app, see stats.js -->
<p class="stats-line">
you've read {stats.read} messages, echoed {stats.echoed}, let {stats.letGo} fade.
</p>
{#if loading}
<div class="loading">Loading your messages...</div>
{:else if messages.length === 0}
<div class="empty">
<p>You haven't left any messages yet.</p>
<a href="/" class="go-back">Go leave one →</a>
</div>
{:else}
<div class="list">
{#each messages as msg}
<!-- fade-out timing for this message -->
{@const decay = getDecayInfo(msg.createdAt, msg.lastEchoAt)}
<div class="card" class:faded={decay.isExpired}>
{#if msg.imageUrl}
<img class="card-img" src={msg.imageUrl} alt="message" />
{/if}
<p class="card-text">{msg.text}</p>
<div class="card-meta">
{#if decay.isExpired}
<span class="status faded">🌫️ Faded</span>
{:else}
<span class="status alive">✦ Alive</span>
{/if}
<span></span>
<span>left {decay.daysAgo} days ago</span>
<span></span>
<span>{decay.daysLeft} days left</span>
<span></span>
<span>echoed {msg.echoCount} times</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.archive {
max-width: 600px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
font-family: sans-serif;
}
.archive-header {
margin-bottom: 2rem;
}
.back {
font-size: 0.85rem;
color: #999;
text-decoration: none;
display: inline-block;
margin-bottom: 1rem;
}
.back:hover { color: #111; }
h1 {
font-size: 1.6rem;
font-weight: 700;
color: #111;
margin-bottom: 0.3rem;
}
.subtitle {
font-size: 0.85rem;
color: #aaa;
}
/* personal engagement stats - calm/muted like .subtitle, with a slight
contemplative italic to set it apart as a private aside rather than
part of the page's structural header */
.stats-line {
font-size: 0.85rem;
color: #999;
font-style: italic;
margin: -0.5rem 0 2rem;
}
.loading {
text-align: center;
color: #aaa;
padding: 3rem 0;
font-size: 0.9rem;
}
.empty {
text-align: center;
padding: 3rem 0;
color: #aaa;
}
.go-back {
display: inline-block;
margin-top: 0.75rem;
color: #c4a8f5;
text-decoration: none;
font-size: 0.9rem;
}
.list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.card {
background: white;
border: 1px solid #eee;
border-radius: 14px;
padding: 1.2rem;
transition: opacity 0.2s;
}
.card.faded {
opacity: 0.5;
}
.card-img {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 10px;
margin-bottom: 0.75rem;
}
.card-text {
font-size: 0.95rem;
color: #111;
line-height: 1.6;
margin-bottom: 0.75rem;
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.75rem;
color: #bbb;
align-items: center;
}
.status {
font-weight: 600;
font-size: 0.75rem;
}
.status.alive { color: #c4a8f5; }
.status.faded { color: #ccc; }
</style>

View File

@@ -0,0 +1,131 @@
<script>
// Stamp book: a "passport" of every geohash-4 region (~city-sized) this
// device/user (anonymous Firebase UID) has ever posted a message from -
// see firebase/stamps.js for how stamps are earned and stored.
import { onMount } from 'svelte';
import { getStamps } from '$lib/firebase/stamps.js';
import Stamp from '$lib/components/Stamp.svelte';
let stamps = $state([]);
let loading = $state(true);
onMount(async () => {
stamps = await getStamps();
loading = false;
});
// --- scattered "sticker album" layout -----------------------------------
// Returns a small random rotation + x/y offset for one stamp, so stickers
// feel hand-placed rather than lined up in a tidy grid. The underlying
// container is still a flex-wrap grid (see .stamp-grid below), which is
// what keeps stamps from drastically overlapping or running off the
// page - this just jitters each one slightly within its grid cell.
function randomTransform() {
const rotate = (Math.random() * 16 - 8).toFixed(1);
const x = (Math.random() * 16 - 8).toFixed(1);
const y = (Math.random() * 16 - 8).toFixed(1);
return `transform: rotate(${rotate}deg) translate(${x}px, ${y}px)`;
}
</script>
<div class="stampbook">
<div class="stampbook-header">
<a href="/" class="back">← Back to map</a>
<h1>Your stamp book</h1>
<p class="subtitle">A passport of everywhere you've left something behind</p>
</div>
{#if loading}
<div class="loading">Loading your stamps...</div>
{:else if stamps.length === 0}
<!-- empty state - same contemplative tone as the "first here" /
"already faded" ambient messages elsewhere in the app -->
<div class="empty">
<p>Your stamp book is empty.</p>
<p class="empty-sub">Leave a message somewhere new, and a piece of that place will stay here with you.</p>
</div>
{:else}
<div class="stamp-grid">
{#each stamps as stamp}
<div class="stamp-slot" style={randomTransform()}>
<Stamp {stamp} />
</div>
{/each}
</div>
{/if}
</div>
<style>
.stampbook {
max-width: 700px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
font-family: sans-serif;
}
.stampbook-header {
margin-bottom: 2rem;
}
.back {
font-size: 0.85rem;
color: #999;
text-decoration: none;
display: inline-block;
margin-bottom: 1rem;
}
.back:hover {
color: #111;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
color: #111;
margin-bottom: 0.3rem;
}
.subtitle {
font-size: 0.85rem;
color: #aaa;
}
.loading {
text-align: center;
color: #aaa;
padding: 3rem 0;
font-size: 0.9rem;
}
.empty {
text-align: center;
padding: 3rem 1rem;
color: #999;
}
.empty-sub {
font-size: 0.85rem;
color: #bbb;
margin-top: 0.5rem;
max-width: 320px;
margin-left: auto;
margin-right: auto;
line-height: 1.6;
}
/* flex-wrap "sticker album" surface - extra row/column gap gives each
stamp's random translate() room to jitter without colliding with its
neighbors (see randomTransform above) */
.stamp-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem 1.5rem;
padding: 1rem 0;
}
.stamp-slot {
flex-shrink: 0;
}
</style>

20
storage.rules Normal file
View File

@@ -0,0 +1,20 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /messages/{fileName} {
// anyone can view uploaded images
allow read: if true;
// anyone can upload an image attached to a message
// must be an image under 5MB
allow create: if
request.resource.size < 5 * 1024 * 1024 &&
request.resource.contentType.matches('image/.*');
// no edits or deletes through the client
allow update, delete: if false;
}
}
}

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
@@ -7,11 +7,12 @@ const config = {
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html'
})
}
};
export default config;
export default config;