Prototype commit
This commit is contained in:
commit
b2ffbe865e
29 changed files with 3438 additions and 0 deletions
57
.forgejo/workflows/check.yml
Normal file
57
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
name: check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
runs-on: nix:host
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: nix develop --command bash -c "cd web && npm ci"
|
||||||
|
|
||||||
|
- name: svelte-check
|
||||||
|
run: nix develop --command bash -c "cd web && npm run check"
|
||||||
|
|
||||||
|
- name: Production build
|
||||||
|
run: nix develop --command bash -c "cd web && npm run build"
|
||||||
|
|
||||||
|
- name: Confirm SW + manifest emitted
|
||||||
|
run: |
|
||||||
|
test -f web/build/index.html
|
||||||
|
test -f web/build/service-worker.js
|
||||||
|
test -f web/build/manifest.webmanifest
|
||||||
|
test -f web/build/robots.txt
|
||||||
|
|
||||||
|
server:
|
||||||
|
runs-on: nix:host
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: nix develop --command bash -c "cd server && go vet ./..."
|
||||||
|
|
||||||
|
- name: go build
|
||||||
|
run: nix develop --command bash -c "cd server && go build ./..."
|
||||||
|
|
||||||
|
- name: Confirm strict mode refuses to start without secrets
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
nix develop --command bash -c "cd server && DOCENT_PASSWORD=t go run . -strict -addr :0" 2>&1 | tee /tmp/strict.log
|
||||||
|
status=${PIPESTATUS[0]}
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
echo "expected -strict to refuse to start without DOCENT_COOKIE_SECRET" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q "DOCENT_QR_KEY must be set in -strict mode\|DOCENT_COOKIE_SECRET must be set in -strict mode" /tmp/strict.log
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
server/docent
|
||||||
|
server/docent.exe
|
||||||
|
|
||||||
|
# Local environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Direnv
|
||||||
|
.direnv/
|
||||||
|
|
||||||
|
# Build artifacts from content pipeline
|
||||||
|
content/build/
|
||||||
45
Makefile
Normal file
45
Makefile
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
SHELL := /usr/bin/env bash
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@echo "make build — build server + frontend locally (sanity check)"
|
||||||
|
@echo "make check — type-check + go vet"
|
||||||
|
@echo "make tag V=patch|minor|major — bump version tag and push"
|
||||||
|
@echo "make prefetch — print sha256 + npmDepsHash + vendorHash for the homelab module"
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
cd web && npm install && npm run build
|
||||||
|
cd server && go build -o docent ./...
|
||||||
|
|
||||||
|
.PHONY: check
|
||||||
|
check:
|
||||||
|
cd web && npm run check
|
||||||
|
cd server && go vet ./...
|
||||||
|
|
||||||
|
.PHONY: tag
|
||||||
|
tag:
|
||||||
|
@if [ -z "$(V)" ]; then echo "usage: make tag V=patch|minor|major" >&2; exit 1; fi
|
||||||
|
@latest=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.0.0"); \
|
||||||
|
IFS=. read -r maj min pat <<< "$$latest"; \
|
||||||
|
case "$(V)" in \
|
||||||
|
major) maj=$$((maj+1)); min=0; pat=0 ;; \
|
||||||
|
minor) min=$$((min+1)); pat=0 ;; \
|
||||||
|
patch) pat=$$((pat+1)) ;; \
|
||||||
|
*) echo "V must be patch|minor|major" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
new="v$${maj}.$${min}.$${pat}"; \
|
||||||
|
echo "tagging $$new"; \
|
||||||
|
git tag -a "$$new" -m "$$new"; \
|
||||||
|
git push --tags
|
||||||
|
|
||||||
|
.PHONY: prefetch
|
||||||
|
prefetch:
|
||||||
|
@latest=$$(git describe --tags --abbrev=0); \
|
||||||
|
url=$$(git remote get-url origin); \
|
||||||
|
echo "rev = $$latest"; \
|
||||||
|
echo "src hash:"; \
|
||||||
|
nix-prefetch-git "$$url" --rev "$$latest" --quiet | sed -n 's/.*"sha256": "\(.*\)".*/ \1/p'; \
|
||||||
|
echo ""; \
|
||||||
|
echo "Run \`nix build .#docent-frontend\` and \`nix build .#docent-server\` once to populate"; \
|
||||||
|
echo "npmDepsHash and vendorHash respectively (will fail on mismatch and print correct hash)."
|
||||||
30
content/stops.yaml
Normal file
30
content/stops.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Source of truth for exhibit content.
|
||||||
|
# A build step generates web/src/lib/stops.json from this file.
|
||||||
|
#
|
||||||
|
# Each stop has:
|
||||||
|
# id — integer, used in URLs and as the asset basename
|
||||||
|
# title — short label, shown in the grid and on the detail page
|
||||||
|
# audio — filename in content/audio/, transcoded to web/static/audio/
|
||||||
|
# image — filename in content/images/, transcoded to web/static/images/
|
||||||
|
# caption — short line shown beneath the title
|
||||||
|
# description — longer text shown below the audio player (markdown allowed)
|
||||||
|
|
||||||
|
exhibit:
|
||||||
|
title: "TBD"
|
||||||
|
subtitle: "TBD"
|
||||||
|
|
||||||
|
stops:
|
||||||
|
- id: 1
|
||||||
|
title: "Welcome"
|
||||||
|
audio: 01-welcome.wav
|
||||||
|
image: 01-welcome.png
|
||||||
|
caption: "An introduction to the exhibit."
|
||||||
|
description: |
|
||||||
|
Placeholder for the full description shown on the stop detail page.
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
title: "Stop two placeholder"
|
||||||
|
audio: 02.wav
|
||||||
|
image: 02.png
|
||||||
|
caption: ""
|
||||||
|
description: ""
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1777578337,
|
||||||
|
"narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "15f4ee454b1dce334612fa6843b3e05cf546efab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
28
flake.nix
Normal file
28
flake.nix
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
description = "Docent — museum audio guide PWA";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
nodejs_22
|
||||||
|
go
|
||||||
|
ffmpeg
|
||||||
|
imagemagick
|
||||||
|
yq-go
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
97
server/auth.go
Normal file
97
server/auth.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cookieName = "docent_session"
|
||||||
|
cookieMaxAge = 30 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) signCookieValue() (string, error) {
|
||||||
|
payload := session{Exp: time.Now().Add(cookieMaxAge).Unix()}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
enc := base64.RawURLEncoding.EncodeToString(data)
|
||||||
|
mac := hmac.New(sha256.New, s.secret)
|
||||||
|
mac.Write([]byte(enc))
|
||||||
|
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
return enc + "." + sig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) verifyCookieValue(value string) bool {
|
||||||
|
parts := strings.SplitN(value, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, s.secret)
|
||||||
|
mac.Write([]byte(parts[0]))
|
||||||
|
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var sess session
|
||||||
|
if err := json.Unmarshal(data, &sess); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().Unix() < sess.Exp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) setSessionCookie(w http.ResponseWriter) {
|
||||||
|
value, err := s.signCookieValue()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(cookieMaxAge.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: s.secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: s.secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) isAuthed(r *http.Request) bool {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.verifyCookieValue(c.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func constantTimeEqualString(a, b string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
3
server/go.mod
Normal file
3
server/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module pentacle.games/docent
|
||||||
|
|
||||||
|
go 1.23
|
||||||
213
server/main.go
Normal file
213
server/main.go
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
staticDir string
|
||||||
|
password string
|
||||||
|
qrKey string
|
||||||
|
secret []byte
|
||||||
|
secure bool
|
||||||
|
loginLimiter *rateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := flag.String("addr", ":8181", "listen address")
|
||||||
|
staticDir := flag.String("static", "../web/build", "static asset directory")
|
||||||
|
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 and DOCENT_QR_KEY")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
password := os.Getenv("DOCENT_PASSWORD")
|
||||||
|
if password == "" {
|
||||||
|
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")
|
||||||
|
if *strict && cookieSecret == "" {
|
||||||
|
log.Fatal("DOCENT_COOKIE_SECRET must be set in -strict mode")
|
||||||
|
}
|
||||||
|
secret, err := loadOrGenSecret(cookieSecret)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("DOCENT_COOKIE_SECRET: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
abs, err := filepath.Abs(*staticDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("static dir: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(abs); err != nil {
|
||||||
|
log.Printf("warning: static dir %s not found yet (build the SvelteKit app first)", abs)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &server{
|
||||||
|
staticDir: abs,
|
||||||
|
password: password,
|
||||||
|
qrKey: qrKey,
|
||||||
|
secret: secret,
|
||||||
|
secure: !*insecure,
|
||||||
|
loginLimiter: newRateLimiter(8, time.Minute),
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /api/login", s.handleLogin)
|
||||||
|
mux.HandleFunc("POST /api/key", s.handleKey)
|
||||||
|
mux.HandleFunc("POST /api/logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("GET /api/me", s.handleMe)
|
||||||
|
mux.Handle("GET /audio/", s.requireAuth(http.HandlerFunc(s.serveStatic)))
|
||||||
|
mux.Handle("GET /images/", s.requireAuth(http.HandlerFunc(s.serveStatic)))
|
||||||
|
mux.HandleFunc("GET /", s.serveSPA)
|
||||||
|
|
||||||
|
log.Printf("docent listening on %s, serving %s", *addr, abs)
|
||||||
|
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrGenSecret(env string) ([]byte, error) {
|
||||||
|
if env != "" {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(env)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(b) < 32 {
|
||||||
|
return nil, errShortSecret
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
log.Println("warning: DOCENT_COOKIE_SECRET not set, generating ephemeral secret (sessions reset on restart)")
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errShortSecret = &cookieError{"DOCENT_COOKIE_SECRET must decode to at least 32 bytes"}
|
||||||
|
|
||||||
|
type cookieError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *cookieError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
func (s *server) handleLogin(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 {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if err != nil || !constantTimeEqualString(body.Password, s.password) {
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.setSessionCookie(w)
|
||||||
|
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) {
|
||||||
|
s.clearSessionCookie(w)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.isAuthed(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) requireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.isAuthed(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveStatic serves files relative to staticDir, refusing path traversal.
|
||||||
|
func (s *server) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clean := filepath.Clean(r.URL.Path)
|
||||||
|
if strings.Contains(clean, "..") {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
full := filepath.Join(s.staticDir, clean)
|
||||||
|
if !strings.HasPrefix(full, s.staticDir) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(full)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, full)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveSPA serves files from staticDir, falling back to index.html for client routes.
|
||||||
|
func (s *server) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clean := filepath.Clean(r.URL.Path)
|
||||||
|
if clean == "/" {
|
||||||
|
clean = "/index.html"
|
||||||
|
}
|
||||||
|
full := filepath.Join(s.staticDir, clean)
|
||||||
|
if !strings.HasPrefix(full, s.staticDir) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(full); err == nil && !info.IsDir() {
|
||||||
|
http.ServeFile(w, r, full)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown path → SPA fallback.
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html"))
|
||||||
|
}
|
||||||
80
server/ratelimit.go
Normal file
80
server/ratelimit.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
visitors map[string]*visitor
|
||||||
|
rate int
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type visitor struct {
|
||||||
|
count int
|
||||||
|
reset time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(rate int, window time.Duration) *rateLimiter {
|
||||||
|
rl := &rateLimiter{
|
||||||
|
visitors: make(map[string]*visitor),
|
||||||
|
rate: rate,
|
||||||
|
window: window,
|
||||||
|
}
|
||||||
|
go rl.cleanup()
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *rateLimiter) allow(key string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
v, ok := rl.visitors[key]
|
||||||
|
if !ok || now.After(v.reset) {
|
||||||
|
rl.visitors[key] = &visitor{count: 1, reset: now.Add(rl.window)}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if v.count >= rl.rate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v.count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *rateLimiter) cleanup() {
|
||||||
|
for {
|
||||||
|
time.Sleep(5 * time.Minute)
|
||||||
|
rl.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for k, v := range rl.visitors {
|
||||||
|
if now.After(v.reset) {
|
||||||
|
delete(rl.visitors, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP extracts the client address from X-Forwarded-For (set by nginx),
|
||||||
|
// falling back to X-Real-IP and finally RemoteAddr.
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
if comma := strings.Index(xff, ","); comma > 0 {
|
||||||
|
return strings.TrimSpace(xff[:comma])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(xff)
|
||||||
|
}
|
||||||
|
if rip := r.Header.Get("X-Real-IP"); rip != "" {
|
||||||
|
return rip
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
1536
web/package-lock.json
generated
Normal file
1536
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
web/package.json
Normal file
21
web/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "docent-web",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
|
"@sveltejs/kit": "^2.7.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
72
web/src/app.css
Normal file
72
web/src/app.css
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
:root {
|
||||||
|
--bg: #faf7f2;
|
||||||
|
--bg-elev: #ffffff;
|
||||||
|
--ink: #2a2520;
|
||||||
|
--ink-mute: #6b6157;
|
||||||
|
--accent: #6e3a1f;
|
||||||
|
--rule: #e8e0d4;
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-lg: 22px;
|
||||||
|
--shadow: 0 1px 2px rgba(42, 37, 32, 0.06), 0 8px 24px rgba(42, 37, 32, 0.08);
|
||||||
|
|
||||||
|
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--font-serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
20
web/src/app.html
Normal file
20
web/src/app.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#2a2520" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Mill Run" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<title>Mill Run</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div id="app">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
web/src/lib/auth.svelte.ts
Normal file
72
web/src/lib/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type AuthState = 'unknown' | 'signed-in' | 'signed-out';
|
||||||
|
|
||||||
|
let state: AuthState = $state('unknown');
|
||||||
|
|
||||||
|
export function authState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuth(next: AuthState) {
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAuth(): Promise<AuthState> {
|
||||||
|
if (!browser) return 'unknown';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/me', { credentials: 'same-origin' });
|
||||||
|
state = res.ok ? 'signed-in' : 'signed-out';
|
||||||
|
} catch {
|
||||||
|
state = 'signed-out';
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(password: string): Promise<boolean> {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
state = 'signed-in';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithKey(key: string): Promise<boolean> {
|
||||||
|
const res = await fetch('/api/key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
state = 'signed-in';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
state = 'signed-out';
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyResult = 'success' | 'failed' | 'absent';
|
||||||
|
|
||||||
|
export async function consumeKeyParam(): Promise<KeyResult> {
|
||||||
|
if (!browser) return 'absent';
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const key = url.searchParams.get('key');
|
||||||
|
if (!key) return 'absent';
|
||||||
|
url.searchParams.delete('key');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
const ok = await loginWithKey(key);
|
||||||
|
return ok ? 'success' : 'failed';
|
||||||
|
}
|
||||||
51
web/src/lib/precache.svelte.ts
Normal file
51
web/src/lib/precache.svelte.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { stops } from './stops';
|
||||||
|
|
||||||
|
export type PrecacheState = 'idle' | 'running' | 'complete' | 'unsupported';
|
||||||
|
|
||||||
|
let state: PrecacheState = $state('idle');
|
||||||
|
let done = $state(0);
|
||||||
|
let total = $state(0);
|
||||||
|
|
||||||
|
export function precacheState() {
|
||||||
|
return { state, done, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function precacheAll(): Promise<void> {
|
||||||
|
if (!browser) return;
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
state = 'unsupported';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state === 'running' || state === 'complete') return;
|
||||||
|
|
||||||
|
const reg = await navigator.serviceWorker.ready.catch(() => null);
|
||||||
|
const target = reg?.active ?? navigator.serviceWorker.controller;
|
||||||
|
if (!target) {
|
||||||
|
state = 'unsupported';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = stops.flatMap((s) => [s.audio, s.image]);
|
||||||
|
|
||||||
|
state = 'running';
|
||||||
|
done = 0;
|
||||||
|
total = urls.length;
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || typeof data !== 'object') return;
|
||||||
|
if (data.type === 'precache-progress') {
|
||||||
|
done = data.done;
|
||||||
|
total = data.total;
|
||||||
|
} else if (data.type === 'precache-done') {
|
||||||
|
done = data.done;
|
||||||
|
total = data.total;
|
||||||
|
state = 'complete';
|
||||||
|
navigator.serviceWorker.removeEventListener('message', onMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
navigator.serviceWorker.addEventListener('message', onMessage);
|
||||||
|
|
||||||
|
target.postMessage({ type: 'precache-stops', urls });
|
||||||
|
}
|
||||||
24
web/src/lib/stops.json
Normal file
24
web/src/lib/stops.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"exhibit": {
|
||||||
|
"title": "Mill Run",
|
||||||
|
"subtitle": ""
|
||||||
|
},
|
||||||
|
"stops": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Welcome",
|
||||||
|
"audio": "/audio/01.opus",
|
||||||
|
"image": "/images/01.webp",
|
||||||
|
"caption": "An introduction to the exhibit.",
|
||||||
|
"description": "Placeholder description."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Stop two placeholder",
|
||||||
|
"audio": "/audio/02.opus",
|
||||||
|
"image": "/images/02.webp",
|
||||||
|
"caption": "",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
22
web/src/lib/stops.ts
Normal file
22
web/src/lib/stops.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import stopsJson from './stops.json';
|
||||||
|
|
||||||
|
export type Stop = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
audio: string;
|
||||||
|
image: string;
|
||||||
|
caption?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Exhibit = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exhibit: Exhibit = stopsJson.exhibit;
|
||||||
|
export const stops: Stop[] = stopsJson.stops;
|
||||||
|
|
||||||
|
export function stopById(id: number): Stop | undefined {
|
||||||
|
return stops.find((s) => s.id === id);
|
||||||
|
}
|
||||||
72
web/src/routes/+layout.svelte
Normal file
72
web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { authState, checkAuth, consumeKeyParam } from '$lib/auth.svelte';
|
||||||
|
import { precacheAll } from '$lib/precache.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
let booted = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
if (dev) {
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(regs.map((r) => r.unregister()));
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||||
|
} else {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/service-worker.js', { type: 'module' })
|
||||||
|
.catch((err) => console.warn('sw register failed:', err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyResult = await consumeKeyParam();
|
||||||
|
if (keyResult !== 'success') await checkAuth();
|
||||||
|
|
||||||
|
const path = $page.url.pathname;
|
||||||
|
if (authState() === 'signed-out' && path !== '/login') {
|
||||||
|
const params = new URLSearchParams({ next: path });
|
||||||
|
if (keyResult === 'failed') params.set('error', 'qr');
|
||||||
|
await goto('/login?' + params.toString(), { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production-only: kick off audio/image bulk precache once authenticated.
|
||||||
|
// SW does the work; failures non-fatal.
|
||||||
|
if (!dev && authState() === 'signed-in') {
|
||||||
|
precacheAll().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
booted = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if booted}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<div class="splash">
|
||||||
|
<div class="ring" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splash {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.ring {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--rule);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2
web/src/routes/+layout.ts
Normal file
2
web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
133
web/src/routes/+page.svelte
Normal file
133
web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { exhibit, stops } from '$lib/stops';
|
||||||
|
import { precacheState } from '$lib/precache.svelte';
|
||||||
|
|
||||||
|
const pre = $derived(precacheState());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<h1>{exhibit.title}</h1>
|
||||||
|
{#if exhibit.subtitle}
|
||||||
|
<p class="subtitle">{exhibit.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if pre.state === 'running'}
|
||||||
|
<div class="precache" role="status" aria-live="polite">
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
<span class="text">
|
||||||
|
Loading exhibit for offline listening… {pre.done}/{pre.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<main class="grid" aria-label="Exhibit stops">
|
||||||
|
{#each stops as stop (stop.id)}
|
||||||
|
<a class="tile" href="/stop/{stop.id}" data-sveltekit-preload-data="hover">
|
||||||
|
<div class="tile-image">
|
||||||
|
<img src={stop.image} alt="" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="tile-meta">
|
||||||
|
<span class="tile-num">{String(stop.id).padStart(2, '0')}</span>
|
||||||
|
<span class="tile-title">{stop.title}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero {
|
||||||
|
padding: 2rem 1.25rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.precache {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.precache .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
50% { opacity: 1; transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.precache .dot { animation: none; opacity: 0.7; }
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid {
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1.25rem 2rem 3rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
.tile-image {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: var(--rule);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tile-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.tile-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.25rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
.tile-num {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.tile-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
web/src/routes/login/+page.svelte
Normal file
130
web/src/routes/login/+page.svelte
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { exhibit } from '$lib/stops';
|
||||||
|
import { login } from '$lib/auth.svelte';
|
||||||
|
|
||||||
|
let password = $state('');
|
||||||
|
let error = $state(
|
||||||
|
$page.url.searchParams.get('error') === 'qr'
|
||||||
|
? "That QR code didn't work — try the password posted at the exhibit."
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
function safeNext(raw: string | null): string {
|
||||||
|
if (!raw || !raw.startsWith('/') || raw.startsWith('//')) return '/';
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy || !password) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
const ok = await login(password);
|
||||||
|
busy = false;
|
||||||
|
if (!ok) {
|
||||||
|
error = "That password didn't work — try again.";
|
||||||
|
password = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await goto(safeNext($page.url.searchParams.get('next')), { replaceState: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h1>{exhibit.title}</h1>
|
||||||
|
<p>Enter the password posted at the exhibit to begin.</p>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<label for="pw" class="visually-hidden">Password</label>
|
||||||
|
<input
|
||||||
|
id="pw"
|
||||||
|
type="password"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={password}
|
||||||
|
placeholder="Password"
|
||||||
|
disabled={busy}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={busy || !password}>
|
||||||
|
{busy ? 'Checking…' : 'Enter'}
|
||||||
|
</button>
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 26rem;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2rem 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: var(--ink-mute);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
button[type='submit'] {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
button[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #b53a25;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
373
web/src/routes/stop/[id]/+page.svelte
Normal file
373
web/src/routes/stop/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { stops, stopById } from '$lib/stops';
|
||||||
|
|
||||||
|
const id = $derived(Number($page.params.id));
|
||||||
|
const stop = $derived(stopById(id));
|
||||||
|
const index = $derived(stops.findIndex((s) => s.id === id));
|
||||||
|
const prev = $derived(index > 0 ? stops[index - 1] : null);
|
||||||
|
const next = $derived(index >= 0 && index < stops.length - 1 ? stops[index + 1] : null);
|
||||||
|
|
||||||
|
let audio: HTMLAudioElement | undefined = $state();
|
||||||
|
let paused = $state(true);
|
||||||
|
let volume = $state(1);
|
||||||
|
let muted = $state(false);
|
||||||
|
|
||||||
|
// iOS Safari treats audio.volume as read-only. Probe a throwaway element
|
||||||
|
// so we can hide the slider where it would be a no-op.
|
||||||
|
const volumeWritable = browser
|
||||||
|
? (() => {
|
||||||
|
const probe = new Audio();
|
||||||
|
probe.volume = 0.42;
|
||||||
|
return probe.volume === 0.42;
|
||||||
|
})()
|
||||||
|
: true;
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.paused) {
|
||||||
|
audio.play().catch((err) => console.warn('audio play blocked:', err));
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
muted = !muted;
|
||||||
|
if (!muted && volume === 0) volume = 0.5;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !stop}
|
||||||
|
<main class="missing">
|
||||||
|
<p>Stop not found.</p>
|
||||||
|
<a href="/">Back to all stops</a>
|
||||||
|
</main>
|
||||||
|
{:else}
|
||||||
|
<header class="bar">
|
||||||
|
<a class="back" href="/" aria-label="Back to all stops">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||||
|
<path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="counter">{String(stop.id).padStart(2, '0')}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="stop">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hero"
|
||||||
|
class:playing={!paused}
|
||||||
|
onclick={toggle}
|
||||||
|
aria-label={paused ? `Play ${stop.title}` : `Pause ${stop.title}`}
|
||||||
|
aria-pressed={!paused}
|
||||||
|
>
|
||||||
|
<img src={stop.image} alt="" />
|
||||||
|
<span class="overlay" aria-hidden="true">
|
||||||
|
<span class="icon">
|
||||||
|
{#if paused}
|
||||||
|
<svg viewBox="0 0 24 24" width="32" height="32">
|
||||||
|
<path d="M8 5l11 7-11 7V5z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" width="32" height="32">
|
||||||
|
<path d="M7 5h3v14H7zM14 5h3v14h-3z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{#if volumeWritable}
|
||||||
|
<div class="volume" role="group" aria-label="Volume">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mute-btn"
|
||||||
|
onclick={toggleMute}
|
||||||
|
aria-label={muted || volume === 0 ? 'Unmute' : 'Mute'}
|
||||||
|
aria-pressed={muted}
|
||||||
|
>
|
||||||
|
{#if muted || volume === 0}
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
|
||||||
|
<path d="M16.5 12l3-3-1.4-1.4-3 3-3-3L10.7 9l3 3-3 3 1.4 1.4 3-3 3 3 1.4-1.4z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{:else if volume < 0.5}
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
|
||||||
|
<path d="M14 8.83v6.34a3 3 0 000-6.34z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
|
||||||
|
<path d="M14 8.83v6.34a3 3 0 000-6.34z" fill="currentColor" />
|
||||||
|
<path d="M14 4.5v2.06a5.5 5.5 0 010 10.88v2.06a7.5 7.5 0 000-15z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
bind:value={volume}
|
||||||
|
aria-label="Volume level"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<audio
|
||||||
|
bind:this={audio}
|
||||||
|
bind:paused
|
||||||
|
bind:volume
|
||||||
|
bind:muted
|
||||||
|
preload="auto"
|
||||||
|
src={stop.audio}
|
||||||
|
>
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<h1>{stop.title}</h1>
|
||||||
|
{#if stop.caption}
|
||||||
|
<p class="caption">{stop.caption}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if stop.description}
|
||||||
|
<div class="description">
|
||||||
|
{#each stop.description.split(/\n\n+/) as para}
|
||||||
|
<p>{para}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav class="pager" aria-label="Stop navigation">
|
||||||
|
<button class="pager-btn" disabled={!prev} onclick={() => prev && goto(`/stop/${prev.id}`)}>
|
||||||
|
<span class="arrow">←</span>
|
||||||
|
<span class="label">{prev ? `${String(prev.id).padStart(2, '0')} ${prev.title}` : ''}</span>
|
||||||
|
</button>
|
||||||
|
<button class="pager-btn right" disabled={!next} onclick={() => next && goto(`/stop/${next.id}`)}>
|
||||||
|
<span class="label">{next ? `${String(next.id).padStart(2, '0')} ${next.title}` : ''}</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.missing {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: color-mix(in oklab, var(--bg) 80%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
background: var(--rule);
|
||||||
|
}
|
||||||
|
.counter {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stop {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.5rem 1.25rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
background: var(--rule);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.hero img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.hero .overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(20, 16, 12, 0.32);
|
||||||
|
transition: background 0.25s ease, opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.hero.playing .overlay {
|
||||||
|
background: rgba(20, 16, 12, 0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.hero:hover .overlay,
|
||||||
|
.hero:focus-visible .overlay {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(20, 16, 12, 0.32);
|
||||||
|
}
|
||||||
|
.hero .icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.hero:active .icon {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
.hero:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero .overlay,
|
||||||
|
.hero .icon {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.6rem, 4vw, 2.25rem);
|
||||||
|
}
|
||||||
|
.caption {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
}
|
||||||
|
.volume {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.25rem 0.25rem;
|
||||||
|
}
|
||||||
|
.mute-btn {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--ink);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mute-btn:hover,
|
||||||
|
.mute-btn:focus-visible {
|
||||||
|
background: var(--rule);
|
||||||
|
}
|
||||||
|
.mute-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.volume input[type='range'] {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.volume input[type='range']:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
.volume input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.volume input[type='range']::-moz-range-thumb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.description p {
|
||||||
|
margin: 0 0 1em;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1.25rem 1.5rem;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pager-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
.pager-btn.right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.pager-btn[disabled] {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
159
web/src/service-worker.ts
Normal file
159
web/src/service-worker.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/// <reference no-default-lib="true"/>
|
||||||
|
/// <reference lib="esnext" />
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
// Production service worker for Docent.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Install: precache the SvelteKit shell (build + small static files).
|
||||||
|
// - Activate: drop caches that don't match the current version.
|
||||||
|
// - Fetch:
|
||||||
|
// /api/* → network only (auth state must not be cached)
|
||||||
|
// navigation → cached index.html as offline fallback
|
||||||
|
// /audio /img → stale-while-revalidate from ASSET_CACHE
|
||||||
|
// everything → cache-first from SHELL_CACHE
|
||||||
|
// - Messages:
|
||||||
|
// { type: 'precache-stops', urls: string[] }
|
||||||
|
// Bulk-cache a list of URLs into ASSET_CACHE, posting progress back to
|
||||||
|
// the sender via { type: 'precache-progress', done, total } and a final
|
||||||
|
// { type: 'precache-done' } when finished.
|
||||||
|
//
|
||||||
|
// In dev (`serviceWorker.register: false` + the layout's dev-mode unregister),
|
||||||
|
// this file is bundled but never registered.
|
||||||
|
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
const SHELL_CACHE = `docent-shell-${version}`;
|
||||||
|
const ASSET_CACHE = `docent-assets-v1`; // version-stable so audio survives shell upgrades
|
||||||
|
|
||||||
|
const shellAssets = [
|
||||||
|
...build,
|
||||||
|
...files.filter((f) => !f.startsWith('/audio/') && !f.startsWith('/images/'))
|
||||||
|
];
|
||||||
|
|
||||||
|
sw.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
await cache.addAll(shellAssets);
|
||||||
|
await sw.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE)
|
||||||
|
.map((k) => caches.delete(k))
|
||||||
|
);
|
||||||
|
await sw.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== location.origin) return;
|
||||||
|
|
||||||
|
// Never cache API responses — auth state must always be authoritative.
|
||||||
|
if (url.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
const isContent = url.pathname.startsWith('/audio/') || url.pathname.startsWith('/images/');
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
// Navigation: prefer cached shell; fall back to network.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
const shell = await caches.open(SHELL_CACHE);
|
||||||
|
const cached = (await shell.match('/')) || (await shell.match('/index.html'));
|
||||||
|
if (cached) return cached;
|
||||||
|
try {
|
||||||
|
return await fetch(request);
|
||||||
|
} catch (err) {
|
||||||
|
return new Response('offline', { status: 503, statusText: 'offline' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheName = isContent ? ASSET_CACHE : SHELL_CACHE;
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) {
|
||||||
|
if (isContent) {
|
||||||
|
// Stale-while-revalidate: return cached, refresh in background.
|
||||||
|
event.waitUntil(refresh(cache, request));
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(request);
|
||||||
|
if (res.ok && res.type === 'basic') {
|
||||||
|
cache.put(request, res.clone());
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
return new Response('offline', { status: 503, statusText: 'offline' });
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh(cache: Cache, request: Request) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(request, { credentials: 'same-origin' });
|
||||||
|
if (res.ok && res.type === 'basic') await cache.put(request, res);
|
||||||
|
} catch {
|
||||||
|
// Offline; keep the cached version.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.addEventListener('message', (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (data?.type !== 'precache-stops' || !Array.isArray(data.urls)) return;
|
||||||
|
|
||||||
|
const urls: string[] = data.urls;
|
||||||
|
const source = event.source;
|
||||||
|
event.waitUntil(precacheStops(urls, source));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function precacheStops(urls: string[], source: Client | ServiceWorker | MessagePort | null) {
|
||||||
|
const cache = await caches.open(ASSET_CACHE);
|
||||||
|
let done = 0;
|
||||||
|
const total = urls.length;
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const cached = await cache.match(url);
|
||||||
|
if (!cached) {
|
||||||
|
const res = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
if (res.ok && res.type === 'basic') await cache.put(url, res);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — skip and continue. UI shows the count.
|
||||||
|
}
|
||||||
|
done++;
|
||||||
|
try {
|
||||||
|
(source as Client | null)?.postMessage?.({
|
||||||
|
type: 'precache-progress',
|
||||||
|
done,
|
||||||
|
total
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Sender went away.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
(source as Client | null)?.postMessage?.({ type: 'precache-done', done, total });
|
||||||
|
} catch {
|
||||||
|
// Sender went away.
|
||||||
|
}
|
||||||
|
}
|
||||||
30
web/static/manifest.webmanifest
Normal file
30
web/static/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "Mill Run",
|
||||||
|
"short_name": "Mill Run",
|
||||||
|
"description": "Audio-guided exhibit",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#faf7f2",
|
||||||
|
"theme_color": "#2a2520",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
web/static/robots.txt
Normal file
2
web/static/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
21
web/svelte.config.js
Normal file
21
web/svelte.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: false,
|
||||||
|
strict: true
|
||||||
|
}),
|
||||||
|
serviceWorker: {
|
||||||
|
register: false
|
||||||
|
},
|
||||||
|
alias: {
|
||||||
|
$content: './src/lib/content'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
14
web/tsconfig.json
Normal file
14
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
web/vite.config.ts
Normal file
39
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
allowedHosts: ['docent-dev.pentacle.games', 'localhost'],
|
||||||
|
hmr: {
|
||||||
|
protocol: 'wss',
|
||||||
|
host: 'docent-dev.pentacle.games',
|
||||||
|
clientPort: 443
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 500
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
strict: true,
|
||||||
|
allow: ['./src', './static', './node_modules', './.svelte-kit'],
|
||||||
|
deny: [
|
||||||
|
'.env',
|
||||||
|
'.env.*',
|
||||||
|
'**/.git/**',
|
||||||
|
'**/secrets/**',
|
||||||
|
'**/*.{pem,crt,key,age}',
|
||||||
|
'../server/**',
|
||||||
|
'../content/**'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8181',
|
||||||
|
'/audio': 'http://localhost:8181',
|
||||||
|
'/images': 'http://localhost:8181'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue