small fixes, small sound feedback, & opening screen

This commit is contained in:
2026-06-13 16:03:55 +09:00
parent cdbff7a560
commit 5f8d224d84
14 changed files with 1269 additions and 164 deletions

View File

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

View File

@@ -1,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() }));
}

View File

@@ -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 {

View File

@@ -1,12 +1,20 @@
<script>
import { mapStore } from '$lib/stores/mapStore.js';
import { messagesStore } from '$lib/stores/messagesStore.js';
import { setMessages } from '$lib/stores/messagesStore.js';
import { addMessage } from '$lib/firebase/messages.js'
import { hasFace } from '$lib/utils/faceDetection.js';
import { uploadImage } from '$lib/Firebase/storage.js';
let {lat, lng} = $props();
// isMobile drives whether this renders as the original bottom sheet
// (mobile, unchanged) or a centered popup with a dimmed backdrop (desktop)
let {lat, lng, isMobile} = $props();
// shared close/cancel handler - used by the Cancel button and, on
// desktop, by clicking the backdrop outside the popup
function close() {
mapStore.set({selectedMessage: null, composing: false});
}
let text = $state('');
let submitting = $state(false);
@@ -73,7 +81,9 @@
// reload pins so that they show up with new message
const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
const updated = await getNearbyMessages(lat, lng);
messagesStore.set(updated);
// setMessages (instead of messagesStore.set) compares against the
// previous list and plays a soft chime for any newly-appeared pins
setMessages(updated);
// reset compose state
text = '';
@@ -86,14 +96,24 @@
}
</script>
<div class="compose" class:visible={true}>
<!-- desktop-only dimmed backdrop behind the centered popup; clicking it
cancels, like a typical modal dialog. mobile keeps the plain bottom
sheet with no backdrop, so this is skipped entirely there -->
{#if !isMobile}
<!-- a plain <button> (not a <div>) so it's keyboard-focusable/operable
by default, satisfying a11y click-handler rules without extra
role/tabindex/onkeydown wiring -->
<button class="backdrop" onclick={close} aria-label="Close compose popup"></button>
{/if}
<!-- class:desktop switches between the original bottom-sheet styles
(mobile, untouched) and the centered popup styles (desktop) -->
<div class="compose" class:visible={true} class:desktop={!isMobile}>
<div class="compose-header">
<button class="cancel" onclick={() => mapStore.set(
{selectedMessage: null, composing: false}
)}>
<button class="cancel" onclick={close}>
Cancel
</button>
<span class="title">Leave a message</span>
<span class="title">Leave a message</span>
<span class="counter" class:over={remaining < 0}>{remaining}</span>
</div>
@@ -142,6 +162,8 @@
</div>
<style>
/* mobile (default): unchanged bottom sheet, full-width, anchored to
the bottom edge with only the top corners rounded */
.compose {
position: fixed;
bottom: 0;
@@ -150,10 +172,66 @@
background: white;
border-radius: 20px 20px 0 0;
padding: 1.2rem 1.5rem 2.5rem;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
z-index: 200;
}
/* border-box on the popup and everything inside it - without this,
elements like textarea/.submit (width: 100% + their own padding/border)
added their padding/border ON TOP of that 100%, so they rendered
wider than .compose and stuck out past its rounded edges */
.compose, .compose * {
box-sizing: border-box;
}
/* desktop: centered popup instead of a stretched bottom sheet.
overrides position/sizing/rounding from .compose above */
.compose.desktop {
bottom: auto;
left: 50%;
right: auto;
top: 50%;
transform: translate(-50%, -50%);
width: 650px; /* slightly smaller than the previous 900px, but... */
aspect-ratio: 1; /* ...paired with this, makes height = width -> a big square popup */
max-width: 95vw;
/* no max-height/overflow-y - the flex layout below lets the textarea
grow/shrink to fill the square instead of causing a scrollbar */
display: flex;
flex-direction: column;
border-radius: 20px; /* round all corners, not just the top */
padding: 1.2rem 1.5rem 1.5rem; /* less bottom padding needed without the bottom-sheet handle area */
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
/* sit above the backdrop below (350) - was relying on the inherited
.compose z-index of 200, which the backdrop now exceeds */
z-index: 360;
}
/* on desktop, .compose-header/.image-section/.submit keep their natural
height and the textarea (flex: 1 below) grows/shrinks to fill the
remaining square space. flex props are no-ops on mobile since
.compose there isn't a flex container */
.compose-header,
.image-section,
.submit {
flex-shrink: 0;
}
/* dimmed full-screen overlay behind the desktop popup; clicking it
cancels. raised above .legend (200) and the global .archive-link
(300, in +layout.svelte) so those dim along with everything else
instead of floating above the overlay - this only ever renders on
desktop (!isMobile), so mobile is unaffected */
.backdrop {
/* reset <button> defaults so it behaves like the plain overlay div it replaced */
border: none;
cursor: default;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 350;
}
.compose-header {
display: flex;
align-items: center;
@@ -198,6 +276,12 @@
outline: none;
font-family: Georgia, 'Times New Roman', Times, serif;
color: #111;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
/* fill the remaining square space on desktop (no-op on mobile,
where .compose isn't a flex container); min-height: 0 lets it
shrink below its content size instead of overflowing the flex column */
flex: 1;
min-height: 0;
}
textarea:focus {
@@ -215,6 +299,7 @@
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.submit:disabled {
@@ -228,12 +313,14 @@
.image-label {
display: inline-block;
paddingL 0.5rem 1rem;
/* fixed pre-existing typo ("paddingL" -> "padding:") that was throwing a CSS error */
padding: 0.5rem 1rem;
border: 1.5px dashed #ddd;
border-radius: 8px;
font-size: 0.85rem;
color: #999;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.image-label:hover {
@@ -251,6 +338,7 @@
max-height: 200px;
object-fit: cover;
border-radius: 10px;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.remove-image {
@@ -265,6 +353,7 @@
height: 24px;
font-size: 0.7rem;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.checking {

View File

@@ -1,8 +1,10 @@
<script>
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { env } from '$env/dynamic/public';
import { messagesStore } from '$lib/stores/messagesStore.js'; // pass the messages store here
import { mapStore } from '$lib/stores/mapStore.js'; // use this to track interactions with da map
import { messagesStore } from '$lib/stores/messagesStore.js'; // pass the messages store here
import { mapStore } from '$lib/stores/mapStore.js'; // use this to track interactions with da map
import { unreadPin, readPin, echoedPin, locationPin } from '$lib/utils/pins.js'; // custom SVG pin generators
// export let latitude;
// export let longitude;
@@ -16,16 +18,19 @@
let userMarker;
let AdvancedMarkerElement;
// ids of messages the user has opened, for this session only (resets on reload)
let readIds = new SvelteSet();
/** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
function addUserLocationMarker(centerLat, centerLng) {
const dot = document.createElement('div');
dot.style.cssText = 'width:20px;height:20px;border-radius:50%;background:#4285F4;border:3px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.3)';
// use the shared locationPin() so the user's marker matches the
// style of the message pins, instead of building a one-off dot here
userMarker = new AdvancedMarkerElement({
position: { lat: centerLat, lng: centerLng },
map,
title: 'Your location',
zIndex: 1000,
content: dot
content: locationPin()
});
}
@@ -42,6 +47,11 @@
const { Map } = await importLibrary('maps');
({ AdvancedMarkerElement } = await importLibrary('marker'));
// NOTE: a JS `styles` array was tried here for the soft white look, but
// Google Maps ignores (and errors on) inline `styles` when `mapId` is set,
// and mapId is required for AdvancedMarkerElement (our pins). Removed so
// pins render again; the soft style would need to be a Cloud-based Map
// Style configured for this mapId in the Google Cloud Console instead.
map = new Map(mapDiv, {
center: { lat: centerLat, lng: centerLng },
zoom: 15,
@@ -60,13 +70,29 @@
markers = [];
messages.forEach(message => {
// pick a pin shape based on message state:
// - messages the user has opened this session get the read circle
// (takes priority - once you've read it, it's a circle even if echoed)
// - otherwise, echoed messages (echoCount > 0) get the heart pin
// - everything else gets the unread star
let icon;
if (readIds.has(message.id)) {
icon = readPin();
} else if (message.echoCount > 0) {
icon = echoedPin();
} else {
icon = unreadPin();
}
const marker = new AdvancedMarkerElement({
position: { lat: message.lat, lng: message.lng },
map,
title: message.text
title: message.text,
content: icon // AdvancedMarkerElement takes a DOM node via `content`, not an `icon` option
});
marker.addEventListener('click', () => {
readIds.add(message.id); // mark as read; SvelteSet triggers the $effect below to re-render pins
mapStore.set({ selectedMessage: message, composing: false });
});
@@ -85,8 +111,11 @@
<div class="map" bind:this={mapDiv}></div>
<style>
/* fill the .map-container parent (height: 100% instead of min-height: 100vh)
so that the parent's padding-bottom on mobile actually shrinks the
visible map instead of the map overflowing past it */
.map {
width: 100%;
min-height: 100vh;
height: 100%;
}
</style>

View File

@@ -1,130 +1,332 @@
<script>
import { mapStore } from '$lib/stores/mapStore.js';
import { getDecayInfo } from '$lib/utils/time.js';
import { echoMessage } from '$lib/firebase/messages.js'
// SidePanel is the desktop sidebar. It has two views in the same fixed
// panel: a list of nearby messages (default), and a message detail view
// (image/text/echo/let go) shown when a pin or list row is selected.
// Echo, Let go, and the back arrow all clear the selection, which
// switches the view back to the list.
import { mapStore } from '$lib/stores/mapStore.js';
import { messagesStore } from '$lib/stores/messagesStore.js';
import { getDecayInfo } from '$lib/utils/time.js';
import { echoMessage } from '$lib/firebase/messages.js';
import { playEchoTone } from '$lib/utils/sound.js';
let { message } = $props();
// currently-selected message; when set, the detail view is shown instead of the list
let { message } = $props();
let decay = $derived(
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
);
// fade-out timing for the selected message (detail view only)
let decay = $derived(
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
);
let echoed = $state(false);
// only show the 3 most recent nearby messages, so the list view fits
// without scrolling and the hint card stays visible underneath it
let topMessages = $derived(
[...$messagesStore]
.sort((a, b) => b.createdAt.toMillis() - a.createdAt.toMillis())
.slice(0, 3)
);
async function handleEcho() {
await echoMessage(message.id);
echoed = true;
}
// back to the list view
function close() {
mapStore.set({ selectedMessage: null, composing: false });
}
async function handleEcho() {
await echoMessage(message.id);
// gentle ascending "thank you" tone confirming the echo went through
playEchoTone();
close(); // back to the list once echoed
}
</script>
<div class = "panel">
{#if message}
<div class="content">
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if}
<p class="message-text">{message.text}</p>
{#if decay}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{/if}
<div class="actions">
<button class="echo-button"
class:echoed={echoed}
onclick={handleEcho}
disabled={echoed}>
{echoed ? 'Echoed' : 'Echo'}
</button>
<button class="letgo-button" onclick={() => mapStore.set(
{selectedMessage: null, composing: false})}>
Let go
</button>
</div>
</div>
{:else}
<div class="empty">
<p>Tap a pin to read a message</p>
</div>
<div class="panel">
{#if message}
<!-- detail view: shown for the selected message -->
<button class="back-btn" onclick={close}> Back</button>
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if}
<p class="message-text">{message.text}</p>
{#if decay}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{/if}
<div class="actions">
<button class="echo-button" onclick={handleEcho}>
Echo
</button>
<button class="letgo-button" onclick={close}>
Let go
</button>
</div>
{:else}
<!-- list view: header, compose button, nearby messages -->
<div class="panel-header">
<h1>Overheard: Shared Secrets</h1>
<p class="subtitle">Messages near you</p>
</div>
<hr />
<!-- compose button: opens the compose sheet, deselecting any open message -->
<button
class="compose-btn"
onclick={() => mapStore.set({ selectedMessage: null, composing: true })}>
+ Leave a message here
</button>
<!-- message list -->
<p class="section-label">RECENT & NEARBY</p>
<div class="message-list">
{#if $messagesStore.length === 0}
<!-- shown while no messages have loaded for this area yet -->
<div class="empty-card">
<p>No messages nearby yet</p>
<p class="empty-sub">Be the first to leave one</p>
</div>
{:else}
{#each topMessages as msg}
<!-- compute fade-out timing per message -->
{@const itemDecay = getDecayInfo(msg.createdAt, msg.lastEchoAt)}
<div
class="message-item"
class:selected={message?.id === msg.id}
onclick={() => mapStore.set({ selectedMessage: msg, composing: false })}>
<p class="msg-text">{msg.text}</p>
<div class="msg-meta">
<span>🕐 {itemDecay.daysAgo}d ago</span>
<span></span>
<span>{itemDecay.daysLeft}d left</span>
<span></span>
<!-- show an echo/image indicator if applicable -->
<span>{msg.echoCount > 0 ? '🤍' : ''}{msg.imageUrl ? '🖼' : ''}</span>
</div>
</div>
{/each}
{/if}
</div>
<!-- hint card, always pinned to the bottom of the panel -->
<div class="hint-card">
<p>Messages appear as you explore</p>
<p class="hint-sub">Tap the confetti on the map to read them</p>
</div>
{/if}
</div>
<style>
.panel {
position: fixed;
top: 0;
left: 0;
width: 320px;
height: 100vh;
background: white;
box-shadow: 2px 0 12px rgba(0,0,0,0.1);
padding: 2rem 1.5rem;
z-index: 100;
overflow-y: auto;
}
.panel {
position: fixed;
top: 0;
left: 0;
width: 340px;
height: 100vh;
/* without this, the 1.5rem/1.2rem padding below was added ON TOP of the
100vh height, pushing the panel taller than the viewport - since the
panel is position:fixed, that extra height just got clipped off the
bottom, cutting off the Echo/Let go buttons */
box-sizing: border-box;
background: white;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
display: flex;
flex-direction: column;
padding: 1.5rem 1.2rem;
z-index: 100;
gap: 0.8rem;
font-family: sans-serif;
}
.message-text{
font-size: 1rem;
line-height: 1.6;
color: #111;
margin-bottom: 0.5rem;
}
.panel-header h1 {
font-size: 1.3rem;
font-weight: 700;
color: #111;
margin-bottom: 0.2rem;
}
.meta {
font-size: 1rem;
line-height: 1.6;
color: #111;
margin-bottom: 0.5rem;
}
/* detail view */
.actions {
display: flex;
gap: 0.75rem;
}
.back-btn {
align-self: flex-start;
background: none;
border: none;
padding: 0.4rem 0;
font-size: 0.9rem;
font-weight: 500;
color: #888;
cursor: pointer;
}
.echo-button {
flex: 1;
padding: 0.75rem;
background: #111;
color: white;
border: none;
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
}
.back-btn:hover {
color: #111;
}
.letgo-button {
flex: 1;
padding: 0..75rem;
background: transparent;
color: #111;
border: 1.5px solid #ddd;
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
}
.message-img {
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 12px;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.empty {
color: #999;
font-size: 0.9rem;
margin-top: 2rem;
text-align: center;
}
.message-text {
font-size: 0.95rem;
line-height: 1.6;
color: #111;
}
.echo-button.echoed {
background: #4ecdc4;
animation: pulsate 1.5s ease-in-out 3;
}
.meta {
font-size: 0.75rem;
color: #999;
}
.message-img{
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 12px;
margin-bottom: 0.75rem;
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: auto;
}
@keyframes pulsate {
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}
}
.echo-button {
flex: 1;
padding: 0.75rem;
background: #c4a8f5;
color: white;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.echo-button:hover {
background: #b090e8;
}
.letgo-button {
flex: 1;
padding: 0.75rem;
background: transparent;
color: #111;
border: 1.5px solid #ddd;
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.subtitle {
font-size: 0.8rem;
color: #aaa;
}
hr {
border: none;
border-top: 1px solid #eee;
margin: 0.2rem 0;
}
.compose-btn {
width: 100%;
padding: 0.75rem;
background: #c4a8f5;
color: white;
border: none;
border-radius: 50px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.compose-btn:hover {
background: #b090e8;
}
.section-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
color: #bbb;
margin-top: 0.4rem;
}
.message-list {
display: flex;
flex-direction: column;
gap: 0;
}
.message-item {
padding: 0.9rem 0.5rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
border-radius: 8px;
transition: background 0.15s;
}
.message-item:hover {
background: #fafafa;
}
.message-item.selected {
background: #faf5ff;
border-left: 3px solid #c4a8f5;
padding-left: 0.8rem;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.msg-text {
font-size: 0.9rem;
color: #111;
line-height: 1.5;
margin-bottom: 0.3rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.msg-meta {
display: flex;
gap: 0.4rem;
font-size: 0.75rem;
color: #bbb;
align-items: center;
}
.hint-card {
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 12px;
padding: 1rem;
text-align: center;
font-size: 0.85rem;
color: #999;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.hint-sub {
font-size: 0.75rem;
color: #bbb;
margin-top: 0.3rem;
}
.empty-card {
text-align: center;
padding: 1.5rem;
color: #999;
font-size: 0.85rem;
}
.empty-sub {
font-size: 0.75rem;
color: #bbb;
margin-top: 0.3rem;
}
</style>

View File

@@ -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
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);
}

View File

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

View File

@@ -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);
}

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

@@ -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 <img> 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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60">
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24"
fill="${solid}"/>
</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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20" fill="${solid}"/>
</svg>`;
// sized to match locationPin (32px) per latest request, so message pins
// are no longer larger than the current-location marker
return 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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 56 56">
<path d="M28,46 C28,46 10,33 10,20 C10,13 15,9 21,9 C25,9 28,12 28,17 C28,12 31,9 35,9 C41,9 46,13 46,20 C46,33 28,46 28,46 Z"
fill="${solid}"/>
</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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
<circle cx="16" cy="16" r="5" fill="white"/>
</svg>`;
return svgToElement(svg, 32);
}

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

@@ -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);
});
}

View File

@@ -1,11 +1,150 @@
<script>
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/stores';
import { browser } from '$app/environment';
let { children } = $props();
// --- one-time opening ritual ---------------------------------------
// `ritualMounted` controls whether the overlay exists in the DOM at all.
// Starting it at `false` means SSR renders nothing (avoiding a flash of
// the overlay on every page before we've even checked localStorage),
// and it only ever becomes `true` in the browser, once, for first-time
// visitors.
let ritualMounted = $state(false);
// `ritualVisible` drives the CSS opacity transition (fade in/out).
// Toggling this class is what actually animates the overlay; the timers
// below just flip it true -> false at the right moments.
let ritualVisible = $state(false);
$effect(() => {
if (!browser) return; // localStorage doesn't exist during SSR
// the flag that marks "this device has already seen the ritual"
const hasVisited = localStorage.getItem('overheard_has_visited');
if (hasVisited) return; // not the first visit - never mount the overlay
// Mark the device as visited immediately (rather than after the
// animation finishes). If we waited until the end, a user who closes
// the tab partway through the ritual would see it again on their
// next visit - setting the flag up front guarantees "only once ever".
localStorage.setItem('overheard_has_visited', 'true');
ritualMounted = true;
// Fade-in timing: wait one animation frame so the overlay first
// paints at opacity 0 (its initial CSS state), then flip
// `ritualVisible` to true so the opacity transition to 1 actually
// animates instead of snapping straight to visible.
const fadeInFrame = requestAnimationFrame(() => {
ritualVisible = true;
});
// Total sequence ~5s: 1.2s fade in + 2.6s hold + 1.2s fade out.
const fadeInMs = 1200;
const holdMs = 2600;
const fadeOutMs = 1200;
// After the fade-in finishes and the hold completes, start fading out.
const fadeOutTimer = setTimeout(() => {
ritualVisible = false;
}, fadeInMs + holdMs);
// Once the fade-out transition has finished, unmount the overlay
// entirely (rather than just leaving it hidden via CSS/opacity:0).
// A hidden-but-present full-screen element would still sit in the
// DOM at a high z-index and could intercept clicks/taps meant for
// the map underneath, so we remove it completely once it's invisible.
const removeTimer = setTimeout(() => {
ritualMounted = false;
}, fadeInMs + holdMs + fadeOutMs);
// cleanup if the component is destroyed mid-sequence
return () => {
cancelAnimationFrame(fadeInFrame);
clearTimeout(fadeOutTimer);
clearTimeout(removeTimer);
};
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<!-- Map/geolocation in {@render children()} starts loading immediately and
in parallel - this overlay just sits on top of it visually, it doesn't
block or delay anything underneath. -->
{@render children()}
<!-- one-time opening ritual overlay, first-ever visit only -->
{#if ritualMounted}
<div class="ritual-overlay" class:visible={ritualVisible} aria-hidden="true">
<p class="ritual-text">Some things are only true if you're standing here.</p>
</div>
{/if}
<!-- floating link to the archive page, hidden while already on it -->
{#if $page.url.pathname !== '/archive'}
<a href="/archive" class="archive-link">📁</a>
{/if}
<style>
/* full-screen opening ritual overlay - soft cream background matching
the app's pastel palette, sits above everything (including the
.archive-link below at z-index 300) while it's visible */
.ritual-overlay {
position: fixed;
inset: 0;
background: #f9f7f4;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
/* starts invisible; .visible below fades it in/out over 1.2s.
the same transition is reused for fade-out since both are just
opacity changes between 0 and 1 */
opacity: 0;
transition: opacity 1.2s ease;
}
.ritual-overlay.visible {
opacity: 1;
}
.ritual-text {
/* same serif used for the loading/error text in +page.svelte, for a
calm, contemplative feel */
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 1.15rem;
color: #555; /* muted dark grey, not pure black */
letter-spacing: 0.08em;
text-align: center;
line-height: 1.6;
margin: 0;
padding: 0 2rem;
}
@media (min-width: 768px) {
.ritual-text {
font-size: 1.35rem;
}
}
.archive-link {
position: fixed;
top: 1rem;
right: 1rem;
background: white;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
text-decoration: none;
font-size: 1.1rem;
z-index: 300;
}
</style>

View File

@@ -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}
<p class="error">{error}</p>
{:else if lat && lng}
<MapView {lat} {lng} />
{:else}
{:else if !(lat && lng)}
<p class="loading">Looking for you...</p>
{/if}
<!-- map must fill the whole screen-->
{#if lat && lng}
<MapView {lat} {lng} />
<!-- map must fill the whole screen; wrapped in .map-container so desktop
can push it right of the side panel via the media query below.
(this also removes a duplicate <MapView> that was rendering two map instances)
on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav -->
{#if lat && lng}
<div class="map-container" style="padding-bottom: {windowWidth < 768 ? '64px' : '0'}">
<MapView {lat} {lng} />
</div>
{/if}
<!-- show the right panel based on mobile or desktop-->
{#if windowWidth < 768}
<BottomSheet message={$mapStore.selectedMessage} />
{:else}
<!-- SidePanel itself swaps between the list view and a message detail
view depending on whether a message is selected -->
<SidePanel message={$mapStore.selectedMessage} />
{/if}
<!--floating action button for adding a message-->
{#if !$mapStore.composing}
<!-- pin legend, desktop only.
icons are inline copies of the exact shapes/viewBoxes from pins.js
(unreadPin/readPin/echoedPin/locationPin) instead of unrelated emoji,
so the key visually matches what's on the map. message-pin icons use
one fixed pastel fill here (rather than pins.js's randomPastel()) since
a legend needs a single representative swatch, not a random one. -->
{#if !isMobile}
<div class="legend">
<!-- each icon sits in a fixed 36x36 .legend-icon-wrap so the text
column stays aligned, even though the svgs below are sized
differently from each other. the star/heart shapes occupy a
smaller fraction of their own viewBox than the two circles do
of theirs, so they need a bigger svg size to LOOK the same size
on screen - sizes below were picked by eye, not by matching
pixel dimensions -->
<div class="legend-item">
<span class="legend-icon-wrap">
<svg class="legend-icon" viewBox="0 0 32 32" width="23" height="23">
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
<circle cx="16" cy="16" r="5" fill="white"/>
</svg>
</span>
<span>You are here</span>
</div>
<div class="legend-item">
<span class="legend-icon-wrap">
<svg class="legend-icon" viewBox="0 0 60 60" width="36" height="36">
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24" fill="hsl(265, 60%, 80%)"/>
</svg>
</span>
<span>Unread</span>
</div>
<div class="legend-item">
<span class="legend-icon-wrap">
<svg class="legend-icon" viewBox="0 0 50 50" width="25" height="25">
<circle cx="25" cy="25" r="20" fill="hsl(265, 60%, 80%)"/>
</svg>
</span>
<span>Read</span>
</div>
<div class="legend-item">
<span class="legend-icon-wrap">
<svg class="legend-icon" viewBox="0 0 56 56" width="30" height="30">
<path d="M28,46 C28,46 10,33 10,20 C10,13 15,9 21,9 C25,9 28,12 28,17 C28,12 31,9 35,9 C41,9 46,13 46,20 C46,33 28,46 28,46 Z" fill="hsl(265, 60%, 80%)"/>
</svg>
</span>
<span>Echoed</span>
</div>
</div>
{/if}
<!--floating action button for adding a message; hidden while a message is open-->
{#if !$mapStore.composing && !$mapStore.selectedMessage}
<button
class="fab"
class:shifted={isMobile && $mapStore.selectedMessage}
onclick={() => mapStore.set(
{selectedMessage: null, composing: true })}>
+
</button>
{/if}
<!-- bottom nav, mobile only -->
{#if windowWidth < 768}
<nav class="bottom-nav">
<a href="/" class="nav-item" class:active={$page.url.pathname === '/'}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
<span>Map</span>
</a>
<a href="/archive" class="nav-item" class:active={$page.url.pathname === '/archive'}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M2 9h20"/>
<path d="M9 4v5"/>
<path d="M15 4v5"/>
</svg>
<span>Archive</span>
</a>
</nav>
{/if}
<!--compose sheet (making a message)-->
{#if $mapStore.composing}
<ComposeSheet {lat} {lng} />
<!-- pass isMobile so ComposeSheet can render as a bottom sheet on mobile
(unchanged) vs. a centered popup with backdrop on desktop -->
<ComposeSheet {lat} {lng} {isMobile} />
{/if}
@@ -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;
}
</style>

View File

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