second to last commit hopefully final features and Ui refinements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
85
scripts/migrate-geohash-precision.js
Normal file
85
scripts/migrate-geohash-precision.js
Normal 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}`);
|
||||
@@ -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
|
||||
|
||||
79
src/lib/Firebase/stamps.js
Normal file
79
src/lib/Firebase/stamps.js
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
69
src/lib/components/Stamp.svelte
Normal file
69
src/lib/components/Stamp.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
9
src/lib/stores/stampsStore.js
Normal file
9
src/lib/stores/stampsStore.js
Normal 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([]);
|
||||
@@ -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
33
src/lib/utils/geocode.js
Normal 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' };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
37
src/lib/utils/stampIcons.js
Normal file
37
src/lib/utils/stampIcons.js
Normal 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];
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
131
src/routes/stampbook/+page.svelte
Normal file
131
src/routes/stampbook/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user