second to last commit hopefully final features and Ui refinements

This commit is contained in:
2026-06-15 23:51:24 +09:00
parent 4c20bf5ab6
commit ad2c538e49
18 changed files with 874 additions and 61 deletions

View File

@@ -1,21 +1,25 @@
robots.txt,1780149196204,c42e958aa717ecf11e70c927ebe348b7cfdeb317d164463dde44a6245edc25bf robots.txt,1780149196204,c42e958aa717ecf11e70c927ebe348b7cfdeb317d164463dde44a6245edc25bf
index.html,1781458582579,c238f8f4335a07ba3975ee476ef3d9dc7029dbb4da2414bebea79539edb43ad8 index.html,1781534986659,c4e04f5484da1c956597345e2f98d096d8ae250aaac041cb1579ab8eddd2dad0
_app/env.js,1781458582001,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638 _app/env.js,1781534985969,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638
_app/immutable/nodes/1.DRGycYkH.js,1781458581476,ddc216c00274522f0163f6e17d7a61a5f83e3e464cc4db9a443d84028c562c60 _app/version.json,1781534985254,51f3700601405b891bb6acf7e8f8713fa82a5f942794780bbc05872e3ae795a9
_app/version.json,1781458581482,cbb552342fea01555352de88ca2249dc1c7967fe5631dc49cd83e85f37f1574b _app/immutable/nodes/4.GA9nhugj.js,1781534985239,c7f1ca87a6c0ebdf966bf3c6f725599436d9f99fcafa37281a918d93ca846347
_app/immutable/nodes/3.DuWWIOOm.js,1781458581477,26d857c375138b43445caa866442182f628215754f510953baf51b687ecc4262 _app/immutable/nodes/1.lvAGEFLt.js,1781534985237,306b95c5c05905777dcae6eb606e86bb1aabd7f15978e89c11f697ea28e32aee
_app/immutable/entry/start.BqO-PbzI.js,1781458581475,d82dbd922861c4a44043e136625387f1e0c3abe17c39221d0f5d2f704c27dc75 _app/immutable/nodes/3.Br_mUUSD.js,1781534985239,5d583a20725b7a1325d927c5303dd574b23e358fe897c91522320acc86aa6a8d
_app/immutable/chunks/xihTtKlq.js,1781458581481,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e _app/immutable/nodes/0.ro2cMSuj.js,1781534985236,3eeddd736b4b5e5ef39c08228d107cb65e24bbc160df89788bc5190ee05c886a
_app/immutable/chunks/kNaey6uv.js,1781458581480,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610 _app/immutable/entry/start.BstLhiV9.js,1781534985236,b8a32b87469c068bd61d051f8a8ea67ae3074460be50dbd7ac7dd1feb7f579fc
_app/immutable/entry/app.BKmqOLpp.js,1781458581474,8f657f3df0b7da5e2f354e4d0daaa527dd9154560deb2debb22be3a6d449cc1a _app/immutable/entry/app.DTPvPvz-.js,1781534985234,5250ef8be22692c66cb99eee468949620bcd962566596dcd753d007d909a02db
_app/immutable/chunks/dYw4s8FK.js,1781458581480,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb _app/immutable/chunks/dYw4s8FK.js,1781534985248,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb
_app/immutable/chunks/BqvGMMFS.js,1781458581477,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999 _app/immutable/chunks/xihTtKlq.js,1781534985249,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e
_app/immutable/assets/2.B2G-eVjW.css,1781458581481,1c67ba19797be9f8fde6c067517d129820a6cf0d03c38e440bb6d9f9c83568a7 _app/immutable/chunks/Clt4wA-V.js,1781534985243,c7aa951803761d5d9911ddea6cc7b4043eee049800a8fae1dca2eb75b7ecf54f
_app/immutable/assets/3.RINX6wbk.css,1781458581481,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d _app/immutable/chunks/BmXl2Dnh.js,1781534985241,acee0daed1317076016cfe5cdefa61436f9f4e6138eb63980898acf623f19243
_app/immutable/assets/0.DB_w-Orf.css,1781458581481,65816fda749a1c0cca0e0d336ab5ce2e8072226d1011391e0fa3577e8f8d7cd2 _app/immutable/chunks/kNaey6uv.js,1781534985249,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610
_app/immutable/nodes/0.OUbaqqFA.js,1781458581475,d211915e16706bad6122ec7403c04f7e81e2db36ef347c39cc5dc9d54d67e6da _app/immutable/chunks/DOAwxdoU.js,1781534985244,58e7771df8a303c0ec3ad5bf6ccb0c1e5c16447516820a1d2e05873fc4bb63dc
_app/immutable/chunks/DDp4mPSJ.js,1781458581478,02d46f2be4b0586a43d9f3a29ea25c0c44c22e444da9943f92b1fbb3f0ef4105 _app/immutable/assets/3.RINX6wbk.css,1781534985253,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d
_app/immutable/chunks/Gly99FG6.js,1781458581480,b075ba40b4d6f384b98ffa0ab35b245b03b81877288e357fa3bfb31b24a41e54 _app/immutable/chunks/BXRdFrf6.js,1781534985240,b5ad6763a83bf8db5fa75951cfef48c9bd596a3b411a24b624cf1a4ffddd2a7a
_app/immutable/chunks/D4ShdbHl.js,1781458581478,a5cf19027054fbc801c5e3c64857c30ba4a549222e534629580b035eb730efad _app/immutable/assets/4.DNmkdUsl.css,1781534985254,bf20a1cecff78d2c2566cb760593b117110eeacc701b4c79ee9b897bbdcc2ea5
_app/immutable/nodes/2.BnMN9e9a.js,1781458581476,4c5aeb7ba20a00990a6147dfa493edbe74409f93da27b40a312c423455c26013 _app/immutable/chunks/BqvGMMFS.js,1781534985242,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999
_app/immutable/chunks/Dub02Wc7.js,1781458581478,b8f2be198d8c6cab67697e1420f2a9ed824cde281d9c998f1ce056daadd84bf1 _app/immutable/assets/0.DB_w-Orf.css,1781534985251,65816fda749a1c0cca0e0d336ab5ce2e8072226d1011391e0fa3577e8f8d7cd2
_app/immutable/assets/2.BAt0MrfH.css,1781534985252,7b585e41d74917eac70caacaddcfe19f9760dba8b270cb860b0ca31e485ebb11
_app/immutable/chunks/z5owE3AB.js,1781534985250,a3cbca6624018231c584f76781735d69f12b229ce8858cbcd219da801737e265
_app/immutable/nodes/2.g303636_.js,1781534985237,4ab58e678b87cbac73cf937dc61fe562a45db4f74a9ca74a687746d4371a4166
_app/immutable/chunks/DdlKGuOI.js,1781534985246,03899c735f5705ef92c0abb1351af5ff6a86455552bda7df4907b3cfc3b87228

View File

@@ -12,7 +12,7 @@ service cloud.firestore {
request.resource.data.text is string && request.resource.data.text is string &&
request.resource.data.text.size() <= 240; request.resource.data.text.size() <= 240;
// the only update allowed is an echo // the only update allowed is an echo
// echoCount and lastEchoAt fields can be changed // echoCount and lastEchoAt fields can be changed
// this prevents anyone from editing the text of someone else's message // this prevents anyone from editing the text of someone else's message
allow update: if allow update: if
@@ -23,6 +23,27 @@ service cloud.firestore {
allow delete: if false; 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) ------------------------- // --- global "memory counter" (GlobalCountPill) -------------------------
// a single shared doc, meta/stats, holding totalMessagesEverPosted. // a single shared doc, meta/stats, holding totalMessagesEverPosted.
// anyone can read it (it's shown to every visitor); writes are limited to // anyone can read it (it's shown to every visitor); writes are limited to

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

@@ -1,8 +1,9 @@
import { collection, query, where, getDocs, addDoc, getCountFromServer } from 'firebase/firestore'; // tools for building and running db queries 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 { 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 { 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 { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
import { checkAndAwardStamp } from './stamps.js'; // "passport stamps" - see stamps.js
// --- global "memory counter" ----------------------------------------------- // --- global "memory counter" -----------------------------------------------
// A single document (meta/stats) holds `totalMessagesEverPosted`: a running // A single document (meta/stats) holds `totalMessagesEverPosted`: a running
@@ -25,7 +26,7 @@ export async function getNearbyMessages(lat, lng) {
cells.map(prefix => getDocs(query( cells.map(prefix => getDocs(query(
collection(db, 'messages'), collection(db, 'messages'),
where('geohash', '>=', prefix), where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z') where('geohash', '<', prefix + '')
))) )))
); );
@@ -48,14 +49,71 @@ export async function getNearbyMessages(lat, lng) {
const echoTime = message.lastEchoAt?.toMillis() ?? message.createdAt.toMillis(); const echoTime = message.lastEchoAt?.toMillis() ?? message.createdAt.toMillis();
const daysSinceEcho = (now - echoTime) / (1000 * 60 * 60 * 24); const daysSinceEcho = (now - echoTime) / (1000 * 60 * 60 * 24);
//console.log(message.text.slice(0, 20), '→ days since echo:', daysSinceEcho);
return daysSinceEcho < 30; // less than 30 means it lives / is active return daysSinceEcho < 30; // less than 30 means it lives / is active
}); });
return 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) ------------------------- // --- "you're the first here" check (+page.svelte) -------------------------
// getNearbyMessages() above already fetches every doc matching the geohash // getNearbyMessages() above already fetches every doc matching the geohash
// prefix into `all`, then filters out anything past its 30-day decay into // prefix into `all`, then filters out anything past its 30-day decay into
@@ -77,7 +135,7 @@ export async function hasAnyMessagesEverNearby(lat, lng) {
cells.map(prefix => getCountFromServer(query( cells.map(prefix => getCountFromServer(query(
collection(db, 'messages'), collection(db, 'messages'),
where('geohash', '>=', prefix), where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z') where('geohash', '<', prefix + '')
))) )))
); );
@@ -99,7 +157,11 @@ export async function echoMessage(messageId) {
// stored as-is on the message doc; pins.js falls back to a random pastel // stored as-is on the message doc; pins.js falls back to a random pastel
// when this is null (see pinColor() in pins.js). // when this is null (see pinColor() in pins.js).
export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null){ export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null){
const geohash = ngeohash.encode(lat, lng, 6); // 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'), { await addDoc(collection(db, 'messages'), {
text, text,
@@ -133,6 +195,18 @@ export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null
} catch (err) { } catch (err) {
console.error('[memory counter] failed to increment meta/stats:', 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);
}
} }
// fetch the current value of the global "memory counter" (meta/stats), for // fetch the current value of the global "memory counter" (meta/stats), for

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

@@ -1,7 +1,8 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { messagesStore } from '$lib/stores/messagesStore.js'; // pass the messages store here 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 { 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 { 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 { mapStyles } from '$lib/utils/mapStyles.js'; // desaturated/atmospheric map styling - only works now that mapId is gone
@@ -107,7 +108,12 @@
map = new Map(mapDiv, { map = new Map(mapDiv, {
center: { lat: centerLat, lng: centerLng }, 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, disableDefaultUI: true,
gestureHandling: 'greedy', gestureHandling: 'greedy',
// mapId removed - legacy Markers don't need it, and removing it // mapId removed - legacy Markers don't need it, and removing it
@@ -119,12 +125,44 @@
addUserLocationMarker(centerLat, centerLng); addUserLocationMarker(centerLat, centerLng);
}); });
// --- "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 to render pins
function renderPins(messages) { function renderPins(messages) {
// clear current pins // clear current pins
markers.forEach(marker => marker.setMap(null)); markers.forEach(marker => marker.setMap(null));
markers = []; 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 => { messages.forEach(message => {
// every message gets the same plain circle pin (previously this // every message gets the same plain circle pin (previously this
// varied - star/circle/heart - based on read/unread/echoed state, // varied - star/circle/heart - based on read/unread/echoed state,
@@ -134,12 +172,22 @@
// stored in Firestore via addMessage) overrides the random pastel // stored in Firestore via addMessage) overrides the random pastel
// color inside messagePin() when present - see pins.js's pinColor() // color inside messagePin() when present - see pins.js's pinColor()
const icon = messagePin(message.moodColor); const icon = messagePin(message.moodColor);
const isLivePin = newLiveIds.has(message.id);
const marker = new Marker({ const marker = new Marker({
position: { lat: message.lat, lng: message.lng }, position: { lat: message.lat, lng: message.lng },
map, map,
title: message.text, title: message.text,
icon: toMarkerIcon(icon) // 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 // legacy Marker uses the Maps JS event system (addListener), not
@@ -149,8 +197,19 @@
mapStore.set({ selectedMessage: message, composing: false }); mapStore.set({ selectedMessage: message, composing: false });
}); });
if (isLivePin) {
animatePinBounce(marker, icon);
}
markers.push(marker); 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 // this is a reactive statement so anytime the store changes it updates

View File

@@ -161,7 +161,7 @@
{:else} {:else}
<!-- list view: header, compose button, nearby messages --> <!-- list view: header, compose button, nearby messages -->
<div class="panel-header"> <div class="panel-header">
<h1>Overheard: Shared Secrets</h1> <h1>Overheard: Shared memories</h1>
<!-- ambient presence indicator replacing the old static "Messages near <!-- ambient presence indicator replacing the old static "Messages near
you" text - styling/font-size unchanged, only the copy is dynamic --> you" text - styling/font-size unchanged, only the copy is dynamic -->
<p class="subtitle">{presenceText}</p> <p class="subtitle">{presenceText}</p>
@@ -298,18 +298,25 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; 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 /* "last chance" treatment - a soft warm peach tint + border, distinct from
the normal panel background but staying within the existing pastel/ the normal gray card above but staying within the existing pastel/
parchment palette. A faint glow (box-shadow) adds a touch of warmth parchment palette. A faint glow (box-shadow) adds a touch of warmth
without being a jarring "alert" color like red. */ without being a jarring "alert" color like red. */
.detail-wrap.last-chance { .detail-wrap.last-chance {
background: #fff6ea; background: #fff6ea;
border: 1px solid #f3dcb8; 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); box-shadow: 0 0 0 4px rgba(243, 184, 92, 0.08);
} }

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

@@ -3,6 +3,16 @@ 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 // 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 - // 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 // covering both "someone else posted nearby" and "this device's own query

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

@@ -5,7 +5,6 @@ let faceDetector = null;
export async function loadFaceDetector() { export async function loadFaceDetector() {
if (faceDetector) return; if (faceDetector) return;
console.log('[faceDetection] loading model...');
const vision = await FilesetResolver.forVisionTasks( const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm' 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm'
); );
@@ -16,7 +15,6 @@ export async function loadFaceDetector() {
}, },
runningMode: 'IMAGE' runningMode: 'IMAGE'
}); });
console.log('[faceDetection] model ready');
} }
// fixed: parameter was named 'File' (capital F) but used as 'file' below — caused ReferenceError // fixed: parameter was named 'File' (capital F) but used as 'file' below — caused ReferenceError
@@ -39,6 +37,5 @@ export async function hasFace(file) {
const result = faceDetector.detect(img); const result = faceDetector.detect(img);
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
console.log('[faceDetection] detections:', result.detections);
return result.detections.length > 0; 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,20 +1,20 @@
import ngeohash from 'ngeohash'; // library that does the geohasing encoding/decoding yippee 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
// reverted from 9 -> 6: must match the precision addMessage() writes to Firestore // (~5m precision) - this matches the precision addMessage() now stores on
// (messages.js uses ngeohash.encode(lat, lng, 6)), otherwise stored geohashes // every message (see messages.js), which is the maximum precision this app
// are shorter than this prefix and the >= / < range query in getNearbyMessages // ever needs, so getQueryPrefix() below always has room to use any shorter
// never matches anything (this is why pins disappeared) // prefix without breaking the range query.
export function encode(lat, lng) { export function encode(lat, lng) {
return ngeohash.encode(lat, lng, 9); 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 // encodes a lat/lng pair to a string geohash used as a Firestore query
// basically like looking for all geohashes that start with this 4 characters // prefix - the "nearby" area shrinks as this precision increases. Safe to
// it will include all geohashes in a ~40km radius of the given lat/lng pair // tune anywhere from 1-9: a shorter prefix can always match a longer stored
// geohash via the >= / < range query in getNearbyMessages, just not the
// reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes // other way around (a prefix LONGER than the stored geohash's 9 characters
// stored on existing messages (see encode() above) // would never match anything - same failure mode as before).
export function getQueryPrefix(lat, lng) { export function getQueryPrefix(lat, lng) {
return ngeohash.encode(lat, lng, 6); return ngeohash.encode(lat, lng, 6);
} }
@@ -36,7 +36,27 @@ export function getQueryPrefix(lat, lng) {
// other (which covers normal GPS/WiFi error), their 3x3 grids overlap enough // 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 // to cover the same messages - so both devices see the same "nearby" set
// even if they're each centered in a different cell. // 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) { export function getNearbyGeohashCells(lat, lng) {
const center = getQueryPrefix(lat, lng); const center = getQueryPrefix(lat, lng);
return [center, ...ngeohash.neighbors(center)]; 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

@@ -163,3 +163,62 @@ export function playEchoTone() {
playTone(ctx, frequency, startTime, noteDuration, peakGain); 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];
}

View File

@@ -78,11 +78,11 @@
block or delay anything underneath. --> block or delay anything underneath. -->
{@render children()} {@render children()}
<!-- global "memory counter" pill, top-right on every page EXCEPT /archive - <!-- global "memory counter" pill, top-right on every page EXCEPT /archive and
the archive is the user's own private list of messages, so the global /stampbook - both are the user's own private views (their messages, their
"everyone, everywhere" count would feel out of place there (see earned stamps), so the global "everyone, everywhere" count would feel out
GlobalCountPill.svelte for the pill itself). --> of place there (see GlobalCountPill.svelte for the pill itself). -->
{#if $page.url.pathname !== '/archive'} {#if $page.url.pathname !== '/archive' && $page.url.pathname !== '/stampbook'}
<GlobalCountPill /> <GlobalCountPill />
{/if} {/if}

View File

@@ -2,11 +2,14 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import MapView from '$lib/components/MapView.svelte'; import MapView from '$lib/components/MapView.svelte';
import { getNearbyMessages, hasAnyMessagesEverNearby, getMessageById } from '$lib/firebase/messages.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 { getDecayInfo } from '$lib/utils/time.js'; // checks whether a shared message has expired
import { messagesStore, setMessages } from '$lib/stores/messagesStore.js'; 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 { mapStore } from '$lib/stores/mapStore.js';
import { initAuth } from '$lib/stores/userStore.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 { 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 { replaceState } from '$app/navigation'; // clears the ?message= param after handling a shared link
@@ -19,6 +22,13 @@
let lng = $state(); let lng = $state();
let error = $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 windowWidth = $state(0);
let isMobile = $derived(windowWidth < 768); let isMobile = $derived(windowWidth < 768);
@@ -65,7 +75,6 @@
// setMessages (instead of messagesStore.set) compares against the // setMessages (instead of messagesStore.set) compares against the
// previous list and plays a soft chime for any newly-appeared pins // previous list and plays a soft chime for any newly-appeared pins
setMessages(messages); setMessages(messages);
console.log('messages loaded:', $messagesStore);
// --- "you're the first here" zero-results check -------- // --- "you're the first here" zero-results check --------
// getNearbyMessages() already filters out anything past its // getNearbyMessages() already filters out anything past its
@@ -86,16 +95,64 @@
firstHereMounted = true; 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 // cleanup: undo the body overflow lock when this page unmounts
// (e.g. navigating to /archive), so other routes can scroll normally. // (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 () => { return () => {
document.body.style.overflow = ''; 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 // 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 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 // the element first paints at opacity 0, then fade in; after a short
@@ -255,8 +312,29 @@
<SidePanel message={$mapStore.selectedMessage} /> <SidePanel message={$mapStore.selectedMessage} />
{/if} {/if}
<!--floating action button for adding a message; hidden while a message is open--> <!-- desktop-only stamp book entry point, sits just above the FAB. mobile gets
{#if !$mapStore.composing && !$mapStore.selectedMessage} 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 <button
class="fab" class="fab"
onclick={() => mapStore.set( onclick={() => mapStore.set(
@@ -286,6 +364,18 @@
</svg> </svg>
<span>Archive</span> <span>Archive</span>
</a> </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> </nav>
{/if} {/if}
@@ -371,6 +461,35 @@
} }
} }
/* 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 */ /* mobile-only bottom nav bar with Map/Archive links */
.bottom-nav { .bottom-nav {
position: fixed; position: fixed;

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>