From 5f8d224d84d2924fbffe252ef2a77d0ee1a866ec Mon Sep 17 00:00:00 2001 From: Samantha Date: Sat, 13 Jun 2026 16:03:55 +0900 Subject: [PATCH] small fixes, small sound feedback, & opening screen --- src/lib/Firebase/config.js | 4 +- src/lib/Firebase/messages.js | 33 +- src/lib/components/BottomSheet.svelte | 9 +- src/lib/components/ComposeSheet.svelte | 109 ++++++- src/lib/components/MapView.Svelte | 43 ++- src/lib/components/SidePanel.svelte | 420 ++++++++++++++++++------- src/lib/stores/messagesStore.js | 23 +- src/lib/stores/userStore.js | 20 ++ src/lib/utils/geohash.js | 7 +- src/lib/utils/pins.js | 79 +++++ src/lib/utils/sound.js | 165 ++++++++++ src/routes/+layout.svelte | 139 ++++++++ src/routes/+page.svelte | 213 +++++++++++-- src/routes/archive/+page.svelte | 169 ++++++++++ 14 files changed, 1269 insertions(+), 164 deletions(-) create mode 100644 src/lib/stores/userStore.js create mode 100644 src/lib/utils/pins.js create mode 100644 src/lib/utils/sound.js create mode 100644 src/routes/archive/+page.svelte diff --git a/src/lib/Firebase/config.js b/src/lib/Firebase/config.js index 02b2c04..fa35e59 100644 --- a/src/lib/Firebase/config.js +++ b/src/lib/Firebase/config.js @@ -5,6 +5,7 @@ import { initializeApp } from 'firebase/app'; import { getFirestore } from 'firebase/firestore'; import { getStorage } from 'firebase/storage'; +import { getAuth } from 'firebase/auth'; import { PUBLIC_FIREBASE_API_KEY, PUBLIC_FIREBASE_AUTH_DOMAIN, @@ -25,4 +26,5 @@ const firebaseConfig = { const app = initializeApp(firebaseConfig); export const db = getFirestore(app); -export const storage = getStorage(app); \ No newline at end of file +export const storage = getStorage(app); +export const auth = getAuth(app); // anonymous auth, gives each device a persistent UID \ No newline at end of file diff --git a/src/lib/Firebase/messages.js b/src/lib/Firebase/messages.js index 1802c02..5d5a6b3 100644 --- a/src/lib/Firebase/messages.js +++ b/src/lib/Firebase/messages.js @@ -1,5 +1,5 @@ import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries -import { db } from './config'; // database connection +import { db, auth } from './config'; // database connection + anonymous auth import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string import { doc, updateDoc, increment, serverTimestamp } from 'firebase/firestore'; import ngeohash from 'ngeohash'; @@ -45,24 +45,29 @@ export async function addMessage(lat, lng, text, imageUrl = ''){ const geohash = ngeohash.encode(lat, lng, 6); await addDoc(collection(db, 'messages'), { - text, - imageUrl, - lat, + text, + imageUrl, + lat, lng, - geohash, + geohash, createdAt: serverTimestamp(), lastEchoAt: serverTimestamp(), echoCount: 0, - sessionId: getSessionId() + // links the message to this device's persistent anonymous Firebase UID + authorId: auth.currentUser?.uid ?? 'anon' }); } -// session ID (temporary) -function getSessionId() { - let id = localStorage.getItem('overheard_session'); - if (!id) { - id = crypto.randomUUID(); // created random useer id - localStorage.setItem('oveheard_session', id); - } - return id; +// fetch every message this device has authored, for the archive page +export async function getMyMessages() { + const uid = auth.currentUser?.uid; + if (!uid) return []; // not signed in yet, nothing to show + + const q = query( + collection(db, 'messages'), + where('authorId', '==', uid) + ); + + const snapshot = await getDocs(q); + return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); } \ No newline at end of file diff --git a/src/lib/components/BottomSheet.svelte b/src/lib/components/BottomSheet.svelte index 04e9b4a..0d329dd 100644 --- a/src/lib/components/BottomSheet.svelte +++ b/src/lib/components/BottomSheet.svelte @@ -2,6 +2,7 @@ import { mapStore } from '$lib/stores/mapStore.js'; import { getDecayInfo } from '$lib/utils/time.js' import { echoMessage } from '$lib/firebase/messages.js' + import { playEchoTone } from '$lib/utils/sound.js'; let { message } = $props(); @@ -14,6 +15,8 @@ async function handleEcho() { await echoMessage(message.id); echoed = true; + // gentle ascending "thank you" tone confirming the echo went through + playEchoTone(); } let startY = 0; // where the swipe started @@ -79,7 +82,7 @@ background: white; border-radius: 20px 20px 0 0; padding: 1rem 1.5rem 2rem; - box-shadow: 0 -4px 20px rgba(0,0,0,0.15); + /* flat 2D style for now - shadow removed, paper-style shadows may be added later */ transform: translateY(100%); transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1); z-index: 100; @@ -102,6 +105,7 @@ height: 4px; background: #ddd; border-radius: 2px; + /* flat 2D style for now - shadow removed, paper-style shadows may be added later */ } .message-text { @@ -131,6 +135,7 @@ border-radius: 10px; font-size: 0.95rem; cursor: pointer; + /* flat 2D style for now - shadow removed, paper-style shadows may be added later */ } .letgo-button { @@ -142,6 +147,7 @@ border-radius: 10px; font-size: 0.95rem; cursor: pointer; + /* flat 2D style for now - shadow removed, paper-style shadows may be added later */ } .echo-button.echoed { @@ -155,6 +161,7 @@ object-fit: cover; border-radius: 12px; margin-bottom: 0.75rem; + /* flat 2D style for now - shadow removed, paper-style shadows may be added later */ } @keyframes pulsate { diff --git a/src/lib/components/ComposeSheet.svelte b/src/lib/components/ComposeSheet.svelte index f43a644..b531846 100644 --- a/src/lib/components/ComposeSheet.svelte +++ b/src/lib/components/ComposeSheet.svelte @@ -1,12 +1,20 @@ -
+ +{#if !isMobile} + + +{/if} + + +
- - Leave a message + Leave a message {remaining}
@@ -142,6 +162,8 @@
\ No newline at end of file diff --git a/src/lib/components/SidePanel.svelte b/src/lib/components/SidePanel.svelte index 2f8654f..21bfbe1 100644 --- a/src/lib/components/SidePanel.svelte +++ b/src/lib/components/SidePanel.svelte @@ -1,130 +1,332 @@ -
- {#if message} -
- {#if message.imageUrl} - message attachment - {/if} -

{message.text}

- {#if decay} -

left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.

- {/if} -
- - -
-
- {:else} -
-

Tap a pin to read a message

-
+
+ {#if message} + + + + {#if message.imageUrl} + message attachment {/if} + +

{message.text}

+ + {#if decay} +

left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.

+ {/if} + +
+ + +
+ {:else} + +
+

Overheard: Shared Secrets

+

Messages near you

+
+ +
+ + + + + + + +
+ {#if $messagesStore.length === 0} + +
+

No messages nearby yet

+

Be the first to leave one

+
+ {:else} + {#each topMessages as msg} + + {@const itemDecay = getDecayInfo(msg.createdAt, msg.lastEchoAt)} +
mapStore.set({ selectedMessage: msg, composing: false })}> +

{msg.text}

+
+ 🕐 {itemDecay.daysAgo}d ago + + {itemDecay.daysLeft}d left + + + {msg.echoCount > 0 ? '🤍' : ''}{msg.imageUrl ? '🖼' : ''} +
+
+ {/each} + {/if} +
+ + +
+

Messages appear as you explore

+

Tap the confetti on the map to read them

+
+ {/if}
- diff --git a/src/lib/stores/messagesStore.js b/src/lib/stores/messagesStore.js index 2fce44e..ddb36aa 100644 --- a/src/lib/stores/messagesStore.js +++ b/src/lib/stores/messagesStore.js @@ -1,3 +1,22 @@ -import { writable } from 'svelte/store'; +import { writable, get } from 'svelte/store'; +import { playNewPinChime } from '$lib/utils/sound.js'; -export const messagesStore = writable([]); // the store will fill up when the page lloads and queries firestore \ No newline at end of file +export const messagesStore = writable([]); // the store will fill up when the page lloads and queries firestore + +// Replaces the store's contents with a fresh list of nearby messages, and +// plays a soft chime if any pin in the new list wasn't present before - +// covering both "someone else posted nearby" and "this device's own query +// refreshed with new results". +export function setMessages(newMessages) { + const previous = get(messagesStore); + const previousIds = new Set(previous.map((m) => m.id)); + const hasNewPin = newMessages.some((m) => !previousIds.has(m.id)); + + // skip the chime on the very first load (previous list is empty) - + // otherwise every pin from the initial fetch would "ding" at once + if (hasNewPin && previous.length > 0) { + playNewPinChime(); + } + + messagesStore.set(newMessages); +} \ No newline at end of file diff --git a/src/lib/stores/userStore.js b/src/lib/stores/userStore.js new file mode 100644 index 0000000..0b2c7d9 --- /dev/null +++ b/src/lib/stores/userStore.js @@ -0,0 +1,20 @@ +import { writable } from 'svelte/store'; +import { auth } from '$lib/firebase/config.js'; +import { signInAnonymously, onAuthStateChanged } from 'firebase/auth'; + +// tracks the current Firebase auth user (anonymous, persists across reloads on this device) +export const userStore = writable({ + uid: null, + ready: false +}); + +// signs the device in anonymously (if not already) and keeps userStore in sync +export function initAuth() { + signInAnonymously(auth); + + onAuthStateChanged(auth, (user) => { + if (user) { + userStore.set({ uid: user.uid, ready: true }); + } + }); +} diff --git a/src/lib/utils/geohash.js b/src/lib/utils/geohash.js index 0c22f3d..6230e5c 100644 --- a/src/lib/utils/geohash.js +++ b/src/lib/utils/geohash.js @@ -1,6 +1,10 @@ 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) export function encode(lat, lng) { return ngeohash.encode(lat, lng, 6); } @@ -9,7 +13,8 @@ export function encode(lat, lng) { // basically like looking for all geohashes that start with this 4 characters // it will include all geohashes in a ~40km radius of the given lat/lng pair -// maybe we lessen the radius later but for now is good for testing +// reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes +// stored on existing messages (see encode() above) export function getQueryPrefix(lat, lng) { return ngeohash.encode(lat, lng, 4); } \ No newline at end of file diff --git a/src/lib/utils/pins.js b/src/lib/utils/pins.js new file mode 100644 index 0000000..ef5e453 --- /dev/null +++ b/src/lib/utils/pins.js @@ -0,0 +1,79 @@ +// generates a random pastel hue, returning both a solid fill and a +// translucent version of the same color for the glow halo behind each pin +function randomPastel() { + const hue = Math.floor(Math.random() * 360); + return { + solid: `hsl(${hue}, 60%, 72%)`, + glow: `hsla(${hue}, 60%, 72%, 0.25)` + }; +} + +// turns an SVG string into a DOM element sized to `size` x `size` px. +// AdvancedMarkerElement (the marker API this app uses) takes a DOM node +// via its `content` option, not an icon/url like the older google.maps.Marker, +// so we inline the SVG as a data URL on an instead of returning an icon object. +function svgToElement(svg, size) { + const img = document.createElement('img'); + img.src = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg); + img.style.width = `${size}px`; + img.style.height = `${size}px`; + // AdvancedMarkerElement anchors content by its bottom-center by default + // (made for teardrop pins). Shift up by half the height so the *center* + // of our shapes lands on the actual coordinate instead. + img.style.transform = 'translateY(-50%)'; + // flat 2D style for now (no drop-shadow) - paper-style shadows may be added later + return img; +} + +// star pin for unread messages: solid pastel star, no glow halo, flat fill +export function unreadPin() { + const { solid } = randomPastel(); + const svg = ` + + + `; + // sized to match locationPin (32px) per latest request, so message pins + // are no longer larger than the current-location marker + return svgToElement(svg, 46); +} + +// circle pin for read messages: solid pastel perfect circle, no glow halo +export function readPin() { + const { solid } = randomPastel(); + const svg = ` + + + `; + // sized to match locationPin (32px) per latest request, so message pins + // are no longer larger than the current-location marker + return svgToElement(svg, 28); +} + +// heart pin for echoed messages: solid pastel heart, no glow halo +export function echoedPin() { + const { solid } = randomPastel(); + // replaced the old single-point teardrop path with an actual two-lobed + // heart: a notch/cleft at the top center (28,17) and a point at the + // bottom (28,46) + // dropped the white inner-highlight overlay - it gave the heart a + // ring/outline look instead of a solid fill, which the user didn't want + const svg = ` + + + `; + // sized to match locationPin (32px) per latest request, so message pins + // are no longer larger than the current-location marker + return svgToElement(svg, 32); +} + +// fixed dark dot used for the user's own location (not randomized, no glow) +export function locationPin() { + const svg = ` + + + + `; + return svgToElement(svg, 32); +} diff --git a/src/lib/utils/sound.js b/src/lib/utils/sound.js new file mode 100644 index 0000000..b528a94 --- /dev/null +++ b/src/lib/utils/sound.js @@ -0,0 +1,165 @@ +// Ambient sound design for Overheard, built entirely with the Web Audio API. +// No audio files are loaded - every sound here is a sine wave generated on +// the fly by an OscillatorNode and shaped by a GainNode "envelope". +import { browser } from '$app/environment'; + +// --------------------------------------------------------------------- +// AudioContext setup + the "unlock on first interaction" workaround +// --------------------------------------------------------------------- +// Browsers (Chrome, Safari, Firefox) block audio from playing until the +// page has received at least one user gesture (click/tap/keypress). An +// AudioContext created before that gesture starts in a "suspended" state, +// and any sound scheduled on it is silently dropped. +// +// To handle this without requiring a manual "enable sound" button, we: +// 1. Lazily create a single shared AudioContext the first time it's needed +// (getAudioContext below), instead of one at module load time. +// 2. Attach one-time listeners for pointerdown/keydown/touchstart to the +// window. The very first time the user interacts with the page at all, +// we create/resume the shared AudioContext so it's "unlocked" before +// any chime needs to play. +// 3. Every play function also calls ctx.resume() defensively - resuming an +// already-running context is a harmless no-op, but if the context is +// still suspended (e.g. a chime fires before any interaction happened), +// this gives it another chance to start once a gesture has occurred. +// +// Net effect: sound is "on" by default with no toggle, and simply stays +// silent until the user has interacted with the page once - which matches +// what every browser requires anyway. + +let audioCtx = null; + +function getAudioContext() { + if (!browser) return null; // never run on the server during SSR + + if (!audioCtx) { + // webkitAudioContext fallback covers older Safari + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + audioCtx = new AudioContextClass(); + } + + // no-op if already running; otherwise attempts to start playback - + // this only actually succeeds once a user gesture has occurred + if (audioCtx.state === 'suspended') { + audioCtx.resume(); + } + + return audioCtx; +} + +if (browser) { + // One-time "unlock" listeners: the first tap/click/keypress anywhere on + // the page creates (or resumes) the shared AudioContext so it's ready + // before the user does anything that should make a sound (e.g. echoing + // a message). { once: true } removes each listener after it fires. + const unlockAudio = () => getAudioContext(); + window.addEventListener('pointerdown', unlockAudio, { once: true }); + window.addEventListener('keydown', unlockAudio, { once: true }); + window.addEventListener('touchstart', unlockAudio, { once: true }); +} + +// --------------------------------------------------------------------- +// Shared helper: play a single sine-wave tone with an attack/decay envelope +// --------------------------------------------------------------------- +// A "click"-free tone needs its volume to ramp up and back down smoothly +// rather than switching on/off instantly (an instant on/off creates an +// audible pop). We do this with a GainNode whose gain value we schedule +// over time - this is the "envelope". +// +// ctx - the shared AudioContext +// frequency - pitch of the tone, in Hz +// startTime - when (in ctx.currentTime seconds) the tone should begin +// duration - total length of the tone, in seconds +// peakGain - maximum volume (0-1) reached during the attack +function playTone(ctx, frequency, startTime, duration, peakGain) { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + // 'sine' is the smoothest, purest waveform Web Audio offers - no harsh + // overtones, which keeps the chime feeling soft/ambient rather than + // buzzy (a square or sawtooth wave would sound much more aggressive). + oscillator.type = 'sine'; + oscillator.frequency.value = frequency; + + // Envelope: start silent, quickly fade in (attack), then fade back out + // to silence (decay) before the oscillator stops. The very short attack + // (10ms) avoids a click at the start, and the longer exponential decay + // gives the "ding" its natural-sounding tail. + const attackTime = 0.01; // 10ms fade-in - fast enough to feel instant, slow enough to avoid a click + gainNode.gain.setValueAtTime(0.0001, startTime); // start essentially silent + gainNode.gain.exponentialRampToValueAtTime(peakGain, startTime + attackTime); // quick fade in to peak volume + // exponential ramps can't target exactly 0, so we ramp down to a + // near-silent value (0.0001) by the end of the tone's duration + gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + duration); + + // oscillator -> gain -> speakers + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(startTime); + oscillator.stop(startTime + duration); +} + +// --------------------------------------------------------------------- +// Feature 1: new pin chime +// --------------------------------------------------------------------- +// A single soft "ding" played whenever a new message pin appears nearby, +// whether posted by someone else or revealed by this device's own refresh. +export function playNewPinChime() { + const ctx = getAudioContext(); + if (!ctx) return; // SSR or AudioContext unavailable - do nothing + + const now = ctx.currentTime; + + // A5 (880Hz) is high enough to feel light/bell-like without being shrill, + // and a single note keeps the chime unobtrusive - just a soft notice + // rather than a full melody. + const frequency = 880; + + // 0.4s total: comfortably inside the requested 0.3-0.5s range. Most of + // that time is the decay tail, so it reads as one quick "ding" rather + // than a sustained tone. + const duration = 0.4; + + // Peak volume of 0.06 (out of a possible 0-1) keeps this firmly in + // "ambient/background" territory - audible but never jarring, even if + // several pins load in quick succession. + const peakGain = 0.06; + + playTone(ctx, frequency, now, duration, peakGain); +} + +// --------------------------------------------------------------------- +// Feature 2: echo confirmation tone +// --------------------------------------------------------------------- +// A gentle three-note ascending arpeggio played when the user successfully +// echoes a message - a small musical "thank you". +export function playEchoTone() { + const ctx = getAudioContext(); + if (!ctx) return; // SSR or AudioContext unavailable - do nothing + + const now = ctx.currentTime; + + // C5 -> E5 -> G5: a major triad arpeggio. Ascending pitches read as + // positive/affirming (vs. a descending run, which tends to sound like + // an error or "closing" cue), and a major chord feels warm rather than + // tense. + const notes = [523.25, 659.25, 783.99]; // C5, E5, G5 in Hz + + // Each note is short and they overlap slightly (0.14s apart vs. each + // lasting 0.25s), which makes the arpeggio feel like one fluid gesture + // rather than three separate beeps. 2 gaps * 0.14s + final note's + // 0.25s duration = 0.53s total - comfortably under the 1-second limit. + const noteSpacing = 0.14; // seconds between each note's start time + const noteDuration = 0.25; // seconds each individual note lasts + + // Slightly lower peak gain than the pin chime since three overlapping + // notes add up to more total energy than one - keeps the overall + // loudness in the same soft "ambient" range. + const peakGain = 0.05; + + notes.forEach((frequency, index) => { + const startTime = now + index * noteSpacing; + playTone(ctx, frequency, startTime, noteDuration, peakGain); + }); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5c4f0f7..a81e2e3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,150 @@ + {@render children()} + + +{#if ritualMounted} + +{/if} + + +{#if $page.url.pathname !== '/archive'} + 📁 +{/if} + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6920698..1cd95d2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,8 +3,10 @@ import MapView from '$lib/components/MapView.svelte'; import { getNearbyMessages } from '$lib/firebase/messages.js'; - import { messagesStore } from '$lib/stores/messagesStore.js'; + import { messagesStore, setMessages } from '$lib/stores/messagesStore.js'; import { mapStore } from '$lib/stores/mapStore.js'; + import { initAuth } from '$lib/stores/userStore.js'; + import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab import BottomSheet from '$lib/components/BottomSheet.svelte'; import SidePanel from '$lib/components/SidePanel.svelte'; @@ -20,6 +22,9 @@ let isMobile = $derived(windowWidth < 768); onMount(() => { + // sign this device in anonymously so messages can be linked to a persistent UID + initAuth(); + if (!navigator.geolocation) { error = "Your browser doesn't support geolocation :("; return; // do nothing @@ -37,7 +42,9 @@ navigator.geolocation.getCurrentPosition( async (position) => { const messages = await getNearbyMessages(position.coords.latitude, position.coords.longitude); - messagesStore.set(messages); + // 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); } ); @@ -50,38 +57,119 @@ {#if error}

{error}

-{:else if lat && lng} - -{:else} +{:else if !(lat && lng)}

Looking for you...

{/if} - - {#if lat && lng} - + +{#if lat && lng} +
+ +
{/if} {#if windowWidth < 768} {:else} + {/if} - -{#if !$mapStore.composing} + +{#if !isMobile} +
+ +
+ + + + + + + You are here +
+
+ + + + + + Unread +
+
+ + + + + + Read +
+
+ + + + + + Echoed +
+
+{/if} + + +{#if !$mapStore.composing && !$mapStore.selectedMessage} {/if} + +{#if windowWidth < 768} + +{/if} + {#if $mapStore.composing} - + + {/if} @@ -102,30 +190,117 @@ } +/* purple FAB; only rendered when nothing is composing/selected, so no + "shifted" state is needed anymore (that rule has been removed) */ .fab { position: fixed; - bottom: 2rem; + bottom: 5rem; right: 1.5rem; width: 56px; height: 56px; border-radius: 50%; - background: #111; + background: #c4a8f5; color: white; font-size: 1.8rem; border: none; cursor: pointer; - box-shadow: 0 4px 16px rgba(0,0,0,0.25); + /* flat 2D style for now - shadow removed, paper-style shadows may be added later */ z-index: 150; display: flex; align-items:center; justify-content: center; line-height: 1; - transition: bottom 0.35s cubic-bezier(0.32, 0.72, 0, 1); /* match the sheet's slide timing */ } -/* lift it above the bottom sheet so it doesn't get covered, x position stays the same */ -.fab.shifted { - bottom: 35vh; +/* mobile-only bottom nav bar with Map/Archive links */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 64px; + background: white; + border-top: 1px solid #f0f0f0; + display: flex; + align-items: center; + justify-content: space-around; + z-index: 200; +} + +.nav-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + text-decoration: none; + color: #ccc; + font-size: 0.7rem; + font-family: sans-serif; + transition: color 0.15s; +} + +/* highlight the tab matching the current route */ +.nav-item.active { + color: #c4a8f5; +} + +/* map fills the screen on mobile; on desktop it's pushed right of the + fixed-width SidePanel (340px) so the panel doesn't sit on top of it. + box-sizing: border-box so the inline padding-bottom (reserved for the + bottom nav on mobile) shrinks the map instead of overflowing 100vh */ +.map-container { + width: 100%; + height: 100vh; + box-sizing: border-box; +} + +@media (min-width: 768px) { + .map-container { + margin-left: 340px; + width: calc(100% - 340px); + } +} + +/* floating legend explaining the map pin shapes/colors, desktop only. + anchored bottom-left, just to the right of the 340px side panel. + flat 2D style for now - shadow removed, paper-style shadows may be added later */ +.legend { + position: fixed; + bottom: 1.5rem; + left: calc(340px + 4rem); + background: #f3f0fa; + border-radius: 14px; + padding: 0.9rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 200; + font-size: 0.85rem; + color: #444; + font-family: sans-serif; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.6rem; +} + +/* fixed 36x36 box for every icon, centered, so the text after each icon + lines up in a consistent column regardless of each icon's own width/height + (which now vary so the shapes LOOK the same size - see template) */ +.legend-icon-wrap { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* flat 2D style for now - drop-shadow filter removed, paper-style shadows may be added later */ +.legend-icon { + display: block; } \ No newline at end of file diff --git a/src/routes/archive/+page.svelte b/src/routes/archive/+page.svelte new file mode 100644 index 0000000..29b0f79 --- /dev/null +++ b/src/routes/archive/+page.svelte @@ -0,0 +1,169 @@ + + +
+
+ ← Back to map +

Your messages

+

Everything you've left behind

+
+ + {#if loading} +
Loading your messages...
+ + {:else if messages.length === 0} +
+

You haven't left any messages yet.

+ Go leave one → +
+ + {:else} +
+ {#each messages as msg} + + {@const decay = getDecayInfo(msg.createdAt, msg.lastEchoAt)} +
+ + {#if msg.imageUrl} + message + {/if} + +

{msg.text}

+ +
+ {#if decay.isExpired} + 🌫️ Faded + {:else} + ✦ Alive + {/if} + + left {decay.daysAgo} days ago + + {decay.daysLeft} days left + + echoed {msg.echoCount} times +
+ +
+ {/each} +
+ {/if} +
+ +