Compare commits

..

No commits in common. "c09b5f4308b460421d9107c8d64fe27d1c7074e8" and "82a8a6a21a82760058fe690f7f3acd3956dd0969" have entirely different histories.

64 changed files with 604 additions and 3832 deletions

13
.gitignore vendored
View file

@ -27,18 +27,5 @@ Thumbs.db
# Direnv # Direnv
.direnv/ .direnv/
# Source audio (large WAVs from concert tape transfers)
content/audio/*.wav
# Built audio (regenerate from content/audio/ via `make audio`)
web/static/audio/*.opus
# Built images (regenerate from content/images/ via `make images`)
web/static/images/*.webp
# Nix build outputs
result
result-*
# Build artifacts from content pipeline # Build artifacts from content pipeline
content/build/ content/build/

View file

@ -2,46 +2,10 @@ SHELL := /usr/bin/env bash
.PHONY: help .PHONY: help
help: help:
@echo "make build — build server + frontend locally (sanity check)" @echo "make build — build server + frontend locally (sanity check)"
@echo "make check — type-check + go vet" @echo "make check — type-check + go vet"
@echo "make audio-pull — copy source WAVs from Dropbox into content/audio/"
@echo "make audio — transcode each side WAV → Opus"
@echo "make images — convert content/images/*.jpg → WebP"
@echo "make artists — regenerate artists.json from audio.yaml"
@echo "make content — artists + images + audio"
@echo "make deploy-assets — rsync web/static/{audio,images}/ to sprade for production"
@echo "make tag V=patch|minor|major — bump version tag and push" @echo "make tag V=patch|minor|major — bump version tag and push"
@echo "make prefetch — print sha256 + npmDepsHash + vendorHash for the homelab module" @echo "make prefetch — print sha256 + npmDepsHash + vendorHash for the homelab module"
.PHONY: audio-pull
audio-pull:
bin/pull-audio
.PHONY: audio
audio:
bin/build-audio
.PHONY: images
images:
bin/build-images
.PHONY: artists
artists:
bin/build-artists
.PHONY: content
content: artists images audio
.PHONY: deploy-assets
deploy-assets:
@if [ ! -d web/static/audio ] || [ -z "$$(ls web/static/audio 2>/dev/null)" ]; then \
echo "error: web/static/audio is empty. Run \`make audio\` first." >&2; exit 1; \
fi
@if [ ! -d web/static/images ] || [ -z "$$(ls web/static/images 2>/dev/null)" ]; then \
echo "error: web/static/images is empty. Run \`make images\` first." >&2; exit 1; \
fi
rsync -av --delete --progress web/static/audio/ deploy@sprade:/var/lib/docent/audio/
rsync -av --delete --progress web/static/images/ deploy@sprade:/var/lib/docent/images/
.PHONY: build .PHONY: build
build: build:

View file

@ -1,49 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MANIFEST="${MANIFEST:-$REPO_ROOT/content/audio.yaml}"
OUT="${OUT:-$REPO_ROOT/web/src/lib/artists.json}"
for tool in yq jq; do
if ! command -v "$tool" >/dev/null; then
echo "error: $tool not in PATH (try: nix develop)" >&2
exit 1
fi
done
yq -o=json '.' "$MANIFEST" | jq '
def render_artist:
. as $a |
{
id: $a.id,
name: $a.name,
slug: $a.slug,
description: ($a.description // "" | rtrimstr("\n")),
images: [
($a.images // []) | to_entries[] |
("/images/" + $a.slug + "-" + ((.key + 1) | tostring) + ".webp")
],
shows: [
$a.shows[] | . as $s | {
caption: $s.caption,
slug: $s.slug,
sides: [
$s.sides[] | {
id: .id,
label: .label,
audio: ("/audio/" + $a.slug + "-" + $s.slug + "-" + .id + ".opus")
}
]
}
]
};
{
exhibit: .exhibit,
artists: [ .artists[] | render_artist ]
}
' > "$OUT.tmp"
mv "$OUT.tmp" "$OUT"
echo "wrote $OUT ($(jq '.artists | length' "$OUT") artists)"

View file

@ -1,95 +0,0 @@
#!/usr/bin/env bash
# Sides are encoded individually — no concatenation. Idempotent: a side is
# skipped if its output is newer than its source.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MANIFEST="${MANIFEST:-$REPO_ROOT/content/audio.yaml}"
RAW_DIR="${RAW_DIR:-$REPO_ROOT/content/audio}"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/web/static/audio}"
for tool in yq ffmpeg; do
if ! command -v "$tool" >/dev/null; then
echo "error: $tool not in PATH (try: nix develop)" >&2
exit 1
fi
done
if [[ ! -f "$MANIFEST" ]]; then
echo "error: manifest not found: $MANIFEST" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
DEFAULT_BITRATE=$(yq -r '.defaults.bitrate // "64k"' "$MANIFEST")
DEFAULT_CHANNELS=$(yq -r '.defaults.channels // "2"' "$MANIFEST")
DEFAULT_RATE=$(yq -r '.defaults.sample_rate // "48000"' "$MANIFEST")
encoded=0
skipped=0
missing=0
failed=0
NUM_ARTISTS=$(yq '.artists | length' "$MANIFEST")
for ((a=0; a<NUM_ARTISTS; a++)); do
ARTIST_SLUG=$(yq -r ".artists[$a].slug" "$MANIFEST")
NUM_SHOWS=$(yq ".artists[$a].shows | length" "$MANIFEST")
for ((s=0; s<NUM_SHOWS; s++)); do
SHOW_SLUG=$(yq -r ".artists[$a].shows[$s].slug" "$MANIFEST")
NUM_SIDES=$(yq ".artists[$a].shows[$s].sides | length" "$MANIFEST")
for ((sd=0; sd<NUM_SIDES; sd++)); do
SIDE_ID=$(yq -r ".artists[$a].shows[$s].sides[$sd].id" "$MANIFEST")
SOURCE=$(yq -r ".artists[$a].shows[$s].sides[$sd].source" "$MANIFEST")
BITRATE=$(yq -r ".artists[$a].shows[$s].sides[$sd].bitrate // \"$DEFAULT_BITRATE\"" "$MANIFEST")
CHANNELS=$(yq -r ".artists[$a].shows[$s].sides[$sd].channels // \"$DEFAULT_CHANNELS\"" "$MANIFEST")
RATE=$(yq -r ".artists[$a].shows[$s].sides[$sd].sample_rate // \"$DEFAULT_RATE\"" "$MANIFEST")
SRC_PATH="$RAW_DIR/$SOURCE"
OUT_NAME="${ARTIST_SLUG}-${SHOW_SLUG}-${SIDE_ID}.opus"
OUT_PATH="$OUT_DIR/$OUT_NAME"
if [[ ! -f "$SRC_PATH" ]]; then
printf ' %-50s SKIP (source not present)\n' "$OUT_NAME"
missing=$((missing + 1))
continue
fi
if [[ -f "$OUT_PATH" ]]; then
src_mt=$(stat -c %Y "$SRC_PATH")
out_mt=$(stat -c %Y "$OUT_PATH")
if (( out_mt > src_mt )); then
printf ' %-50s up to date\n' "$OUT_NAME"
skipped=$((skipped + 1))
continue
fi
fi
printf ' %-50s encoding ...\n' "$OUT_NAME"
if ffmpeg \
-i "$SRC_PATH" \
-c:a libopus -b:a "$BITRATE" -ac "$CHANNELS" -ar "$RATE" \
-application audio -vbr on -compression_level 10 \
-y "$OUT_PATH" \
-hide_banner -loglevel warning -nostdin </dev/null; then
size=$(du -h "$OUT_PATH" | cut -f1)
printf ' %-50s done (%s)\n' "$OUT_NAME" "$size"
encoded=$((encoded + 1))
else
printf ' %-50s FAILED\n' "$OUT_NAME" >&2
failed=$((failed + 1))
fi
done
done
done
printf '\n%d encoded · %d up-to-date · %d missing sources · %d failed\n' \
"$encoded" "$skipped" "$missing" "$failed"
if (( failed > 0 )); then
exit 1
fi

View file

@ -1,76 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MANIFEST="${MANIFEST:-$REPO_ROOT/content/audio.yaml}"
SRC_DIR="${SRC_DIR:-$REPO_ROOT/content/images}"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/web/static/images}"
MAX_SIZE="${MAX_SIZE:-1200}"
QUALITY="${QUALITY:-80}"
for tool in yq magick; do
if ! command -v "$tool" >/dev/null; then
echo "error: $tool not in PATH (try: nix develop)" >&2
exit 1
fi
done
mkdir -p "$OUT_DIR"
encoded=0
skipped=0
missing=0
failed=0
NUM_ARTISTS=$(yq '.artists | length' "$MANIFEST")
for ((a=0; a<NUM_ARTISTS; a++)); do
ARTIST_SLUG=$(yq -r ".artists[$a].slug" "$MANIFEST")
NUM_IMAGES=$(yq ".artists[$a].images | length // 0" "$MANIFEST")
for ((i=0; i<NUM_IMAGES; i++)); do
SRC_NAME=$(yq -r ".artists[$a].images[$i]" "$MANIFEST")
SRC_PATH="$SRC_DIR/$SRC_NAME"
OUT_NAME="${ARTIST_SLUG}-$((i + 1)).webp"
OUT_PATH="$OUT_DIR/$OUT_NAME"
if [[ ! -f "$SRC_PATH" ]]; then
printf ' %-45s SKIP (source not found)\n' "$OUT_NAME"
missing=$((missing + 1))
continue
fi
if [[ -f "$OUT_PATH" ]]; then
src_mt=$(stat -c %Y "$SRC_PATH")
out_mt=$(stat -c %Y "$OUT_PATH")
if (( out_mt > src_mt )); then
printf ' %-45s up to date\n' "$OUT_NAME"
skipped=$((skipped + 1))
continue
fi
fi
printf ' %-45s converting ...\n' "$OUT_NAME"
if magick "$SRC_PATH" \
-auto-orient \
-strip \
-resize "${MAX_SIZE}x${MAX_SIZE}>" \
-quality "$QUALITY" \
"$OUT_PATH"; then
size=$(du -h "$OUT_PATH" | cut -f1)
printf ' %-45s done (%s)\n' "$OUT_NAME" "$size"
encoded=$((encoded + 1))
else
printf ' %-45s FAILED\n' "$OUT_NAME" >&2
failed=$((failed + 1))
fi
done
done
printf '\n%d converted · %d up-to-date · %d missing sources · %d failed\n' \
"$encoded" "$skipped" "$missing" "$failed"
if (( failed > 0 )); then
exit 1
fi

View file

@ -1,39 +0,0 @@
#!/usr/bin/env bash
# Copy concert WAV files from the curator's Dropbox folder into content/audio/,
# stripping the ` Brianna` suffix that the source files carry.
# Idempotent: skips files that already exist at the destination.
set -euo pipefail
SRC_DIR="${SRC_DIR:-$HOME/Dropbox/File requests/Mill Run Audio}"
DEST_DIR="${DEST_DIR:-$(cd "$(dirname "$0")/.." && pwd)/content/audio}"
if [[ ! -d "$SRC_DIR" ]]; then
echo "error: source directory not found: $SRC_DIR" >&2
exit 1
fi
mkdir -p "$DEST_DIR"
count=0
copied=0
skipped=0
shopt -s nullglob
for src in "$SRC_DIR"/*.wav; do
count=$((count + 1))
name=$(basename "$src")
# Strip trailing " Brianna.wav" → ".wav"
dest_name="${name% Brianna.wav}.wav"
dest="$DEST_DIR/$dest_name"
if [[ -f "$dest" ]] && [[ $(stat -c %s "$src") -eq $(stat -c %s "$dest") ]]; then
skipped=$((skipped + 1))
continue
fi
printf ' [%2d] %s\n' "$count" "$dest_name"
cp -- "$src" "$dest"
copied=$((copied + 1))
done
printf '\n%d total · %d copied · %d skipped (already present)\n' "$count" "$copied" "$skipped"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

View file

@ -1,899 +0,0 @@
# Source of truth for the exhibit's audio collection.
#
# The Mill Run Playhouse-Theatre stop in the larger exhibit holds recordings
# from concerts at the venue, digitized side-by-side from cassette tapes.
# The data model here is artist → shows → sides. Each `side` produces one
# Opus file; sides are not concatenated.
#
# Filename convention (auto-derived):
# web/static/audio/<artist.slug>-<show.slug>-<side.id>.opus
#
# Pipeline:
# bin/pull-audio — copy WAVs from Dropbox to content/audio/
# bin/build-audio — encode each side as its own opus
# bin/build-images — convert content/images/*.jpg → web/static/images/*.webp
# bin/build-stops — regenerate web/src/lib/stops.json from this file
defaults:
bitrate: 64k
channels: 2
sample_rate: 48000
exhibit:
title: "Mill Run Theatre"
description: |
Listen to performances from the Mill Run
Select from the artists below
artists:
- id: 1
name: "Al Martino"
slug: al-martino
description: |
American singer of Italian descent best known for the ballad "Here
in My Heart" and the perennial "Spanish Eyes." Martino also played
the singer Johnny Fontane in The Godfather and its sequel.
images:
- "AL MARTINO001_74.jpg"
shows:
- caption: "1974"
slug: "1974"
sides:
- id: a
label: "Side A"
source: "Al Martino - 1974 (Side A) (No Side B).wav"
- id: 2
name: "Caterina Valente"
slug: caterina-valente
description: |
Italo-French entertainer who recorded in over a dozen languages across
a career spanning the 1950s through the 1980s. A jazz-trained singer,
she was a fixture of European and American television variety shows.
images: []
shows:
- caption: "Aug 1976"
slug: "1976-aug"
sides:
- id: a
label: "Side A"
source: "Caterina Valente - August 1976 (2nd Show Sunday) (Side A) (No Side B).wav"
- id: 3
name: "Dionne Warwick"
slug: dionne-warwicke
description: |
American singer who became the definitive voice of the Burt Bacharach
and Hal David songbook in the 1960s: "Walk On By," "Anyone Who Had a
Heart," and "Do You Know the Way to San Jose." From 1971 to 1975 she
performed under the alternate spelling "Warwicke."
images:
- "Dionne Warwick 1974 from Don Leavitt.jpg"
shows:
- caption: "1974"
slug: "1974"
sides:
- id: a
label: "Side A"
source: "Dionne Warwicke - 1974 (Side A).wav"
- id: b
label: "Side B"
source: "Dionne Warwicke - 1974 (Side B).wav"
- id: 4
name: "Don Ho"
slug: don-ho
description: |
Hawaiian musician and entertainer whose 1966 single "Tiny Bubbles"
became his lifelong signature. He performed nightly in Waikiki for
decades and toured the mainland into the 2000s.
images:
- "Don Ho 001 1980.jpg"
shows:
- caption: "Jun 26, 1977 — 1st show"
slug: "1977-jun-1"
sides:
- id: a
label: "Side A"
source: "Don Ho - June 26th 1977 (Tape 1) (Side A).wav"
- id: b
label: "Side B"
source: "Don Ho - June 26th 1977 (Tape 1) (Side B).wav"
- caption: "Jun 26, 1977 — 2nd show"
slug: "1977-jun-2"
sides:
- id: a
label: "Side A"
source: "Don Ho - June 26th 1977 (Tape 2) (Side A).wav"
- id: b
label: "Side B"
source: "Don Ho - June 26th 1977 (Tape 2) (Side B).wav"
- id: 5
name: "Don Rickles"
slug: don-rickles
description: |
American stand-up comedian known for insult comedy aimed affectionately
at celebrities and audience members alike. A regular on television
variety and talk shows from the 1960s onward.
images:
- "Don Rickles 002 1975.jpg"
shows:
- caption: "May 25, 1975"
slug: "1975-may"
sides:
- id: a
label: "Side A"
source: "Don Rickles - May 25th 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Don Rickles - May 25th 1975 (Side B).wav"
- id: 6
name: "Eddy Arnold"
slug: eddy-arnold
description: |
Tennessee-born country singer who placed more than 145 songs on the
country charts across five decades. Hits include "Make the World Go
Away" and "Cattle Call."
images:
- "Eddy Arnold 001 1975.jpg"
shows:
- caption: "1975"
slug: "1975"
sides:
- id: a
label: "Side A"
source: "Eddy Arnold - 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Eddy Arnold - 1975 (Side B).wav"
- caption: "Nov 1977"
slug: "1977-nov"
sides:
- id: a
label: "Side A"
source: "Eddy Arnold - November 1977 (Side A).wav"
- id: b
label: "Side B"
source: "Eddy Arnold - November 1977 (Side B).wav"
- id: 7
name: "Engelbert Humperdinck"
slug: engelbert-humperdinck
description: |
British pop singer whose 1967 single "Release Me" famously kept the
Beatles' "Strawberry Fields Forever" from reaching number one in the
UK. His stage name was borrowed from a 19th-century German opera
composer.
images:
- "Engelbert Humperdinck 003 1975.jpg"
shows:
- caption: "Oct 1975"
slug: "1975-oct"
sides:
- id: a
label: "Side A"
source: "Engelebert Humperdinck - October 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Engelebert Humperdinck - October 1975 (Side B).wav"
- id: 8
name: "Frankie Laine"
slug: frankie-laine
description: |
American crooner whose hits include "That's My Desire," "Mule Train,"
"Jezebel," and the theme from the television series Rawhide.
images:
- "Frankie Laine 002 1975.jpg"
shows:
- caption: "Sep 1975"
slug: "1975-sep"
sides:
- id: a
label: "Side A"
source: "Frankie Laine - September 1975 (2nd Show Sunday) (Side A).wav"
- id: b
label: "Side B"
source: "Frankie Laine - September 1975 (2nd Show Sunday) (Side B).wav"
- id: 9
name: "Gladys Knight & the Pips"
slug: gladys-knight
description: |
American soul and funk group fronted by Gladys Knight with her brother
and two cousins as the Pips. Hits include "Midnight Train to Georgia,"
"I Heard It Through the Grapevine," and "Neither One of Us."
images:
- "Gladys Knight and the Pips 001 1975.jpg"
- "Gladys Knight & the Pips 001 1975.jpg"
shows:
- caption: "Aug 1977 — 1st show"
slug: "1977-aug-1st"
sides:
- id: a
label: "Side A"
source: "Gladys Knight - August 1977 (1st Show Saturday) (Side A).wav"
- id: b
label: "Side B"
source: "Gladys Knight - August 1977 (1st Show Saturday) (Side B).wav"
- caption: "Aug 1977 — 2nd show"
slug: "1977-aug-2nd"
sides:
- id: a
label: "Side A"
source: "Gladys Knight - August 1977 (2nd Show Saturday) (Side A).wav"
- id: b
label: "Side B"
source: "Gladys Knight - August 1977 (2nd Show Saturday) (Side B).wav"
- caption: "Oct 1975 — 1st show"
slug: "1975-oct-fri"
sides:
- id: a
label: "Side A"
source: "Gladys Knight - October 1975 (2nd Show Friday) (Side A).wav"
- id: b
label: "Side B"
source: "Gladys Knight - October 1975 (2nd Show Friday) (Side B).wav"
- caption: "Oct 1975 — 2nd show"
slug: "1975-oct-sat"
sides:
- id: a
label: "Side A"
source: "Gladys Knight - October 1975 (2nd Show Saturday) (Side A).wav"
- id: b
label: "Side B"
source: "Gladys Knight - October 1975 (2nd Show Saturday) (Side B).wav"
- id: 10
name: "Glen Campbell & Jud Strunk"
slug: glen-campbell-jud-strunk
description: |
Country-pop superstar Glen Campbell ("Wichita Lineman," "Rhinestone
Cowboy") shared this bill with Jud Strunk, a Maine native who reached
the U.S. Top 20 in 1973 with the novelty hit "Daisy a Day."
images:
- "Glenn Campbell 006 1973.jpg"
shows:
- caption: "Dec 1973"
slug: "1973-dec"
sides:
- id: a
label: "Side A"
source: "Glen Campbell_Jud Strunk - December 1973 (Side A).wav"
- id: b
label: "Side B"
source: "Glen Campbell_Jud Strunk - December 1973 (Side B).wav"
- id: 11
name: "Glenn Yarbrough & The Limeliters"
slug: glenn-yarbrough-limeliters
description: |
Folk singer who rose to fame with the Limeliters, one of the leading
acts of the early-1960s folk revival, before launching a successful
solo career. He occasionally rejoined the group for reunion tours.
images: []
shows:
- caption: "Oct 1977"
slug: "1977-oct"
sides:
- id: a
label: "Side A"
source: "Glenn Yarbrough & Limeliters - October 1977 (Side A).wav"
- id: b
label: "Side B"
source: "Glenn Yarbrough & Limeliters - October 1977 (Side B).wav"
- id: 12
name: "Harry Belafonte"
slug: harry-belafonte
description: |
American singer, actor, and civil rights activist who brought Caribbean
music to mainstream audiences with "Day-O (The Banana Boat Song)" and
"Jump in the Line." His 1956 album Calypso was the first by a solo
artist to sell a million copies in the United States.
images: []
shows:
- caption: "May 1976"
slug: "1976-may"
sides:
- id: a
label: "Act I — Side A"
source: "Harry Belafonte - May 1976 (1st Half) (Act I) (Side A).wav"
- id: b
label: "Act I — Side B"
source: "Harry Belafonte - May 1976 (1st Half) (Act I) (Side B).wav"
- id: 13
name: "Helen Reddy"
slug: helen-reddy
description: |
Australian-American singer best known for "I Am Woman," an anthem
that became a touchstone of second-wave feminism. Other hits include
"Delta Dawn" and "Angie Baby."
images:
- "HelenReddy001_74.jpg"
shows:
- caption: "1974"
slug: "1974"
sides:
- id: a
label: "Side A"
source: "Helen Reddy - 1974 (Side A).wav"
- id: b
label: "Side B"
source: "Helen Reddy - 1974 (Side B).wav"
- id: 14
name: "Florence Henderson & Jim Nabors"
slug: henderson-nabors
description: |
Florence Henderson (Carol Brady of The Brady Bunch) shared this bill
with Jim Nabors (Gomer Pyle of The Andy Griffith Show). Both had
concert-singing careers alongside their television roles and were
regulars on variety programs.
images:
- "Florence Henderson 009 1975.jpg"
- "Jim Nabors 002 1975.jpg"
shows:
- caption: "Jul 1975"
slug: "1975-jul"
sides:
- id: a
label: "Side A"
source: "Henderson & Nabors - July 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Henderson & Nabors - July 1975 (Side B).wav"
- id: 15
name: "The Hues Corporation & The 5th Dimension"
slug: hues-corp-5th-dimension
description: |
The Hues Corporation, who topped the charts in 1974 with "Rock the
Boat," shared this bill with The 5th Dimension, the late-1960s
sunshine-pop group behind "Up, Up and Away" and the medley
"Aquarius/Let the Sunshine In," drawn from the musical Hair.
images:
- "The 5th DimentionFD001_74.jpg"
shows:
- caption: "1974"
slug: "1974"
sides:
- id: a
label: "Side A"
source: "Hues Corp; 5th Dimension - 1974 (Side A).wav"
- id: b
label: "Side B"
source: "Hues Corp; 5th Dimension - 1974 (Side B).wav"
- id: 16
name: "John Davidson"
slug: john-davidson
description: |
American singer, actor, and television host with a clean-cut style.
A frequent variety-show guest in the 1960s and '70s, he later hosted
That's Incredible! and Hollywood Squares.
images:
- "JohnDavidson002_74 HQ.jpg"
shows:
- caption: "1974"
slug: "1974"
sides:
- id: a
label: "Side A"
source: "John Davidson - 1974 (Side A).wav"
- id: b
label: "Side B"
source: "John Davidson - 1974 (Side B).wav"
- id: 17
name: "Johnny Mathis"
slug: johnny-mathis
description: |
American singer whose smooth tenor on songs like "Chances Are,"
"Misty," and "It's Not for Me to Say" made him one of the best-selling
recording artists of the twentieth century.
images:
- "Johnny Mathis 004 1975.jpg"
shows:
- caption: "Aug 1975 — 1st show"
slug: "1975-aug-sun-1st"
sides:
- id: a
label: "Side A"
source: "Johnny Mathis - August 1975 (1st Show Sunday) (Side A).wav"
- id: b
label: "Side B"
source: "Johnny Mathis - August 1975 (1st Show Sunday) (Side B).wav"
- caption: "Aug 1975 — 2nd show"
slug: "1975-aug-1st"
sides:
- id: a
label: "Side A"
source: "Johnny Mathis - August 1975 (1st) (Side A).wav"
- id: b
label: "Side B"
source: "Johnny Mathis - August 1975 (1st) (Side B).wav"
- caption: "Aug 1975 — 3rd show"
slug: "1975-aug-2nd-half"
sides:
- id: a
label: "Side A"
source: "Johnny Mathis - August 1975 (2nd Half) (Side A) (No Side B).wav"
- caption: "Aug 1975 — 4th show"
slug: "1975-aug-3rd"
sides:
- id: a
label: "Side A"
source: "Johnny Mathis - August 1975 (3rd) (Side A) (No Side B).wav"
- caption: "Aug 1976 — 1st show"
slug: "1976-aug-1"
sides:
- id: a
label: "Side A"
source: "Johnny Mathis - August 1976 (Side A) 1-13-2021 (tape with stickers on box).wav"
- id: b
label: "Side B"
source: "Johnny Mathis - August 1976 (Side B) 1-13-2021 (tape with stickers on box).wav"
- caption: "Aug 1976 — 2nd show"
slug: "1976-aug-2"
sides:
- id: a
label: "Side A"
source: "Johnny Mathis - August 1976 (Side A) 7-30-2021.wav"
- id: b
label: "Side B"
source: "Johnny Mathis - August 1976 (Side B) 7-30-2021.wav"
- id: 18
name: "Kate Smith"
slug: kate-smith
description: |
American singer with a 50-year career and a deep contralto voice,
famous for her 1939 recording of Irving Berlin's "God Bless America."
She hosted radio and television variety programs through the 1950s
and '60s.
images:
- "Kate Smith 006 1975.jpg"
shows:
- caption: "Nov 1975"
slug: "1975-nov"
sides:
- id: a
label: "Side A"
source: "Kate Smith - November 1975 (1st Show) (Side A).wav"
- id: b
label: "Side B"
source: "Kate Smith - November 1975 (1st Show) (Side B).wav"
- id: 19
name: "Lainie Kazan"
slug: lainie-kazan
description: |
American singer and actress with a powerful belt and Broadway roots.
As Barbra Streisand's understudy in Funny Girl, she famously stepped
in for one performance in 1964. She later became a fixture in supper
clubs and film comedies.
images: []
shows:
- caption: "Aug 1975 — 1st show"
slug: "1975-aug-1"
sides:
- id: a
label: "Side A"
source: "Lainie Kazan - August 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Lainie Kazan - August 1975 (Side B).wav"
- caption: "Aug 1975 — 2nd show"
slug: "1975-aug-2"
sides:
- id: a
label: "Side A"
source: "Lainie Kazan - August 1975 (Tape 2) (Side A).wav"
- id: b
label: "Side B"
source: "Lainie Kazan - August 1975 (Tape 2) (Side B).wav"
- id: 20
name: "The Love Machine"
slug: love-machine
description: |
International all-female funk and soul group active in the 1970s.
The seven-member ensemble, which included songwriter Sandra Sully,
toured Europe, Asia, and Africa and recorded for labels including
Buddah, Arista, and Motown.
images: []
shows:
- caption: "Oct 1976"
slug: "1976-oct"
sides:
- id: a
label: "Side A"
source: "Love Machine - October 1976 (Side A).wav"
- id: 21
name: "Tony Martin & Cyd Charisse"
slug: martin-charisse
description: |
Husband-and-wife stage act. Tony Martin sang and Cyd Charisse danced;
both were veterans of Hollywood musicals, and they toured together
through the 1970s.
images: []
shows:
- caption: "Aug 1975"
slug: "1975-aug"
sides:
- id: a
label: "Side A"
source: "Martin;Charisse - August 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Martin;Charisse - August 1975 (Side B).wav"
- id: 22
name: "The Mills Brothers"
slug: mills-brothers
description: |
American close-harmony vocal quartet from Piqua, Ohio, active from
the 1920s through the 1980s. Best known for "Paper Doll" (1943),
"Glow Worm," and "You Always Hurt the One You Love."
images:
- "Mills Brothers 003 1975.jpg"
shows:
- caption: "Sep 1975"
slug: "1975-sep"
sides:
- id: a
label: "Side A"
source: "Mills Brothers - September 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Mills Brothers - September 1975 (Side B).wav"
- id: 23
name: "Mitzi Gaynor"
slug: mitzi-gaynor
description: |
American actress, singer, and dancer who starred in the 1958 film of
South Pacific opposite Rossano Brazzi. Her touring revues throughout
the 1970s showcased her dance numbers and comic timing.
images:
- "Mitzi Gaynor 007 1976.jpg"
shows:
- caption: "Oct 2, 1977"
slug: "1977-oct"
sides:
- id: i-a
label: "Act I — Side A"
source: "Mitzi Gaynor - October 2nd 1977 (Act I) (Side A).wav"
- id: i-b
label: "Act I — Side B"
source: "Mitzi Gaynor - October 2nd 1977 (Act I) (Side B).wav"
- id: ii-a
label: "Act II — Side A"
source: "Mitzi Gaynor - October 2nd 1977 (Act II) (Side A) (No Side B).wav"
- id: 24
name: "Robert Goulet"
slug: robert-goulet
description: |
Canadian-American baritone who originated the role of Lancelot in
Lerner and Loewe's Camelot on Broadway in 1960. A long career on
television, in film, and on the Las Vegas circuit followed.
images:
- "Robert Goulet crop 008 1976.jpg"
shows:
- caption: "Jun 1975 — with Foster Brooks"
slug: "1975-jun"
sides:
- id: a
label: "Side A"
source: "Robert Goulet_Foster Brooks - June 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Robert Goulet_Foster Brooks - June 1975 (Side B).wav"
- caption: "Oct 1976"
slug: "1976-oct"
sides:
- id: a
label: "Side A"
source: "Robert Goulet - October 1976 (Side A).wav"
- id: b
label: "Side B"
source: "Robert Goulet - October 1976 (Side B).wav"
- id: 25
name: "Roy Clark"
slug: roy-clark
description: |
American country musician and comedian, longtime co-host of the
syndicated television show Hee Haw. A virtuoso multi-instrumentalist
and a Country Music Hall of Fame inductee.
images:
- "Roy Clark 009 crop1976.jpg"
shows:
- caption: "Apr 1976"
slug: "1976-apr"
sides:
- id: a
label: "Side A"
source: "Roy Clark - April 1976 (Side A).wav"
- id: b
label: "Side B"
source: "Roy Clark - April 1976 (Side B).wav"
- id: 26
name: "Sammy Davis Jr."
slug: sammy-davis-jr
description: |
American singer, dancer, actor, and member of the Rat Pack, whose
career spanned more than six decades across Broadway, film, and
television. His signature hit was "The Candy Man."
images:
- "Sammy Davis Jr. 002 1975.jpg"
- "Sammy Davis Jr. 003 1976.jpg"
shows:
- caption: "1975"
slug: "1975"
sides:
- id: a
label: "Side A"
source: "Sammy Davis - 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Sammy Davis - 1975 (Side B).wav"
- caption: "Nov 1976"
slug: "1976-nov"
sides:
- id: a
label: "Side A"
source: "Sammy Davis Jr - November 1976 (Side A).wav"
- id: b
label: "Side B"
source: "Sammy Davis Jr - November 1976 (Side B).wav"
- id: 27
name: "Sandler & Young"
slug: sandler-young
description: |
Singing duo Tony Sandler (Belgian) and Ralph Young (American), known
for their multilingual repertoire and tight harmony. A staple of
supper-club and television variety bookings from the late 1960s
onward.
images:
- "Sandler & Young 010 1975.jpg"
shows:
- caption: "Dec 1975 — 1st show"
slug: "1975-dec-mill-run"
sides:
- id: a
label: "Side A"
source: "Sandler_Young - December 1975 ('Mill Run' Label) (Side A).wav"
- id: b
label: "Side B"
source: "Sandler_Young - December 1975 ('Mill Run' Label) (Side B).wav"
- caption: "Dec 1975 — 2nd show"
slug: "1975-dec"
sides:
- id: a
label: "Side A"
source: "Sandler_Young - December 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Sandler_Young - December 1975 (Side B).wav"
- id: 28
name: "Shirley Bassey"
slug: shirley-bassey
description: |
Welsh singer with a powerful contralto, internationally known for the
three James Bond film theme songs she recorded: "Goldfinger" (1964),
"Diamonds Are Forever" (1971), and "Moonraker" (1979).
images:
- "Shirley Bassey 011 1976.jpg"
shows:
- caption: "Sep 1976"
slug: "1976-sep"
sides:
- id: a
label: "Side A"
source: "Shirley Bassey - September 1976 (Side A).wav"
- id: b
label: "Side B"
source: "Shirley Bassey - September 1976 (Side B).wav"
- id: 29
name: "Steve Lawrence & Eydie Gormé"
slug: steve-lawrence-eydie-gorme
description: |
American husband-and-wife singing duo who met as Steve Allen's Tonight
Show regulars in the 1950s and remained inseparable performers until
Eydie's death in 2013. Each maintained a successful solo career
alongside the duo work.
images:
- "Steve Lawrence_EydieGorme001_73.jpg"
shows:
- caption: "Aug 1977 — 1st show"
slug: "1977-aug-1"
sides:
- id: a
label: "Side A"
source: "Steve & Eydie - August 1977 (Side A).wav"
- id: b
label: "Side B"
source: "Steve & Eydie - August 1977 (Side B).wav"
- caption: "Aug 1977 — 2nd show"
slug: "1977-aug-2"
sides:
- id: a
label: "Side A"
source: "Steve Lawrence & Eydie Gorme - August 1977 (1st Show Sunday) (Side A).wav"
- id: b
label: "Side B"
source: "Steve Lawrence & Eydie Gorme - August 1977 (1st Show Sunday) (Side B).wav"
- id: 30
name: "The Dells"
slug: the-dells
description: |
Chicago R&B vocal group whose career stretched from the 1950s
("Oh What a Night") through the 1990s ("Stay in My Corner"). One of
the longest-lived doo-wop and soul ensembles.
images: []
shows:
- caption: "1975"
slug: "1975"
sides:
- id: a
label: "Side A"
source: "The Dells - 1975 (Side A).wav"
- id: b
label: "Side B"
source: "The Dells - 1975 (Side B).wav"
- id: 31
name: "The Irish Rovers"
slug: irish-rovers
description: |
Canadian-Irish folk group founded in Toronto in the 1960s, best known
for "The Unicorn" (1968) and "Wasn't That a Party" (1981).
images: []
shows:
- caption: "Jul 1977 — 1st show"
slug: "1977-jul-16"
sides:
- id: a
label: "Side A"
source: "The Irish Rovers - July 16th, 1977 (1st Show) (Side A).wav"
- id: b
label: "Side B"
source: "The Irish Rovers - July 16th, 1977 (1st Show) (Side B).wav"
- caption: "Jul 1977 — 2nd show"
slug: "1977-jul-17"
sides:
- id: a
label: "Side A"
source: "The Irish Rovers - July 17th, 1977 (2nd Show) (Side A).wav"
- id: b
label: "Side B"
source: "The Irish Rovers - July 17th, 1977 (2nd Show) (Side B).wav"
- id: 32
name: "The Kingston Trio"
slug: kingston-trio
description: |
American folk group from San Francisco, one of the most popular acts
of the late 1950s and early '60s and a catalyst for the folk revival.
Hits include "Tom Dooley" and "Where Have All the Flowers Gone."
images: []
shows:
- caption: "Oct 1977"
slug: "1977-oct"
sides:
- id: a
label: "Side A"
source: "The Kingston Trio - October 1977 (Side A).wav"
- id: b
label: "Side B"
source: "The Kingston Trio - October 1977 (Side B).wav"
- id: 33
name: "The Temptations & Honey Cone"
slug: temptations-honey-cone
description: |
Motown soul giants The Temptations ("My Girl," "Just My Imagination")
shared this bill with Honey Cone, the trio whose 1971 single
"Want Ads" topped the Billboard Hot 100.
images:
- "TheTemptations002_74.jpg"
shows:
- caption: "Dec 30, 1972"
slug: "1972-dec"
sides:
- id: a
label: "Side A"
source: "The Temptations_Honeycone - December 30th 1972 (Side A).wav"
- id: b
label: "Side B"
source: "The Temptations_Honeycone - December 30th 1972 (Side B).wav"
- id: 34
name: "Tom Jones"
slug: tom-jones
description: |
Welsh singer with a powerhouse baritone who broke through with "It's
Not Unusual" in 1965 and went on to build a decades-long Las Vegas
residency career. His variety show This Is Tom Jones aired from 1969
to 1971.
images:
- "TomJones005_74.jpg"
- "Tom Jones redo 008 1976.jpg"
shows:
- caption: "1975"
slug: "1975"
sides:
- id: a
label: "Side A"
source: "Tom Jones - 1975 (1st Half) (1st Show Sat) (Side A) (No Side B).wav"
- caption: "Sep 1976"
slug: "1976-sep"
sides:
- id: a
label: "Side A"
source: "Tom Jones - September 1976 (Side A).wav"
- id: b
label: "Side B"
source: "Tom Jones - September 1976 (Side B).wav"
- id: 35
name: "Vic Damone"
slug: vic-damone
description: |
American singer whose smooth baritone made him a fixture on television
variety shows and a Las Vegas mainstay. Hits include "You're Breaking
My Heart" and "On the Street Where You Live."
images:
- "VicDamone001_74 HQ.jpg"
shows:
- caption: "Dec 1974"
slug: "1974-dec"
sides:
- id: a
label: "Side A"
source: "Vic Damone - December 1974 (Side A).wav"
- id: b
label: "Side B"
source: "Vic Damone - December 1974 (Side B).wav"
- id: 36
name: "Vikki Carr"
slug: vikki-carr
description: |
American singer of Mexican-American heritage who scored a pop hit with
"It Must Be Him" in 1967 before pivoting to a Spanish-language career
that earned her multiple Grammy and Latin Grammy awards.
images:
- "Vikki Carr 004 1976.jpg"
- "Vikki Carr 006 1975.jpg"
shows:
- caption: "1975"
slug: "1975"
sides:
- id: a
label: "Side A"
source: "Vikki Carr - 1975 (Side A).wav"
- id: b
label: "Side B"
source: "Vikki Carr - 1975 (Side B).wav"
- caption: "May 1975"
slug: "1975-may"
sides:
- id: a
label: "Side A"
source: "Vikki Carr - Sunday May 1975 (Second Half) (Red by WJT) (Side A).wav"
- id: b
label: "Side B"
source: "Vikki Carr - Sunday May 1975 (Second Half) (Red by WJT) (Side B).wav"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

View file

@ -1,324 +1,30 @@
# Source of truth for exhibit content. # Source of truth for exhibit content.
# A build step generates web/src/lib/stops.json from this file (and # A build step generates web/src/lib/stops.json from this file.
# transcodes content/audio/*.wav → web/static/audio/*.opus, similarly for images).
# #
# Each stop: # Each stop has:
# id — integer, used in URLs and as the asset basename prefix # id — integer, used in URLs and as the asset basename
# title — performer(s) as printed on the exhibit # title — short label, shown in the grid and on the detail page
# audio — source filename in content/audio/ # audio — filename in content/audio/, transcoded to web/static/audio/
# image — source filename in content/images/ # image — filename in content/images/, transcoded to web/static/images/
# caption — short line shown beneath the title (date / show info) # caption — short line shown beneath the title
# description — longer text shown below the audio player (markdown allowed) # description — longer text shown below the audio player (markdown allowed)
exhibit: exhibit:
title: "Mill Run Playhouse-Theatre" title: "TBD"
subtitle: "" subtitle: "TBD"
stops: stops:
- id: 1 - id: 1
title: "Al Martino" title: "Welcome"
audio: 01-al-martino.wav audio: 01-welcome.wav
image: 01-al-martino.png image: 01-welcome.png
caption: "1974" caption: "An introduction to the exhibit."
description: "" description: |
Placeholder for the full description shown on the stop detail page.
- id: 2 - id: 2
title: "Caterina Valente" title: "Stop two placeholder"
audio: 02-caterina-valente.wav audio: 02.wav
image: 02-caterina-valente.png image: 02.png
caption: "Aug 1976" caption: ""
description: ""
- id: 3
title: "Dionne Warwicke"
audio: 03-dionne-warwicke.wav
image: 03-dionne-warwicke.png
caption: "1974"
description: ""
- id: 4
title: "Don Ho"
audio: 04-don-ho.wav
image: 04-don-ho.png
caption: "Jun 26, 1977"
description: ""
- id: 5
title: "Don Rickles"
audio: 05-don-rickles.wav
image: 05-don-rickles.png
caption: "May 25, 1975"
description: ""
- id: 6
title: "Eddy Arnold"
audio: 06-eddy-arnold.wav
image: 06-eddy-arnold.png
caption: "1975"
description: ""
- id: 7
title: "Engelbert Humperdinck"
audio: 07-engelbert-humperdinck.wav
image: 07-engelbert-humperdinck.png
caption: "Oct 1975"
description: ""
- id: 8
title: "Frankie Laine"
audio: 08-frankie-laine.wav
image: 08-frankie-laine.png
caption: "Sun, Sep 1975 — 2nd show"
description: ""
- id: 9
title: "Gladys Knight"
audio: 09-gladys-knight-aug-1977.wav
image: 09-gladys-knight-aug-1977.png
caption: "Sat, Aug 1977 — 1st & 2nd show"
description: ""
- id: 10
title: "Gladys Knight"
audio: 10-gladys-knight-oct-1975-fri.wav
image: 10-gladys-knight-oct-1975-fri.png
caption: "Fri, Oct 1975 — 2nd show"
description: ""
- id: 11
title: "Gladys Knight"
audio: 11-gladys-knight-oct-1975-sat.wav
image: 11-gladys-knight-oct-1975-sat.png
caption: "Sat, Oct 1975 — 2nd show"
description: ""
- id: 12
title: "Glenn Campbell & Jud Strunk"
audio: 12-glenn-campbell-jud-strunk.wav
image: 12-glenn-campbell-jud-strunk.png
caption: "Dec 1973"
description: ""
- id: 13
title: "Glenn Yarbrough & The Limeliters"
audio: 13-glenn-yarbrough-limeliters.wav
image: 13-glenn-yarbrough-limeliters.png
caption: "Oct 1977"
description: ""
- id: 14
title: "Harry Belafonte"
audio: 14-harry-belafonte.wav
image: 14-harry-belafonte.png
caption: "May 1976"
description: ""
- id: 15
title: "Helen Reddy"
audio: 15-helen-reddy.wav
image: 15-helen-reddy.png
caption: "1974"
description: ""
- id: 16
title: "Henderson & Nabors"
audio: 16-henderson-nabors.wav
image: 16-henderson-nabors.png
caption: "Jul 1975"
description: ""
- id: 17
title: "Hues Corp & 5th Dimension"
audio: 17-hues-corp-5th-dimension.wav
image: 17-hues-corp-5th-dimension.png
caption: "1974"
description: ""
- id: 18
title: "John Davidson"
audio: 18-john-davidson.wav
image: 18-john-davidson.png
caption: "1974"
description: ""
- id: 19
title: "Johnny Mathis"
audio: 19-johnny-mathis-aug-1975.wav
image: 19-johnny-mathis-aug-1975.png
caption: "Sun, Aug 1975 — 1st show"
description: ""
- id: 20
title: "Johnny Mathis"
audio: 20-johnny-mathis-aug-1976.wav
image: 20-johnny-mathis-aug-1976.png
caption: "Aug 1976"
description: ""
- id: 21
title: "Kate Smith"
audio: 21-kate-smith.wav
image: 21-kate-smith.png
caption: "Nov 1975"
description: ""
- id: 22
title: "Lainie Kazan"
audio: 22-lainie-kazan.wav
image: 22-lainie-kazan.png
caption: "Aug 1975"
description: ""
- id: 23
title: "Love Machine"
audio: 23-love-machine.wav
image: 23-love-machine.png
caption: "Oct 1976"
description: ""
- id: 24
title: "Martin & Charisse"
audio: 24-martin-charisse.wav
image: 24-martin-charisse.png
caption: "Aug 1975"
description: ""
- id: 25
title: "Mill Bros"
audio: 25-mill-bros.wav
image: 25-mill-bros.png
caption: "Sep 1975"
description: ""
- id: 26
title: "Mitzi Gaynor"
audio: 26-mitzi-gaynor.wav
image: 26-mitzi-gaynor.png
caption: "Oct 2, 1977"
description: ""
- id: 27
title: "Robert Goulet"
audio: 27-robert-goulet.wav
image: 27-robert-goulet.png
caption: "Oct 1976"
description: ""
- id: 28
title: "Goulet & Foster Brooks"
audio: 28-goulet-foster-brooks.wav
image: 28-goulet-foster-brooks.png
caption: "Jun 1975"
description: ""
- id: 29
title: "Roy Clark"
audio: 29-roy-clark.wav
image: 29-roy-clark.png
caption: "Apr 1976"
description: ""
- id: 30
title: "Sammy Davis"
audio: 30-sammy-davis-1975.wav
image: 30-sammy-davis-1975.png
caption: "1975"
description: ""
- id: 31
title: "Sammy Davis Jr"
audio: 31-sammy-davis-jr-1976.wav
image: 31-sammy-davis-jr-1976.png
caption: "Nov 1976"
description: ""
- id: 32
title: "Sandler & Young"
audio: 32-sandler-young.wav
image: 32-sandler-young.png
caption: "Dec 1975"
description: ""
- id: 33
title: "Shirley Bassey"
audio: 33-shirley-bassey.wav
image: 33-shirley-bassey.png
caption: "Sep 1976"
description: ""
- id: 34
title: "Steve Lawrence & Eydie Gorme"
audio: 34-steve-lawrence-eydie-gorme.wav
image: 34-steve-lawrence-eydie-gorme.png
caption: "Aug 1977"
description: ""
- id: 35
title: "The Dells"
audio: 35-the-dells.wav
image: 35-the-dells.png
caption: "1975"
description: ""
- id: 36
title: "The Irish Rovers"
audio: 36-irish-rovers-jul16.wav
image: 36-irish-rovers-jul16.png
caption: "Jul 16, 1977 — 1st show"
description: ""
- id: 37
title: "The Irish Rovers"
audio: 37-irish-rovers-jul17.wav
image: 37-irish-rovers-jul17.png
caption: "Jul 17, 1977 — 2nd show"
description: ""
- id: 38
title: "The Kingston Trio"
audio: 38-kingston-trio.wav
image: 38-kingston-trio.png
caption: "Oct 1977"
description: ""
- id: 39
title: "The Temptations"
audio: 39-temptations.wav
image: 39-temptations.png
caption: "Dec 30, 1972"
description: ""
- id: 40
title: "Tom Jones"
audio: 40-tom-jones-1975.wav
image: 40-tom-jones-1975.png
caption: "Sat, 1975 — 1st show"
description: ""
- id: 41
title: "Tom Jones"
audio: 41-tom-jones-1976.wav
image: 41-tom-jones-1976.png
caption: "Sep 1976"
description: ""
- id: 42
title: "Vic Damone"
audio: 42-vic-damone.wav
image: 42-vic-damone.png
caption: "Dec 1974"
description: ""
- id: 43
title: "Vikki Carr"
audio: 43-vikki-carr-1975.wav
image: 43-vikki-carr-1975.png
caption: "1975"
description: ""
- id: 44
title: "Vikki Carr"
audio: 44-vikki-carr-may-1975.wav
image: 44-vikki-carr-may-1975.png
caption: "Sun, May 1975"
description: "" description: ""

View file

@ -21,7 +21,6 @@
ffmpeg ffmpeg
imagemagick imagemagick
yq-go yq-go
jq
]; ];
}; };
} }

View file

@ -16,6 +16,7 @@ import (
type server struct { type server struct {
staticDir string staticDir string
password string password string
qrKey string
secret []byte secret []byte
secure bool secure bool
loginLimiter *rateLimiter loginLimiter *rateLimiter
@ -25,13 +26,20 @@ func main() {
addr := flag.String("addr", ":8181", "listen address") addr := flag.String("addr", ":8181", "listen address")
staticDir := flag.String("static", "../web/build", "static asset directory") staticDir := flag.String("static", "../web/build", "static asset directory")
insecure := flag.Bool("insecure", false, "drop Secure flag from cookie (dev only)") insecure := flag.Bool("insecure", false, "drop Secure flag from cookie (dev only)")
strict := flag.Bool("strict", false, "production mode: refuse to start without DOCENT_COOKIE_SECRET") strict := flag.Bool("strict", false, "production mode: refuse to start without DOCENT_COOKIE_SECRET and DOCENT_QR_KEY")
flag.Parse() flag.Parse()
password := os.Getenv("DOCENT_PASSWORD") password := os.Getenv("DOCENT_PASSWORD")
if password == "" { if password == "" {
log.Fatal("DOCENT_PASSWORD must be set") log.Fatal("DOCENT_PASSWORD must be set")
} }
qrKey := os.Getenv("DOCENT_QR_KEY")
if qrKey == "" {
if *strict {
log.Fatal("DOCENT_QR_KEY must be set in -strict mode")
}
log.Println("warning: DOCENT_QR_KEY not set; QR-code auto-login disabled")
}
cookieSecret := os.Getenv("DOCENT_COOKIE_SECRET") cookieSecret := os.Getenv("DOCENT_COOKIE_SECRET")
if *strict && cookieSecret == "" { if *strict && cookieSecret == "" {
@ -53,6 +61,7 @@ func main() {
s := &server{ s := &server{
staticDir: abs, staticDir: abs,
password: password, password: password,
qrKey: qrKey,
secret: secret, secret: secret,
secure: !*insecure, secure: !*insecure,
loginLimiter: newRateLimiter(8, time.Minute), loginLimiter: newRateLimiter(8, time.Minute),
@ -60,6 +69,7 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleLogin) mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("POST /api/key", s.handleKey)
mux.HandleFunc("POST /api/logout", s.handleLogout) mux.HandleFunc("POST /api/logout", s.handleLogout)
mux.HandleFunc("GET /api/me", s.handleMe) mux.HandleFunc("GET /api/me", s.handleMe)
mux.Handle("GET /audio/", s.requireAuth(http.HandlerFunc(s.serveStatic))) mux.Handle("GET /audio/", s.requireAuth(http.HandlerFunc(s.serveStatic)))
@ -116,6 +126,26 @@ func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (s *server) handleKey(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.allow(clientIP(r)) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
var body struct {
Key string `json:"key"`
}
err := json.NewDecoder(r.Body).Decode(&body)
// An empty qrKey means the QR auto-login is disabled — never accept any value.
if err != nil || s.qrKey == "" || !constantTimeEqualString(body.Key, s.qrKey) {
time.Sleep(250 * time.Millisecond)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.setSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.clearSessionCookie(w) s.clearSessionCookie(w)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@ -177,6 +207,7 @@ func (s *server) serveSPA(w http.ResponseWriter, r *http.Request) {
return return
} }
// Unknown path → SPA fallback.
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html")) http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html"))
} }

62
web/package-lock.json generated
View file

@ -7,14 +7,6 @@
"": { "": {
"name": "docent-web", "name": "docent-web",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"@fontsource/bebas-neue": "^5.1.0",
"@fontsource/cantarell": "^5.1.0",
"@fontsource/cormorant-garamond": "^5.1.0",
"@fontsource/inspiration": "^5.1.0",
"@fontsource/oswald": "^5.1.0",
"@fontsource/playwrite-de-sas": "^5.1.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.5", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.7.0", "@sveltejs/kit": "^2.7.0",
@ -416,60 +408,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fontsource/bebas-neue": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.2.7.tgz",
"integrity": "sha512-DsmBrmq55d9BCU0mt4DT4RZDdH8vhWRKEUOfbuNB1EEjMuwbtFvM8N+3gIlkYSFbsb10P8Q19BV5OdpMu2h0fA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/cantarell": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/cantarell/-/cantarell-5.2.8.tgz",
"integrity": "sha512-MQKwrtpAmd5disYpoTcxliqQjzn/5msazpycrS57ppNNV8HKDP3RYpVTcEw8nYvWVoXwqSOkU2/RHmo3VqckkQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/cormorant-garamond": {
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/@fontsource/cormorant-garamond/-/cormorant-garamond-5.2.11.tgz",
"integrity": "sha512-5JjpN023lhA5soijgVT0BdRGzmlijm402ppjccMd6h+vRE0mX2lJnE+41UPfnlidrkV9/rCo1mf58WZlHnB0CA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inspiration": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/inspiration/-/inspiration-5.2.7.tgz",
"integrity": "sha512-Unh3kgWHn024288t5u8EiBgqR6i+PdHNCjrvPIhgCRS02XlgwoXQeWzUPCknWY/5WRsGJUsu3sJxo+0sMM7esQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/oswald": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/oswald/-/oswald-5.2.8.tgz",
"integrity": "sha512-DFoPK1BqsFIQhVjs2xgO/oL9veF9oUJgFojEWIU+nazUrfqsBDtAMI4n9L9CZFX3kQNDLcCyzKx1xoEDvQT4WQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/playwrite-de-sas": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/playwrite-de-sas/-/playwrite-de-sas-5.2.7.tgz",
"integrity": "sha512-ELyLve5ZbiEm3Ra2itvO4wjlqrjS6G/6SYTh7K8Mdbi/Ib/PEjZcyCzZChfw0lCx62AJWk5HYvGeXqS6KVjPaA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",

View file

@ -9,14 +9,6 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
}, },
"dependencies": {
"@fontsource/bebas-neue": "^5.1.0",
"@fontsource/cantarell": "^5.1.0",
"@fontsource/cormorant-garamond": "^5.1.0",
"@fontsource/inspiration": "^5.1.0",
"@fontsource/oswald": "^5.1.0",
"@fontsource/playwrite-de-sas": "^5.1.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.5", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.7.0", "@sveltejs/kit": "^2.7.0",

View file

@ -1,54 +1,16 @@
@import '@fontsource/playwrite-de-sas/100.css';
@import '@fontsource/cormorant-garamond/400.css';
@import '@fontsource/cormorant-garamond/400-italic.css';
@import '@fontsource/cormorant-garamond/600.css';
@import '@fontsource/cantarell/400.css';
@import '@fontsource/cantarell/700.css';
@import '@fontsource/bebas-neue/400.css';
@import '@fontsource/oswald/400.css';
@import '@fontsource/oswald/500.css';
/* Inspiration is used only for the uppercase letters in script titles
* (matches the brand treatment where the leading caps are extra-flourished).
* The unicode-range restriction means the browser falls through to
* Playwrite DK Loopet for everything else automatically no markup spans.
*/
@font-face {
font-family: 'Inspiration Caps';
font-style: normal;
/* Inspiration is only published at weight 400; declare a wide range here
* so this face matches whatever weight the title-script class requests. */
font-weight: 100 900;
font-display: swap;
src:
url('@fontsource/inspiration/files/inspiration-latin-400-normal.woff2') format('woff2'),
url('@fontsource/inspiration/files/inspiration-latin-400-normal.woff') format('woff');
unicode-range: U+0041-005A;
}
:root { :root {
/* Mill Run brand palette */ --bg: #faf7f2;
--bg: #b9cfba; /* sage — main background */ --bg-elev: #ffffff;
--bg-card: #ffffff; /* card / tile background on the sage page */ --ink: #2a2520;
--bg-callout: #4b6c64; /* deep teal — callout panels */ --ink-mute: #6b6157;
--bg-paper: #f6ecd6; /* warm ivory — Stagebill program paper */ --accent: #6e3a1f;
--ink: #4b6c64; /* primary body text */ --rule: #e8e0d4;
--ink-on-callout: #b9cfba; /* text on dark teal callouts */
--ink-mute: #6b8a82; /* secondary teal for de-emphasis */
--ink-deep: #11281f; /* very dark green for high contrast on paper/light bg */
--title: #c33b23; /* red-orange — titles, focus, errors (darkened from #e1442b) */
--highlight: #f1b416; /* yellow — accents, scrub thumb, indicators */
--rule: #9bb59c; /* darker sage — borders/dividers */
--shadow: 0 1px 2px rgba(75, 108, 100, 0.12), 0 8px 24px rgba(75, 108, 100, 0.18);
--radius: 14px; --radius: 14px;
--radius-lg: 22px; --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-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-serif: ui-serif, Georgia, 'Times New Roman', serif; --font-serif: ui-serif, Georgia, 'Times New Roman', serif;
--font-script: 'Inspiration Caps', 'Playwrite DE SAS', cursive;
--font-marquee: 'Bebas Neue', 'Anton', 'Oswald', 'Arial Narrow', sans-serif;
--font-editorial: 'Cormorant Garamond', 'Iowan Old Style', Cambria, Georgia, serif;
color-scheme: light; color-scheme: light;
} }
@ -79,18 +41,14 @@ body {
} }
h1, h2, h3 { h1, h2, h3 {
font-weight: 600;
letter-spacing: -0.005em;
margin: 0;
color: var(--title);
}
h2, h3 {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
} }
a { a {
color: var(--title); color: var(--accent);
text-decoration: none; text-decoration: none;
} }
@ -112,101 +70,3 @@ img {
display: block; display: block;
max-width: 100%; max-width: 100%;
} }
/* Title-style headings (the script-font, brand-red ones).
* Apply via `.title-script` to any heading we want to render in the
* exhibit's primary script-and-red treatment.
*/
.title-script {
font-family: var(--font-script);
color: var(--title);
font-weight: 100;
line-height: 1.25;
letter-spacing: 0;
/* Playwrite has long ascenders/descenders; allow them to breathe. */
padding: 0.1em 0.05em;
}
/* Marquee letter-board: white sign with bold black changeable-letter text.
* Modeled on the actual Mill Run marquee from the 1970s. */
.marquee-panel {
position: relative;
display: inline-block;
max-width: 100%;
color: #111111;
/* Scale side padding with viewport so phone screens aren't doubly cramped. */
padding: clamp(1.5rem, 5vw, 2.5rem) clamp(1.25rem, 5vw, 3rem) clamp(1.75rem, 5vw, 2.75rem);
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ffffff;
box-shadow:
0 1px 0 rgba(0, 0, 0, 0.08) inset,
0 6px 24px rgba(75, 108, 100, 0.18);
text-align: center;
}
/* Bulb ring: a single stroked rect with `stroke-dasharray: 0 N` and round
* caps renders evenly-spaced circular dots that traverse the corners cleanly. */
.marquee-panel .bulbs {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.marquee-panel .bulbs rect {
x: 11px;
y: 11px;
width: calc(100% - 22px);
height: calc(100% - 22px);
rx: 3px;
fill: none;
stroke: var(--highlight);
stroke-width: 7;
/* pathLength is set on each rect in the template; dasharray "0 2"
* over a path of length N places N/2 evenly-spaced dots, so the first
* and last align exactly with no leftover corner gap. */
stroke-dasharray: 0 2;
stroke-linecap: round;
}
.title-marquee {
font-family: var(--font-marquee);
color: var(--title);
font-weight: 400;
line-height: 0.95;
letter-spacing: 0.04em;
text-transform: uppercase;
margin: 0;
text-align: center;
}
.marquee-body {
font-family: var(--font-marquee);
color: #111111;
font-weight: 400;
font-size: clamp(0.95rem, 2.4vw, 1.25rem);
line-height: 1.15;
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0.4rem 0 0;
text-align: center;
}
.marquee-body + .marquee-body {
margin-top: 0.25rem;
}
/* The yellow heading band that sits behind a script title.
* `max-width: 100%` keeps it inside narrow containers (login card on phones).
* `overflow-wrap: break-word` lets long titles wrap rather than overflow.
*/
.band {
display: inline-block;
max-width: 100%;
padding: 2rem 3.25rem 2.25rem;
background: var(--highlight);
border-radius: 2.5rem;
box-shadow: 0 1px 0 rgba(75, 108, 100, 0.08);
overflow-wrap: break-word;
hyphens: auto;
}

View file

@ -11,7 +11,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/favicon.png" type="image/png" /> <link rel="icon" href="/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/icon-192.png" /> <link rel="apple-touch-icon" href="/icon-192.png" />
<title>Mill Run Theatre</title> <title>Mill Run</title>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

File diff suppressed because it is too large Load diff

View file

@ -1,35 +0,0 @@
import data from './artists.json';
export type Side = {
id: string;
label: string;
audio: string;
};
export type Show = {
caption: string;
slug: string;
sides: Side[];
};
export type Artist = {
id: number;
name: string;
slug: string;
description: string;
images: string[];
shows: Show[];
};
export type Exhibit = {
title: string;
description?: string;
subtitle?: string;
};
export const exhibit: Exhibit = data.exhibit;
export const artists: Artist[] = data.artists;
export function artistById(id: number): Artist | undefined {
return artists.find((a) => a.id === id);
}

View file

@ -38,8 +38,35 @@ export async function login(password: string): Promise<boolean> {
return false; 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() { export async function logout() {
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' }); await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
state = 'signed-out'; state = 'signed-out';
goto('/login'); 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

@ -1,21 +0,0 @@
import { browser } from '$app/environment';
// Becomes true once all custom fonts have loaded. Components that contain
// SVG paths whose geometry depends on element size (e.g. the marquee bulb
// ring) can key off this so they remount after the font swap finishes
// resizing the panel.
let ready = $state(false);
if (browser) {
if ('fonts' in document && typeof document.fonts.ready?.then === 'function') {
document.fonts.ready.then(() => {
ready = true;
});
} else {
ready = true;
}
}
export function fontsReady(): boolean {
return ready;
}

View file

@ -1,20 +1,16 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { artists } from './artists'; import { stops } from './stops';
export type PrecacheState = 'idle' | 'running' | 'complete' | 'unsupported'; export type PrecacheState = 'idle' | 'running' | 'complete' | 'unsupported';
let state: PrecacheState = $state('idle'); let state: PrecacheState = $state('idle');
let done = $state(0); let done = $state(0);
let total = $state(0); let total = $state(0);
let failed = $state(0);
export function precacheState() { export function precacheState() {
return { state, done, total, failed }; return { state, done, total };
} }
// Precache every image and every audio side. ~1.6 GB at current Opus
// bitrate. Designed for the museum's single-kiosk tablet on a flaky wifi:
// staff hit the page once at setup; visitors thereafter hit cache only.
export async function precacheAll(): Promise<void> { export async function precacheAll(): Promise<void> {
if (!browser) return; if (!browser) return;
if (!('serviceWorker' in navigator)) { if (!('serviceWorker' in navigator)) {
@ -30,15 +26,7 @@ export async function precacheAll(): Promise<void> {
return; return;
} }
const urls = artists.flatMap((a) => [ const urls = stops.flatMap((s) => [s.audio, s.image]);
...a.images,
...a.shows.flatMap((s) => s.sides.map((side) => side.audio))
]);
if (urls.length === 0) {
state = 'complete';
return;
}
state = 'running'; state = 'running';
done = 0; done = 0;
@ -50,16 +38,14 @@ export async function precacheAll(): Promise<void> {
if (data.type === 'precache-progress') { if (data.type === 'precache-progress') {
done = data.done; done = data.done;
total = data.total; total = data.total;
failed = data.failed ?? 0;
} else if (data.type === 'precache-done') { } else if (data.type === 'precache-done') {
done = data.done; done = data.done;
total = data.total; total = data.total;
failed = data.failed ?? 0;
state = 'complete'; state = 'complete';
navigator.serviceWorker.removeEventListener('message', onMessage); navigator.serviceWorker.removeEventListener('message', onMessage);
} }
}; };
navigator.serviceWorker.addEventListener('message', onMessage); navigator.serviceWorker.addEventListener('message', onMessage);
target.postMessage({ type: 'precache-artists', urls }); 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

@ -4,7 +4,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { authState, checkAuth } from '$lib/auth.svelte'; import { authState, checkAuth, consumeKeyParam } from '$lib/auth.svelte';
import { precacheAll } from '$lib/precache.svelte'; import { precacheAll } from '$lib/precache.svelte';
let { children } = $props(); let { children } = $props();
@ -24,14 +24,18 @@
} }
} }
await checkAuth(); const keyResult = await consumeKeyParam();
if (keyResult !== 'success') await checkAuth();
const path = $page.url.pathname; const path = $page.url.pathname;
if (authState() === 'signed-out' && path !== '/login') { if (authState() === 'signed-out' && path !== '/login') {
const params = new URLSearchParams({ next: path }); const params = new URLSearchParams({ next: path });
if (keyResult === 'failed') params.set('error', 'qr');
await goto('/login?' + params.toString(), { replaceState: true }); 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') { if (!dev && authState() === 'signed-in') {
precacheAll().catch(() => undefined); precacheAll().catch(() => undefined);
} }
@ -59,7 +63,7 @@
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--rule); border: 2px solid var(--rule);
border-top-color: var(--title); border-top-color: var(--accent);
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin { @keyframes spin {

View file

@ -1,118 +1,63 @@
<script lang="ts"> <script lang="ts">
import { exhibit, artists, type Artist } from '$lib/artists'; import { exhibit, stops } from '$lib/stops';
import { precacheState } from '$lib/precache.svelte'; import { precacheState } from '$lib/precache.svelte';
import { fontsReady } from '$lib/fonts.svelte';
const pre = $derived(precacheState()); const pre = $derived(precacheState());
function artistYears(artist: Artist): string {
const years = new Set<string>();
for (const show of artist.shows) {
const m = show.caption.match(/\b(19|20)\d{2}\b/);
if (m) years.add(m[0]);
}
return [...years].sort().join(', ');
}
</script> </script>
<header class="hero"> <header class="hero">
<div class="marquee-panel"> <h1>{exhibit.title}</h1>
{#key fontsReady()} {#if exhibit.subtitle}
<svg class="bulbs" width="100%" height="100%" aria-hidden="true"><rect pathLength="120" /></svg> <p class="subtitle">{exhibit.subtitle}</p>
{/key} {/if}
<h1 class="title-marquee">{exhibit.title}</h1>
{#if exhibit.description}
{#each exhibit.description.split('\n').filter(Boolean) as line}
<p class="marquee-body">{line}</p>
{/each}
{/if}
</div>
</header> </header>
{#if pre.state === 'running' || (pre.state === 'complete' && pre.failed > 0)} {#if pre.state === 'running'}
<div class="precache" role="status" aria-live="polite"> <div class="precache" role="status" aria-live="polite">
{#if pre.state === 'running'} <span class="dot" aria-hidden="true"></span>
<span class="dot" aria-hidden="true"></span> <span class="text">
<span class="text"> Loading exhibit for offline listening… {pre.done}/{pre.total}
Caching exhibit for offline listening… {pre.done} / {pre.total} </span>
{#if pre.failed > 0}({pre.failed} retrying){/if}
</span>
<div class="bar" aria-hidden="true">
<div class="bar-fill" style="width: {pre.total ? (pre.done / pre.total) * 100 : 0}%"></div>
</div>
{:else}
<span class="text">
{pre.total - pre.failed} of {pre.total} files cached. {pre.failed}
failed to download — they will stream over the network when played.
</span>
{/if}
</div> </div>
{/if} {/if}
<main class="grid" aria-label="Artists"> <main class="grid" aria-label="Exhibit stops">
{#each artists as artist (artist.id)} {#each stops as stop (stop.id)}
{@const cover = artist.images[0]} <a class="tile" href="/stop/{stop.id}" data-sveltekit-preload-data="hover">
<a
class="tile"
href="/artist/{artist.id}"
data-sveltekit-preload-data="hover"
aria-label={artist.name}
>
<div class="tile-image"> <div class="tile-image">
{#if cover} <img src={stop.image} alt="" loading="lazy" />
<img src={cover} alt="" loading="lazy" />
{:else}
<div class="tile-image-placeholder" aria-hidden="true"></div>
{/if}
</div> </div>
<div class="tile-meta"> <div class="tile-meta">
<span class="tile-name">{artist.name}</span> <span class="tile-num">{String(stop.id).padStart(2, '0')}</span>
<span class="tile-shows">{artistYears(artist)}</span> <span class="tile-title">{stop.title}</span>
</div> </div>
</a> </a>
{/each} {/each}
</main> </main>
<style> <style>
.hero { .hero {
padding: 1.5rem 1rem 1.5rem; padding: 2rem 1.25rem 1rem;
text-align: center; text-align: center;
} }
.precache { .precache {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
margin: 0 auto 0.75rem; margin: 0 auto 0.5rem;
padding: 0.6rem 1rem; padding: 0.5rem 1rem;
max-width: 32rem; max-width: 32rem;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--ink-on-callout); color: var(--ink-mute);
background: var(--bg-callout); background: var(--bg-elev);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.precache .text {
flex: 1 1 auto;
}
.precache .bar {
flex: 1 0 100%;
height: 4px;
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
overflow: hidden;
}
.precache .bar-fill {
height: 100%;
background: var(--highlight);
transition: width 0.2s linear;
}
.precache .dot { .precache .dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--highlight); background: var(--accent);
animation: pulse 1.4s ease-in-out infinite; animation: pulse 1.4s ease-in-out infinite;
flex-shrink: 0; flex-shrink: 0;
} }
@ -123,8 +68,12 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.precache .dot { animation: none; opacity: 0.7; } .precache .dot { animation: none; opacity: 0.7; }
} }
.hero :global(.title-marquee) { .hero h1 {
font-size: clamp(2.25rem, 9vw, 4rem); font-size: clamp(2rem, 5vw, 3rem);
}
.subtitle {
margin: 0.5rem 0 0;
color: var(--ink-mute);
} }
.grid { .grid {
display: grid; display: grid;
@ -143,9 +92,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
color: var(--ink); color: inherit;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--bg-card); background: var(--bg-elev);
box-shadow: var(--shadow); box-shadow: var(--shadow);
overflow: hidden; overflow: hidden;
transition: transform 0.15s ease; transition: transform 0.15s ease;
@ -154,7 +103,6 @@
transform: scale(0.98); transform: scale(0.98);
} }
.tile-image { .tile-image {
position: relative;
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
background: var(--rule); background: var(--rule);
overflow: hidden; overflow: hidden;
@ -163,43 +111,23 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: center top;
}
.tile-image-placeholder {
width: 100%;
height: 100%;
background:
repeating-linear-gradient(
135deg,
transparent 0,
transparent 18px,
rgba(75, 108, 100, 0.08) 18px,
rgba(75, 108, 100, 0.08) 19px
),
var(--rule);
} }
.tile-meta { .tile-meta {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
gap: 0.15rem; gap: 0.6rem;
padding: 0.25rem 1rem 1rem; padding: 0.25rem 1rem 1rem;
} }
.tile-name { .tile-num {
font-family: var(--font-marquee); font-family: var(--font-serif);
font-weight: 400; font-size: 0.95rem;
font-size: 1.35rem; color: var(--ink-mute);
line-height: 1.05;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #111111;
}
.tile-shows {
font-family: var(--font-marquee);
font-size: 0.9rem;
line-height: 1.1;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #111111;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.tile-title {
font-family: var(--font-serif);
font-weight: 600;
font-size: 1.1rem;
line-height: 1.2;
}
</style> </style>

View file

@ -1,312 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { artists, artistById } from '$lib/artists';
import { fontsReady } from '$lib/fonts.svelte';
import SidePlayer from './SidePlayer.svelte';
const id = $derived(Number($page.params.id));
const artist = $derived(artistById(id));
const index = $derived(artists.findIndex((a) => a.id === id));
const prev = $derived(index > 0 ? artists[index - 1] : null);
const next = $derived(index >= 0 && index < artists.length - 1 ? artists[index + 1] : null);
</script>
{#if !artist}
<main class="missing">
<p>Artist not found.</p>
<a href="/">Back to all artists</a>
</main>
{:else}
<header class="bar">
<a class="home" href="/" aria-label="Back to all artists">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M3 11l9-8 9 8v10a1 1 0 0 1-1 1h-5v-7h-6v7H4a1 1 0 0 1-1-1z"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
<span class="home-label">Back to All Artists</span>
</a>
</header>
<main class="artist">
<div class="marquee-panel artist-name">
{#key `${artist.id}-${fontsReady()}`}
<svg class="bulbs" width="100%" height="100%" aria-hidden="true"><rect pathLength="96" /></svg>
{/key}
<h1 class="title-marquee">{artist.name}</h1>
</div>
{#if artist.images.length > 0 || artist.description}
<div
class="artist-info"
class:side-by-side={artist.images.length === 1 && !!artist.description}
>
{#if artist.images.length > 0}
<div class="images" class:single={artist.images.length === 1}>
{#each artist.images as src, i (src)}
<figure>
<img src={src} alt="" loading={i === 0 ? 'eager' : 'lazy'} />
</figure>
{/each}
</div>
{/if}
{#if artist.description}
<article class="bio">
{#each artist.description.split(/\n\n+/) as para}
<p>{para}</p>
{/each}
</article>
{/if}
</div>
{/if}
<div class="rule" aria-hidden="true"></div>
<section class="shows">
{#each artist.shows as show, showIdx (show.slug)}
<article class="show">
<h2 class="show-caption">{show.caption}</h2>
<div class="sides">
{#each show.sides as side, sideIdx (side.id)}
<SidePlayer
label={side.label}
src={side.audio}
autoplay={showIdx === 0 && sideIdx === 0}
/>
{/each}
</div>
</article>
{/each}
</section>
<p class="format-note">
Digitized from analog tapes in the Niles Historical Society archive.
</p>
</main>
<nav class="pager" aria-label="Artist navigation">
<button class="pager-btn" disabled={!prev} onclick={() => prev && goto(`/artist/${prev.id}`)}>
<span class="arrow"></span>
<span class="label">{prev ? prev.name : ''}</span>
</button>
<button class="pager-btn right" disabled={!next} onclick={() => next && goto(`/artist/${next.id}`)}>
<span class="label">{next ? next.name : ''}</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;
}
.home {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.5rem 0.95rem 0.5rem 0.7rem;
min-height: 44px;
border-radius: 999px;
color: var(--ink);
background: var(--bg-card);
box-shadow: var(--shadow);
font-family: 'Oswald', 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 1rem;
letter-spacing: 0.01em;
}
.home svg {
align-self: baseline;
transform: translateY(2px);
}
.home:hover,
.home:focus-visible {
color: var(--title);
}
.home:focus-visible {
outline: 2px solid var(--title);
outline-offset: 2px;
}
.home-label {
line-height: 1;
}
.artist {
max-width: 760px;
margin: 0 auto;
padding: 0.25rem 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.artist-name {
align-self: center;
margin-top: 0.5rem;
}
.artist-name :global(.title-marquee) {
font-size: clamp(1.75rem, 6vw, 2.75rem);
}
.artist-info {
display: grid;
gap: 1.25rem;
}
@media (min-width: 640px) {
.artist-info.side-by-side {
grid-template-columns: minmax(200px, 2fr) minmax(0, 3fr);
align-items: stretch;
min-height: 280px;
}
/* Inside the side-by-side layout the images already live in a column,
* so stack multi-image sets vertically and drop the single-image centering. */
.artist-info.side-by-side .images {
grid-template-columns: 1fr;
grid-auto-rows: 1fr;
max-width: none;
margin: 0;
width: auto;
height: 100%;
}
.artist-info.side-by-side .images figure {
aspect-ratio: auto;
height: 100%;
}
}
.images {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}
.images.single {
grid-template-columns: 1fr;
max-width: 480px;
margin: 0 auto;
width: 100%;
}
figure {
margin: 0;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--rule);
aspect-ratio: 4 / 3;
box-shadow: var(--shadow);
}
figure img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
}
.bio {
background: var(--bg-paper);
color: #111111;
padding: 1.25rem 1.5rem;
border-radius: var(--radius);
/* Oswald rather than Bebas Neue here — Bebas Neue's Google Fonts
* release ships only uppercase glyphs, so it can't render mixed case. */
font-family: 'Oswald', 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-weight: 400;
font-size: clamp(1.05rem, 2.4vw, 1.3rem);
line-height: 1.4;
letter-spacing: 0.01em;
box-shadow: var(--shadow);
}
.bio p {
margin: 0 0 0.9em;
}
.bio p:last-child {
margin: 0;
}
.rule {
height: 1px;
background: var(--rule);
margin: 0.25rem 0;
}
.shows {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.show-caption {
font-family: var(--font-marquee);
text-transform: uppercase;
letter-spacing: 0.1em;
color: #111111;
font-size: 1.15rem;
font-weight: 400;
line-height: 1.1;
margin: 0 0 0.6rem;
text-align: center;
}
.sides {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.format-note {
font-family: var(--font-editorial);
font-style: italic;
font-size: 0.9rem;
color: var(--ink-mute);
text-align: center;
margin: 0.5rem 0 0;
}
.pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding: 1rem 1.25rem 1.5rem;
max-width: 760px;
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-card);
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>

View file

@ -1,253 +0,0 @@
<script lang="ts">
type Props = {
label: string;
src: string;
autoplay?: boolean;
};
let { label, src, autoplay = false }: Props = $props();
let audio: HTMLAudioElement | undefined = $state();
let paused = $state(true);
let currentTime = $state(0);
let duration = $state(0);
let loading = $state(false);
let errored = $state(false);
let intent = $state(false);
function toggle() {
if (errored) {
retry();
return;
}
if (!audio) return;
if (audio.paused) {
intent = true;
if (audio.readyState < 3) loading = true;
audio.play().catch(() => undefined);
} else {
intent = false;
audio.pause();
}
}
function retry() {
if (!audio) return;
errored = false;
intent = true;
loading = true;
audio.load();
audio.play().catch(() => undefined);
}
function onPlaying() {
loading = false;
// Single-player invariant: pause every other audio element on the page.
if (audio) {
for (const other of document.querySelectorAll('audio')) {
if (other !== audio && !other.paused) other.pause();
}
}
}
$effect(() => {
if (!autoplay || !audio) return;
intent = true;
audio.play().catch(() => { intent = false; });
});
function fmt(s: number) {
if (!isFinite(s) || s < 0) return '0:00';
const total = Math.floor(s);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const sec = total % 60;
return h > 0
? `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
: `${m}:${sec.toString().padStart(2, '0')}`;
}
</script>
<div class="player" class:playing={!paused && !loading && !errored} class:loading class:errored>
<button
type="button"
class="play"
onclick={toggle}
aria-label={errored
? `Tap to retry ${label}`
: loading
? `Loading ${label}`
: paused
? `Play ${label}`
: `Pause ${label}`}
aria-pressed={!paused}
>
{#if errored}
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M12 2L1 21h22L12 2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<path d="M12 10v5M12 17.5v.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
{:else if loading}
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" class="spin">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-opacity="0.25" />
<path d="M21 12a9 9 0 00-9-9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
{:else if paused}
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M8 5l11 7-11 7V5z" fill="currentColor" />
</svg>
{:else}
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M7 5h3v14H7zM14 5h3v14h-3z" fill="currentColor" />
</svg>
{/if}
</button>
<div class="meta">
<span class="label">{label}</span>
<input
type="range"
min="0"
max={duration && isFinite(duration) ? duration : 1}
step="0.1"
bind:value={currentTime}
disabled={!duration || !isFinite(duration)}
aria-label="Seek {label}"
aria-valuetext="{fmt(currentTime)} of {fmt(duration)}"
/>
<span class="time tabular">{fmt(currentTime)} / {fmt(duration)}</span>
</div>
<audio
bind:this={audio}
bind:paused
bind:currentTime
bind:duration
onloadstart={() => { errored = false; }}
oncanplay={() => { loading = false; }}
onwaiting={() => { if (intent) loading = true; }}
onplaying={onPlaying}
onerror={() => { loading = false; errored = true; }}
onended={() => { paused = true; }}
preload="metadata"
{src}
></audio>
</div>
<style>
.player {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.85rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.play {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--ink);
color: #fff;
flex-shrink: 0;
transition: background 0.15s ease;
}
.player.errored .play {
background: var(--title);
}
.play:hover {
background: var(--ink-deep);
}
.play:focus-visible {
outline: 2px solid var(--title);
outline-offset: 3px;
}
.meta {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.label {
font-family: var(--font-serif);
font-size: 0.95rem;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
}
.time {
font-size: 0.8rem;
color: var(--ink-mute);
white-space: nowrap;
}
.tabular {
font-variant-numeric: tabular-nums;
}
input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 5px;
background: var(--rule);
border-radius: 3px;
outline: none;
margin: 0;
min-width: 0;
}
input[type='range']:focus-visible {
outline: 2px solid var(--title);
outline-offset: 3px;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--highlight);
cursor: pointer;
border: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
input[type='range']::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--highlight);
cursor: pointer;
border: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
input[type='range']:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.spin {
animation: none;
}
}
@media (max-width: 480px) {
.meta {
grid-template-columns: 1fr;
}
.meta .label {
grid-column: 1 / -1;
}
.meta .time {
grid-column: 1 / -1;
text-align: right;
}
}
</style>

View file

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { exhibit } from '$lib/artists'; import { exhibit } from '$lib/stops';
import { login } from '$lib/auth.svelte'; import { login } from '$lib/auth.svelte';
import { fontsReady } from '$lib/fonts.svelte';
let password = $state(''); let password = $state('');
let error = $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); let busy = $state(false);
function safeNext(raw: string | null): string { function safeNext(raw: string | null): string {
@ -32,13 +35,8 @@
<main> <main>
<div class="card"> <div class="card">
<div class="marquee-panel"> <h1>{exhibit.title}</h1>
{#key fontsReady()} <p>Enter the password posted at the exhibit to begin.</p>
<svg class="bulbs" width="100%" height="100%" aria-hidden="true"><rect pathLength="96" /></svg>
{/key}
<h1 class="title-marquee">{exhibit.title}</h1>
</div>
<p>Enter the password to begin.</p>
<form onsubmit={submit}> <form onsubmit={submit}>
<label for="pw" class="visually-hidden">Password</label> <label for="pw" class="visually-hidden">Password</label>
<input <input
@ -73,20 +71,18 @@
.card { .card {
width: 100%; width: 100%;
max-width: 26rem; max-width: 26rem;
background: var(--bg-card); background: var(--bg-elev);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: 2rem 1.75rem; padding: 2rem 1.75rem;
text-align: center; text-align: center;
} }
.marquee-panel {
margin: 0 0 1rem;
}
h1 { h1 {
font-size: clamp(1.5rem, 6vw, 2.5rem); font-size: 2rem;
margin-bottom: 0.5rem;
} }
p { p {
color: var(--ink); color: var(--ink-mute);
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
} }
form { form {
@ -103,14 +99,14 @@
text-align: center; text-align: center;
} }
input:focus { input:focus {
outline: 2px solid var(--title); outline: 2px solid var(--accent);
outline-offset: 1px; outline-offset: 1px;
} }
button[type='submit'] { button[type='submit'] {
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-callout); background: var(--ink);
color: var(--ink-on-callout); color: var(--bg);
font-weight: 600; font-weight: 600;
font-size: 1.05rem; font-size: 1.05rem;
} }
@ -119,7 +115,7 @@
cursor: not-allowed; cursor: not-allowed;
} }
.error { .error {
color: var(--title); color: #b53a25;
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
font-size: 0.95rem; font-size: 0.95rem;
} }

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>

View file

@ -14,7 +14,7 @@
// /audio /img → stale-while-revalidate from ASSET_CACHE // /audio /img → stale-while-revalidate from ASSET_CACHE
// everything → cache-first from SHELL_CACHE // everything → cache-first from SHELL_CACHE
// - Messages: // - Messages:
// { type: 'precache-artists', urls: string[] } // { type: 'precache-stops', urls: string[] }
// Bulk-cache a list of URLs into ASSET_CACHE, posting progress back to // Bulk-cache a list of URLs into ASSET_CACHE, posting progress back to
// the sender via { type: 'precache-progress', done, total } and a final // the sender via { type: 'precache-progress', done, total } and a final
// { type: 'precache-done' } when finished. // { type: 'precache-done' } when finished.
@ -72,6 +72,7 @@ sw.addEventListener('fetch', (event) => {
event.respondWith( event.respondWith(
(async () => { (async () => {
// Navigation: prefer cached shell; fall back to network.
if (request.mode === 'navigate') { if (request.mode === 'navigate') {
const shell = await caches.open(SHELL_CACHE); const shell = await caches.open(SHELL_CACHE);
const cached = (await shell.match('/')) || (await shell.match('/index.html')); const cached = (await shell.match('/')) || (await shell.match('/index.html'));
@ -88,6 +89,7 @@ sw.addEventListener('fetch', (event) => {
const cached = await cache.match(request); const cached = await cache.match(request);
if (cached) { if (cached) {
if (isContent) { if (isContent) {
// Stale-while-revalidate: return cached, refresh in background.
event.waitUntil(refresh(cache, request)); event.waitUntil(refresh(cache, request));
} }
return cached; return cached;
@ -117,79 +119,40 @@ async function refresh(cache: Cache, request: Request) {
sw.addEventListener('message', (event) => { sw.addEventListener('message', (event) => {
const data = event.data; const data = event.data;
if (data?.type !== 'precache-artists' || !Array.isArray(data.urls)) return; if (data?.type !== 'precache-stops' || !Array.isArray(data.urls)) return;
const urls: string[] = data.urls; const urls: string[] = data.urls;
const source = event.source; const source = event.source;
event.waitUntil(precacheArtists(urls, source)); event.waitUntil(precacheStops(urls, source));
}); });
async function precacheArtists(urls: string[], source: Client | ServiceWorker | MessagePort | null) { async function precacheStops(urls: string[], source: Client | ServiceWorker | MessagePort | null) {
const cache = await caches.open(ASSET_CACHE); const cache = await caches.open(ASSET_CACHE);
const total = urls.length;
let done = 0; let done = 0;
let failed = 0; const total = urls.length;
for (const url of urls) {
const queue = urls.slice(); try {
const CONCURRENCY = 3; const cached = await cache.match(url);
const MAX_ATTEMPTS = 3; if (!cached) {
const BACKOFF_MS = 800; const res = await fetch(url, { credentials: 'same-origin' });
if (res.ok && res.type === 'basic') await cache.put(url, res);
function postProgress() { }
} catch {
// Network error — skip and continue. UI shows the count.
}
done++;
try { try {
(source as Client | null)?.postMessage?.({ (source as Client | null)?.postMessage?.({
type: 'precache-progress', type: 'precache-progress',
done, done,
total, total
failed
}); });
} catch {} } catch {
} // Sender went away.
async function fetchWithRetry(url: string): Promise<boolean> {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const res = await fetch(url, { credentials: 'same-origin', cache: 'no-store' });
if (res.ok && res.type === 'basic') {
await cache.put(url, res);
return true;
}
if (res.status >= 400 && res.status < 500) return false; // permanent
} catch {}
if (attempt < MAX_ATTEMPTS) {
await new Promise((r) => setTimeout(r, BACKOFF_MS * attempt));
}
}
return false;
}
async function worker() {
while (queue.length > 0) {
const url = queue.shift();
if (!url) break;
try {
const cached = await cache.match(url);
if (!cached) {
const ok = await fetchWithRetry(url);
if (!ok) failed++;
}
} catch {
failed++;
}
done++;
postProgress();
} }
} }
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
try { try {
(source as Client | null)?.postMessage?.({ (source as Client | null)?.postMessage?.({ type: 'precache-done', done, total });
type: 'precache-done',
done,
total,
failed
});
} catch { } catch {
// Sender went away. // Sender went away.
} }

View file

@ -1,5 +1,5 @@
{ {
"name": "Mill Run Theatre", "name": "Mill Run",
"short_name": "Mill Run", "short_name": "Mill Run",
"description": "Audio-guided exhibit", "description": "Audio-guided exhibit",
"start_url": "/", "start_url": "/",

View file

@ -14,25 +14,6 @@ export default {
serviceWorker: { serviceWorker: {
register: false register: false
}, },
csp: {
mode: 'hash',
directives: {
'default-src': ['self'],
'script-src': ['self'],
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:', 'blob:'],
'media-src': ['self', 'blob:'],
'font-src': ['self', 'data:'],
'connect-src': ['self'],
'manifest-src': ['self'],
'worker-src': ['self'],
'object-src': ['none'],
'base-uri': ['self'],
'frame-ancestors': ['none'],
'form-action': ['self'],
'upgrade-insecure-requests': true
}
},
alias: { alias: {
$content: './src/lib/content' $content: './src/lib/content'
} }