Prototype commit
Some checks are pending
check / frontend (push) Waiting to run
check / server (push) Waiting to run

This commit is contained in:
Pen Anderson 2026-05-05 09:33:51 -05:00
commit b2ffbe865e
29 changed files with 3438 additions and 0 deletions

1536
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
web/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "docent-web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.7.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}

72
web/src/app.css Normal file
View 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
View 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>

View 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';
}

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

View 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>

View file

@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;

133
web/src/routes/+page.svelte Normal file
View 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>

View 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>

View 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
View 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.
}
}

View file

@ -0,0 +1,30 @@
{
"name": "Mill Run",
"short_name": "Mill Run",
"description": "Audio-guided exhibit",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#faf7f2",
"theme_color": "#2a2520",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

2
web/static/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

21
web/svelte.config.js Normal file
View file

@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
}),
serviceWorker: {
register: false
},
alias: {
$content: './src/lib/content'
}
}
};

14
web/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

39
web/vite.config.ts Normal file
View file

@ -0,0 +1,39 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
allowedHosts: ['docent-dev.pentacle.games', 'localhost'],
hmr: {
protocol: 'wss',
host: 'docent-dev.pentacle.games',
clientPort: 443
},
watch: {
usePolling: true,
interval: 500
},
fs: {
strict: true,
allow: ['./src', './static', './node_modules', './.svelte-kit'],
deny: [
'.env',
'.env.*',
'**/.git/**',
'**/secrets/**',
'**/*.{pem,crt,key,age}',
'../server/**',
'../content/**'
]
},
proxy: {
'/api': 'http://localhost:8181',
'/audio': 'http://localhost:8181',
'/images': 'http://localhost:8181'
}
}
});