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
index.html,1781458582579,c238f8f4335a07ba3975ee476ef3d9dc7029dbb4da2414bebea79539edb43ad8
_app/env.js,1781458582001,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638
_app/immutable/nodes/1.DRGycYkH.js,1781458581476,ddc216c00274522f0163f6e17d7a61a5f83e3e464cc4db9a443d84028c562c60
_app/version.json,1781458581482,cbb552342fea01555352de88ca2249dc1c7967fe5631dc49cd83e85f37f1574b
_app/immutable/nodes/3.DuWWIOOm.js,1781458581477,26d857c375138b43445caa866442182f628215754f510953baf51b687ecc4262
_app/immutable/entry/start.BqO-PbzI.js,1781458581475,d82dbd922861c4a44043e136625387f1e0c3abe17c39221d0f5d2f704c27dc75
_app/immutable/chunks/xihTtKlq.js,1781458581481,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e
_app/immutable/chunks/kNaey6uv.js,1781458581480,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610
_app/immutable/entry/app.BKmqOLpp.js,1781458581474,8f657f3df0b7da5e2f354e4d0daaa527dd9154560deb2debb22be3a6d449cc1a
_app/immutable/chunks/dYw4s8FK.js,1781458581480,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb
_app/immutable/chunks/BqvGMMFS.js,1781458581477,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999
_app/immutable/assets/2.B2G-eVjW.css,1781458581481,1c67ba19797be9f8fde6c067517d129820a6cf0d03c38e440bb6d9f9c83568a7
_app/immutable/assets/3.RINX6wbk.css,1781458581481,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d
_app/immutable/assets/0.DB_w-Orf.css,1781458581481,65816fda749a1c0cca0e0d336ab5ce2e8072226d1011391e0fa3577e8f8d7cd2
_app/immutable/nodes/0.OUbaqqFA.js,1781458581475,d211915e16706bad6122ec7403c04f7e81e2db36ef347c39cc5dc9d54d67e6da
_app/immutable/chunks/DDp4mPSJ.js,1781458581478,02d46f2be4b0586a43d9f3a29ea25c0c44c22e444da9943f92b1fbb3f0ef4105
_app/immutable/chunks/Gly99FG6.js,1781458581480,b075ba40b4d6f384b98ffa0ab35b245b03b81877288e357fa3bfb31b24a41e54
_app/immutable/chunks/D4ShdbHl.js,1781458581478,a5cf19027054fbc801c5e3c64857c30ba4a549222e534629580b035eb730efad
_app/immutable/nodes/2.BnMN9e9a.js,1781458581476,4c5aeb7ba20a00990a6147dfa493edbe74409f93da27b40a312c423455c26013
_app/immutable/chunks/Dub02Wc7.js,1781458581478,b8f2be198d8c6cab67697e1420f2a9ed824cde281d9c998f1ce056daadd84bf1
index.html,1781534986659,c4e04f5484da1c956597345e2f98d096d8ae250aaac041cb1579ab8eddd2dad0
_app/env.js,1781534985969,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638
_app/version.json,1781534985254,51f3700601405b891bb6acf7e8f8713fa82a5f942794780bbc05872e3ae795a9
_app/immutable/nodes/4.GA9nhugj.js,1781534985239,c7f1ca87a6c0ebdf966bf3c6f725599436d9f99fcafa37281a918d93ca846347
_app/immutable/nodes/1.lvAGEFLt.js,1781534985237,306b95c5c05905777dcae6eb606e86bb1aabd7f15978e89c11f697ea28e32aee
_app/immutable/nodes/3.Br_mUUSD.js,1781534985239,5d583a20725b7a1325d927c5303dd574b23e358fe897c91522320acc86aa6a8d
_app/immutable/nodes/0.ro2cMSuj.js,1781534985236,3eeddd736b4b5e5ef39c08228d107cb65e24bbc160df89788bc5190ee05c886a
_app/immutable/entry/start.BstLhiV9.js,1781534985236,b8a32b87469c068bd61d051f8a8ea67ae3074460be50dbd7ac7dd1feb7f579fc
_app/immutable/entry/app.DTPvPvz-.js,1781534985234,5250ef8be22692c66cb99eee468949620bcd962566596dcd753d007d909a02db
_app/immutable/chunks/dYw4s8FK.js,1781534985248,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb
_app/immutable/chunks/xihTtKlq.js,1781534985249,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e
_app/immutable/chunks/Clt4wA-V.js,1781534985243,c7aa951803761d5d9911ddea6cc7b4043eee049800a8fae1dca2eb75b7ecf54f
_app/immutable/chunks/BmXl2Dnh.js,1781534985241,acee0daed1317076016cfe5cdefa61436f9f4e6138eb63980898acf623f19243
_app/immutable/chunks/kNaey6uv.js,1781534985249,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610
_app/immutable/chunks/DOAwxdoU.js,1781534985244,58e7771df8a303c0ec3ad5bf6ccb0c1e5c16447516820a1d2e05873fc4bb63dc
_app/immutable/assets/3.RINX6wbk.css,1781534985253,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d
_app/immutable/chunks/BXRdFrf6.js,1781534985240,b5ad6763a83bf8db5fa75951cfef48c9bd596a3b411a24b624cf1a4ffddd2a7a
_app/immutable/assets/4.DNmkdUsl.css,1781534985254,bf20a1cecff78d2c2566cb760593b117110eeacc701b4c79ee9b897bbdcc2ea5
_app/immutable/chunks/BqvGMMFS.js,1781534985242,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999
_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.size() <= 240;
// the only update allowed is an echo
// 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
@@ -23,6 +23,27 @@ service cloud.firestore {
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

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 { 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
@@ -25,7 +26,7 @@ export async function getNearbyMessages(lat, lng) {
cells.map(prefix => getDocs(query(
collection(db, 'messages'),
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 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 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
@@ -77,7 +135,7 @@ export async function hasAnyMessagesEverNearby(lat, lng) {
cells.map(prefix => getCountFromServer(query(
collection(db, 'messages'),
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
// when this is null (see pinColor() in pins.js).
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'), {
text,
@@ -133,6 +195,18 @@ export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null
} 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);
}
}
// 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>
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 { 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
@@ -107,7 +108,12 @@
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',
// mapId removed - legacy Markers don't need it, and removing it
@@ -119,12 +125,44 @@
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 renderPins(messages) {
// clear current pins
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 => {
// every message gets the same plain circle pin (previously this
// varied - star/circle/heart - based on read/unread/echoed state,
@@ -134,12 +172,22 @@
// 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,
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
@@ -149,8 +197,19 @@
mapStore.set({ selectedMessage: message, composing: false });
});
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

View File

@@ -161,7 +161,7 @@
{:else}
<!-- list view: header, compose button, nearby messages -->
<div class="panel-header">
<h1>Overheard: Shared Secrets</h1>
<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>
@@ -298,18 +298,25 @@
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 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
without being a jarring "alert" color like red. */
.detail-wrap.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);
}

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
// --- "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

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() {
if (faceDetector) return;
console.log('[faceDetection] loading model...');
const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm'
);
@@ -16,7 +15,6 @@ export async function loadFaceDetector() {
},
runningMode: 'IMAGE'
});
console.log('[faceDetection] model ready');
}
// 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);
URL.revokeObjectURL(objectUrl);
console.log('[faceDetection] detections:', result.detections);
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
// encodes the latitude/longitude pair to a 6 character string geohash (~1.2km radius)
// reverted from 9 -> 6: must match the precision addMessage() writes to Firestore
// (messages.js uses ngeohash.encode(lat, lng, 6)), otherwise stored geohashes
// are shorter than this prefix and the >= / < range query in getNearbyMessages
// never matches anything (this is why pins disappeared)
// 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, 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
// reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes
// stored on existing messages (see encode() above)
// 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, 6);
}
@@ -36,7 +36,27 @@ export function getQueryPrefix(lat, lng) {
// 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

@@ -163,3 +163,62 @@ export function playEchoTone() {
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. -->
{@render children()}
<!-- global "memory counter" pill, top-right on every page EXCEPT /archive -
the archive is the user's own private list of messages, so the global
"everyone, everywhere" count would feel out of place there (see
GlobalCountPill.svelte for the pill itself). -->
{#if $page.url.pathname !== '/archive'}
<!-- 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}

View File

@@ -2,11 +2,14 @@
import { onMount } from '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 { 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 { 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 { replaceState } from '$app/navigation'; // clears the ?message= param after handling a shared link
@@ -19,6 +22,13 @@
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);
@@ -65,7 +75,6 @@
// setMessages (instead of messagesStore.set) compares against the
// previous list and plays a soft chime for any newly-appeared pins
setMessages(messages);
console.log('messages loaded:', $messagesStore);
// --- "you're the first here" zero-results check --------
// getNearbyMessages() already filters out anything past its
@@ -86,16 +95,64 @@
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.
// (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
@@ -255,8 +312,29 @@
<SidePanel message={$mapStore.selectedMessage} />
{/if}
<!--floating action button for adding a message; hidden while a message is open-->
{#if !$mapStore.composing && !$mapStore.selectedMessage}
<!-- 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(
@@ -286,6 +364,18 @@
</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}
@@ -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 */
.bottom-nav {
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>