Prototype commit
This commit is contained in:
commit
b2ffbe865e
29 changed files with 3438 additions and 0 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue