Prototype commit
This commit is contained in:
commit
b2ffbe865e
29 changed files with 3438 additions and 0 deletions
72
web/src/app.css
Normal file
72
web/src/app.css
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
:root {
|
||||
--bg: #faf7f2;
|
||||
--bg-elev: #ffffff;
|
||||
--ink: #2a2520;
|
||||
--ink-mute: #6b6157;
|
||||
--accent: #6e3a1f;
|
||||
--rule: #e8e0d4;
|
||||
--radius: 14px;
|
||||
--radius-lg: 22px;
|
||||
--shadow: 0 1px 2px rgba(42, 37, 32, 0.06), 0 8px 24px rgba(42, 37, 32, 0.08);
|
||||
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
20
web/src/app.html
Normal file
20
web/src/app.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#2a2520" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Mill Run" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<title>Mill Run</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div id="app">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
72
web/src/lib/auth.svelte.ts
Normal file
72
web/src/lib/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type AuthState = 'unknown' | 'signed-in' | 'signed-out';
|
||||
|
||||
let state: AuthState = $state('unknown');
|
||||
|
||||
export function authState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function setAuth(next: AuthState) {
|
||||
state = next;
|
||||
}
|
||||
|
||||
export async function checkAuth(): Promise<AuthState> {
|
||||
if (!browser) return 'unknown';
|
||||
try {
|
||||
const res = await fetch('/api/me', { credentials: 'same-origin' });
|
||||
state = res.ok ? 'signed-in' : 'signed-out';
|
||||
} catch {
|
||||
state = 'signed-out';
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<boolean> {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (res.ok) {
|
||||
state = 'signed-in';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function loginWithKey(key: string): Promise<boolean> {
|
||||
const res = await fetch('/api/key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (res.ok) {
|
||||
state = 'signed-in';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
state = 'signed-out';
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
export type KeyResult = 'success' | 'failed' | 'absent';
|
||||
|
||||
export async function consumeKeyParam(): Promise<KeyResult> {
|
||||
if (!browser) return 'absent';
|
||||
const url = new URL(window.location.href);
|
||||
const key = url.searchParams.get('key');
|
||||
if (!key) return 'absent';
|
||||
url.searchParams.delete('key');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
const ok = await loginWithKey(key);
|
||||
return ok ? 'success' : 'failed';
|
||||
}
|
||||
51
web/src/lib/precache.svelte.ts
Normal file
51
web/src/lib/precache.svelte.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { stops } from './stops';
|
||||
|
||||
export type PrecacheState = 'idle' | 'running' | 'complete' | 'unsupported';
|
||||
|
||||
let state: PrecacheState = $state('idle');
|
||||
let done = $state(0);
|
||||
let total = $state(0);
|
||||
|
||||
export function precacheState() {
|
||||
return { state, done, total };
|
||||
}
|
||||
|
||||
export async function precacheAll(): Promise<void> {
|
||||
if (!browser) return;
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
state = 'unsupported';
|
||||
return;
|
||||
}
|
||||
if (state === 'running' || state === 'complete') return;
|
||||
|
||||
const reg = await navigator.serviceWorker.ready.catch(() => null);
|
||||
const target = reg?.active ?? navigator.serviceWorker.controller;
|
||||
if (!target) {
|
||||
state = 'unsupported';
|
||||
return;
|
||||
}
|
||||
|
||||
const urls = stops.flatMap((s) => [s.audio, s.image]);
|
||||
|
||||
state = 'running';
|
||||
done = 0;
|
||||
total = urls.length;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.type === 'precache-progress') {
|
||||
done = data.done;
|
||||
total = data.total;
|
||||
} else if (data.type === 'precache-done') {
|
||||
done = data.done;
|
||||
total = data.total;
|
||||
state = 'complete';
|
||||
navigator.serviceWorker.removeEventListener('message', onMessage);
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker.addEventListener('message', onMessage);
|
||||
|
||||
target.postMessage({ type: 'precache-stops', urls });
|
||||
}
|
||||
24
web/src/lib/stops.json
Normal file
24
web/src/lib/stops.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"exhibit": {
|
||||
"title": "Mill Run",
|
||||
"subtitle": ""
|
||||
},
|
||||
"stops": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Welcome",
|
||||
"audio": "/audio/01.opus",
|
||||
"image": "/images/01.webp",
|
||||
"caption": "An introduction to the exhibit.",
|
||||
"description": "Placeholder description."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Stop two placeholder",
|
||||
"audio": "/audio/02.opus",
|
||||
"image": "/images/02.webp",
|
||||
"caption": "",
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
22
web/src/lib/stops.ts
Normal file
22
web/src/lib/stops.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import stopsJson from './stops.json';
|
||||
|
||||
export type Stop = {
|
||||
id: number;
|
||||
title: string;
|
||||
audio: string;
|
||||
image: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type Exhibit = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
export const exhibit: Exhibit = stopsJson.exhibit;
|
||||
export const stops: Stop[] = stopsJson.stops;
|
||||
|
||||
export function stopById(id: number): Stop | undefined {
|
||||
return stops.find((s) => s.id === id);
|
||||
}
|
||||
72
web/src/routes/+layout.svelte
Normal file
72
web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { dev } from '$app/environment';
|
||||
import { authState, checkAuth, consumeKeyParam } from '$lib/auth.svelte';
|
||||
import { precacheAll } from '$lib/precache.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let booted = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
if (dev) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map((r) => r.unregister()));
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
} else {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js', { type: 'module' })
|
||||
.catch((err) => console.warn('sw register failed:', err));
|
||||
}
|
||||
}
|
||||
|
||||
const keyResult = await consumeKeyParam();
|
||||
if (keyResult !== 'success') await checkAuth();
|
||||
|
||||
const path = $page.url.pathname;
|
||||
if (authState() === 'signed-out' && path !== '/login') {
|
||||
const params = new URLSearchParams({ next: path });
|
||||
if (keyResult === 'failed') params.set('error', 'qr');
|
||||
await goto('/login?' + params.toString(), { replaceState: true });
|
||||
}
|
||||
|
||||
// Production-only: kick off audio/image bulk precache once authenticated.
|
||||
// SW does the work; failures non-fatal.
|
||||
if (!dev && authState() === 'signed-in') {
|
||||
precacheAll().catch(() => undefined);
|
||||
}
|
||||
|
||||
booted = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if booted}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="splash">
|
||||
<div class="ring" aria-hidden="true"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.ring {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--rule);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
2
web/src/routes/+layout.ts
Normal file
2
web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
133
web/src/routes/+page.svelte
Normal file
133
web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<script lang="ts">
|
||||
import { exhibit, stops } from '$lib/stops';
|
||||
import { precacheState } from '$lib/precache.svelte';
|
||||
|
||||
const pre = $derived(precacheState());
|
||||
</script>
|
||||
|
||||
<header class="hero">
|
||||
<h1>{exhibit.title}</h1>
|
||||
{#if exhibit.subtitle}
|
||||
<p class="subtitle">{exhibit.subtitle}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if pre.state === 'running'}
|
||||
<div class="precache" role="status" aria-live="polite">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span class="text">
|
||||
Loading exhibit for offline listening… {pre.done}/{pre.total}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="grid" aria-label="Exhibit stops">
|
||||
{#each stops as stop (stop.id)}
|
||||
<a class="tile" href="/stop/{stop.id}" data-sveltekit-preload-data="hover">
|
||||
<div class="tile-image">
|
||||
<img src={stop.image} alt="" loading="lazy" />
|
||||
</div>
|
||||
<div class="tile-meta">
|
||||
<span class="tile-num">{String(stop.id).padStart(2, '0')}</span>
|
||||
<span class="tile-title">{stop.title}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding: 2rem 1.25rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.precache {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0 auto 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
max-width: 32rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-mute);
|
||||
background: var(--bg-elev);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.precache .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.precache .dot { animation: none; opacity: 0.7; }
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 2rem 3rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-elev);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.tile:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.tile-image {
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--rule);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tile-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.tile-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.25rem 1rem 1rem;
|
||||
}
|
||||
.tile-num {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-mute);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tile-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
130
web/src/routes/login/+page.svelte
Normal file
130
web/src/routes/login/+page.svelte
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { exhibit } from '$lib/stops';
|
||||
import { login } from '$lib/auth.svelte';
|
||||
|
||||
let password = $state('');
|
||||
let error = $state(
|
||||
$page.url.searchParams.get('error') === 'qr'
|
||||
? "That QR code didn't work — try the password posted at the exhibit."
|
||||
: ''
|
||||
);
|
||||
let busy = $state(false);
|
||||
|
||||
function safeNext(raw: string | null): string {
|
||||
if (!raw || !raw.startsWith('/') || raw.startsWith('//')) return '/';
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (busy || !password) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
const ok = await login(password);
|
||||
busy = false;
|
||||
if (!ok) {
|
||||
error = "That password didn't work — try again.";
|
||||
password = '';
|
||||
return;
|
||||
}
|
||||
await goto(safeNext($page.url.searchParams.get('next')), { replaceState: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>{exhibit.title}</h1>
|
||||
<p>Enter the password posted at the exhibit to begin.</p>
|
||||
<form onsubmit={submit}>
|
||||
<label for="pw" class="visually-hidden">Password</label>
|
||||
<input
|
||||
id="pw"
|
||||
type="password"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={busy || !password}>
|
||||
{busy ? 'Checking…' : 'Enter'}
|
||||
</button>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
background: var(--bg-elev);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 2rem 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
p {
|
||||
color: var(--ink-mute);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
}
|
||||
input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
button[type='submit'] {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
button[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
color: #b53a25;
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
373
web/src/routes/stop/[id]/+page.svelte
Normal file
373
web/src/routes/stop/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { stops, stopById } from '$lib/stops';
|
||||
|
||||
const id = $derived(Number($page.params.id));
|
||||
const stop = $derived(stopById(id));
|
||||
const index = $derived(stops.findIndex((s) => s.id === id));
|
||||
const prev = $derived(index > 0 ? stops[index - 1] : null);
|
||||
const next = $derived(index >= 0 && index < stops.length - 1 ? stops[index + 1] : null);
|
||||
|
||||
let audio: HTMLAudioElement | undefined = $state();
|
||||
let paused = $state(true);
|
||||
let volume = $state(1);
|
||||
let muted = $state(false);
|
||||
|
||||
// iOS Safari treats audio.volume as read-only. Probe a throwaway element
|
||||
// so we can hide the slider where it would be a no-op.
|
||||
const volumeWritable = browser
|
||||
? (() => {
|
||||
const probe = new Audio();
|
||||
probe.volume = 0.42;
|
||||
return probe.volume === 0.42;
|
||||
})()
|
||||
: true;
|
||||
|
||||
function toggle() {
|
||||
if (!audio) return;
|
||||
if (audio.paused) {
|
||||
audio.play().catch((err) => console.warn('audio play blocked:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted = !muted;
|
||||
if (!muted && volume === 0) volume = 0.5;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !stop}
|
||||
<main class="missing">
|
||||
<p>Stop not found.</p>
|
||||
<a href="/">Back to all stops</a>
|
||||
</main>
|
||||
{:else}
|
||||
<header class="bar">
|
||||
<a class="back" href="/" aria-label="Back to all stops">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
<span class="counter">{String(stop.id).padStart(2, '0')}</span>
|
||||
</header>
|
||||
|
||||
<main class="stop">
|
||||
<button
|
||||
type="button"
|
||||
class="hero"
|
||||
class:playing={!paused}
|
||||
onclick={toggle}
|
||||
aria-label={paused ? `Play ${stop.title}` : `Pause ${stop.title}`}
|
||||
aria-pressed={!paused}
|
||||
>
|
||||
<img src={stop.image} alt="" />
|
||||
<span class="overlay" aria-hidden="true">
|
||||
<span class="icon">
|
||||
{#if paused}
|
||||
<svg viewBox="0 0 24 24" width="32" height="32">
|
||||
<path d="M8 5l11 7-11 7V5z" fill="currentColor" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" width="32" height="32">
|
||||
<path d="M7 5h3v14H7zM14 5h3v14h-3z" fill="currentColor" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if volumeWritable}
|
||||
<div class="volume" role="group" aria-label="Volume">
|
||||
<button
|
||||
type="button"
|
||||
class="mute-btn"
|
||||
onclick={toggleMute}
|
||||
aria-label={muted || volume === 0 ? 'Unmute' : 'Mute'}
|
||||
aria-pressed={muted}
|
||||
>
|
||||
{#if muted || volume === 0}
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
|
||||
<path d="M16.5 12l3-3-1.4-1.4-3 3-3-3L10.7 9l3 3-3 3 1.4 1.4 3-3 3 3 1.4-1.4z" fill="currentColor" />
|
||||
</svg>
|
||||
{:else if volume < 0.5}
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
|
||||
<path d="M14 8.83v6.34a3 3 0 000-6.34z" fill="currentColor" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
|
||||
<path d="M14 8.83v6.34a3 3 0 000-6.34z" fill="currentColor" />
|
||||
<path d="M14 4.5v2.06a5.5 5.5 0 010 10.88v2.06a7.5 7.5 0 000-15z" fill="currentColor" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
bind:value={volume}
|
||||
aria-label="Volume level"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<audio
|
||||
bind:this={audio}
|
||||
bind:paused
|
||||
bind:volume
|
||||
bind:muted
|
||||
preload="auto"
|
||||
src={stop.audio}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<h1>{stop.title}</h1>
|
||||
{#if stop.caption}
|
||||
<p class="caption">{stop.caption}</p>
|
||||
{/if}
|
||||
|
||||
{#if stop.description}
|
||||
<div class="description">
|
||||
{#each stop.description.split(/\n\n+/) as para}
|
||||
<p>{para}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<nav class="pager" aria-label="Stop navigation">
|
||||
<button class="pager-btn" disabled={!prev} onclick={() => prev && goto(`/stop/${prev.id}`)}>
|
||||
<span class="arrow">←</span>
|
||||
<span class="label">{prev ? `${String(prev.id).padStart(2, '0')} ${prev.title}` : ''}</span>
|
||||
</button>
|
||||
<button class="pager-btn right" disabled={!next} onclick={() => next && goto(`/stop/${next.id}`)}>
|
||||
<span class="label">{next ? `${String(next.id).padStart(2, '0')} ${next.title}` : ''}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.missing {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: color-mix(in oklab, var(--bg) 80%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 10;
|
||||
}
|
||||
.back {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
color: var(--ink);
|
||||
}
|
||||
.back:hover {
|
||||
background: var(--rule);
|
||||
}
|
||||
.counter {
|
||||
font-family: var(--font-serif);
|
||||
color: var(--ink-mute);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stop {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.hero {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: var(--rule);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.hero img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.hero .overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(20, 16, 12, 0.32);
|
||||
transition: background 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
.hero.playing .overlay {
|
||||
background: rgba(20, 16, 12, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
.hero:hover .overlay,
|
||||
.hero:focus-visible .overlay {
|
||||
opacity: 1;
|
||||
background: rgba(20, 16, 12, 0.32);
|
||||
}
|
||||
.hero .icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.hero:active .icon {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.hero:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero .overlay,
|
||||
.hero .icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(1.6rem, 4vw, 2.25rem);
|
||||
}
|
||||
.caption {
|
||||
margin: 0;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.25rem 0.25rem;
|
||||
}
|
||||
.mute-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
color: var(--ink);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mute-btn:hover,
|
||||
.mute-btn:focus-visible {
|
||||
background: var(--rule);
|
||||
}
|
||||
.mute-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.volume input[type='range'] {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 6px;
|
||||
background: var(--rule);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
}
|
||||
.volume input[type='range']:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.volume input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.volume input[type='range']::-moz-range-thumb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.description {
|
||||
line-height: 1.6;
|
||||
}
|
||||
.description p {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
.pager {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem 1.5rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.pager-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elev);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
min-height: 56px;
|
||||
}
|
||||
.pager-btn.right {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
.pager-btn[disabled] {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 1.25rem;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.label {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.95rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
159
web/src/service-worker.ts
Normal file
159
web/src/service-worker.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// Production service worker for Docent.
|
||||
//
|
||||
// Behavior:
|
||||
// - Install: precache the SvelteKit shell (build + small static files).
|
||||
// - Activate: drop caches that don't match the current version.
|
||||
// - Fetch:
|
||||
// /api/* → network only (auth state must not be cached)
|
||||
// navigation → cached index.html as offline fallback
|
||||
// /audio /img → stale-while-revalidate from ASSET_CACHE
|
||||
// everything → cache-first from SHELL_CACHE
|
||||
// - Messages:
|
||||
// { type: 'precache-stops', urls: string[] }
|
||||
// Bulk-cache a list of URLs into ASSET_CACHE, posting progress back to
|
||||
// the sender via { type: 'precache-progress', done, total } and a final
|
||||
// { type: 'precache-done' } when finished.
|
||||
//
|
||||
// In dev (`serviceWorker.register: false` + the layout's dev-mode unregister),
|
||||
// this file is bundled but never registered.
|
||||
|
||||
import { build, files, version } from '$service-worker';
|
||||
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const SHELL_CACHE = `docent-shell-${version}`;
|
||||
const ASSET_CACHE = `docent-assets-v1`; // version-stable so audio survives shell upgrades
|
||||
|
||||
const shellAssets = [
|
||||
...build,
|
||||
...files.filter((f) => !f.startsWith('/audio/') && !f.startsWith('/images/'))
|
||||
];
|
||||
|
||||
sw.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
await cache.addAll(shellAssets);
|
||||
await sw.skipWaiting();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE)
|
||||
.map((k) => caches.delete(k))
|
||||
);
|
||||
await sw.clients.claim();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// Never cache API responses — auth state must always be authoritative.
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
const isContent = url.pathname.startsWith('/audio/') || url.pathname.startsWith('/images/');
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
// Navigation: prefer cached shell; fall back to network.
|
||||
if (request.mode === 'navigate') {
|
||||
const shell = await caches.open(SHELL_CACHE);
|
||||
const cached = (await shell.match('/')) || (await shell.match('/index.html'));
|
||||
if (cached) return cached;
|
||||
try {
|
||||
return await fetch(request);
|
||||
} catch (err) {
|
||||
return new Response('offline', { status: 503, statusText: 'offline' });
|
||||
}
|
||||
}
|
||||
|
||||
const cacheName = isContent ? ASSET_CACHE : SHELL_CACHE;
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) {
|
||||
if (isContent) {
|
||||
// Stale-while-revalidate: return cached, refresh in background.
|
||||
event.waitUntil(refresh(cache, request));
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(request);
|
||||
if (res.ok && res.type === 'basic') {
|
||||
cache.put(request, res.clone());
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
return new Response('offline', { status: 503, statusText: 'offline' });
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
async function refresh(cache: Cache, request: Request) {
|
||||
try {
|
||||
const res = await fetch(request, { credentials: 'same-origin' });
|
||||
if (res.ok && res.type === 'basic') await cache.put(request, res);
|
||||
} catch {
|
||||
// Offline; keep the cached version.
|
||||
}
|
||||
}
|
||||
|
||||
sw.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (data?.type !== 'precache-stops' || !Array.isArray(data.urls)) return;
|
||||
|
||||
const urls: string[] = data.urls;
|
||||
const source = event.source;
|
||||
event.waitUntil(precacheStops(urls, source));
|
||||
});
|
||||
|
||||
async function precacheStops(urls: string[], source: Client | ServiceWorker | MessagePort | null) {
|
||||
const cache = await caches.open(ASSET_CACHE);
|
||||
let done = 0;
|
||||
const total = urls.length;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const cached = await cache.match(url);
|
||||
if (!cached) {
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (res.ok && res.type === 'basic') await cache.put(url, res);
|
||||
}
|
||||
} catch {
|
||||
// Network error — skip and continue. UI shows the count.
|
||||
}
|
||||
done++;
|
||||
try {
|
||||
(source as Client | null)?.postMessage?.({
|
||||
type: 'precache-progress',
|
||||
done,
|
||||
total
|
||||
});
|
||||
} catch {
|
||||
// Sender went away.
|
||||
}
|
||||
}
|
||||
try {
|
||||
(source as Client | null)?.postMessage?.({ type: 'precache-done', done, total });
|
||||
} catch {
|
||||
// Sender went away.
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue