small fixes, small sound feedback, & opening screen
This commit is contained in:
@@ -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
|
||||
@@ -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() }));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
20
src/lib/stores/userStore.js
Normal file
20
src/lib/stores/userStore.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
79
src/lib/utils/pins.js
Normal 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
165
src/lib/utils/sound.js
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
169
src/routes/archive/+page.svelte
Normal file
169
src/routes/archive/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user