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

This commit is contained in:
Pen Anderson 2026-05-05 09:29:40 -05:00
commit 9884ccd9ed
29 changed files with 3438 additions and 0 deletions

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

@ -0,0 +1,3 @@
module pentacle.games/docent
go 1.23

213
server/main.go Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

21
web/package.json Normal file
View file

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

72
web/src/app.css Normal file
View file

@ -0,0 +1,72 @@
:root {
--bg: #faf7f2;
--bg-elev: #ffffff;
--ink: #2a2520;
--ink-mute: #6b6157;
--accent: #6e3a1f;
--rule: #e8e0d4;
--radius: 14px;
--radius-lg: 22px;
--shadow: 0 1px 2px rgba(42, 37, 32, 0.06), 0 8px 24px rgba(42, 37, 32, 0.08);
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-serif: ui-serif, Georgia, 'Times New Roman', serif;
color-scheme: light;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain;
}
body {
min-height: 100dvh;
}
#app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
h1, h2, h3 {
font-family: var(--font-serif);
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
a {
color: var(--accent);
text-decoration: none;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
input {
font: inherit;
color: inherit;
}
img {
display: block;
max-width: 100%;
}

20
web/src/app.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#2a2520" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Mill Run" />
<meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Mill Run</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div id="app">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,72 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
export type AuthState = 'unknown' | 'signed-in' | 'signed-out';
let state: AuthState = $state('unknown');
export function authState() {
return state;
}
export function setAuth(next: AuthState) {
state = next;
}
export async function checkAuth(): Promise<AuthState> {
if (!browser) return 'unknown';
try {
const res = await fetch('/api/me', { credentials: 'same-origin' });
state = res.ok ? 'signed-in' : 'signed-out';
} catch {
state = 'signed-out';
}
return state;
}
export async function login(password: string): Promise<boolean> {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
credentials: 'same-origin'
});
if (res.ok) {
state = 'signed-in';
return true;
}
return false;
}
export async function loginWithKey(key: string): Promise<boolean> {
const res = await fetch('/api/key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
credentials: 'same-origin'
});
if (res.ok) {
state = 'signed-in';
return true;
}
return false;
}
export async function logout() {
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
state = 'signed-out';
goto('/login');
}
export type KeyResult = 'success' | 'failed' | 'absent';
export async function consumeKeyParam(): Promise<KeyResult> {
if (!browser) return 'absent';
const url = new URL(window.location.href);
const key = url.searchParams.get('key');
if (!key) return 'absent';
url.searchParams.delete('key');
window.history.replaceState({}, '', url.toString());
const ok = await loginWithKey(key);
return ok ? 'success' : 'failed';
}

View file

@ -0,0 +1,51 @@
import { browser } from '$app/environment';
import { stops } from './stops';
export type PrecacheState = 'idle' | 'running' | 'complete' | 'unsupported';
let state: PrecacheState = $state('idle');
let done = $state(0);
let total = $state(0);
export function precacheState() {
return { state, done, total };
}
export async function precacheAll(): Promise<void> {
if (!browser) return;
if (!('serviceWorker' in navigator)) {
state = 'unsupported';
return;
}
if (state === 'running' || state === 'complete') return;
const reg = await navigator.serviceWorker.ready.catch(() => null);
const target = reg?.active ?? navigator.serviceWorker.controller;
if (!target) {
state = 'unsupported';
return;
}
const urls = stops.flatMap((s) => [s.audio, s.image]);
state = 'running';
done = 0;
total = urls.length;
const onMessage = (event: MessageEvent) => {
const data = event.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'precache-progress') {
done = data.done;
total = data.total;
} else if (data.type === 'precache-done') {
done = data.done;
total = data.total;
state = 'complete';
navigator.serviceWorker.removeEventListener('message', onMessage);
}
};
navigator.serviceWorker.addEventListener('message', onMessage);
target.postMessage({ type: 'precache-stops', urls });
}

24
web/src/lib/stops.json Normal file
View file

@ -0,0 +1,24 @@
{
"exhibit": {
"title": "Mill Run",
"subtitle": ""
},
"stops": [
{
"id": 1,
"title": "Welcome",
"audio": "/audio/01.opus",
"image": "/images/01.webp",
"caption": "An introduction to the exhibit.",
"description": "Placeholder description."
},
{
"id": 2,
"title": "Stop two placeholder",
"audio": "/audio/02.opus",
"image": "/images/02.webp",
"caption": "",
"description": ""
}
]
}

22
web/src/lib/stops.ts Normal file
View file

@ -0,0 +1,22 @@
import stopsJson from './stops.json';
export type Stop = {
id: number;
title: string;
audio: string;
image: string;
caption?: string;
description?: string;
};
export type Exhibit = {
title: string;
subtitle: string;
};
export const exhibit: Exhibit = stopsJson.exhibit;
export const stops: Stop[] = stopsJson.stops;
export function stopById(id: number): Stop | undefined {
return stops.find((s) => s.id === id);
}

View file

@ -0,0 +1,72 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { dev } from '$app/environment';
import { authState, checkAuth, consumeKeyParam } from '$lib/auth.svelte';
import { precacheAll } from '$lib/precache.svelte';
let { children } = $props();
let booted = $state(false);
onMount(async () => {
if ('serviceWorker' in navigator) {
if (dev) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
} else {
navigator.serviceWorker
.register('/service-worker.js', { type: 'module' })
.catch((err) => console.warn('sw register failed:', err));
}
}
const keyResult = await consumeKeyParam();
if (keyResult !== 'success') await checkAuth();
const path = $page.url.pathname;
if (authState() === 'signed-out' && path !== '/login') {
const params = new URLSearchParams({ next: path });
if (keyResult === 'failed') params.set('error', 'qr');
await goto('/login?' + params.toString(), { replaceState: true });
}
// Production-only: kick off audio/image bulk precache once authenticated.
// SW does the work; failures non-fatal.
if (!dev && authState() === 'signed-in') {
precacheAll().catch(() => undefined);
}
booted = true;
});
</script>
{#if booted}
{@render children()}
{:else}
<div class="splash">
<div class="ring" aria-hidden="true"></div>
</div>
{/if}
<style>
.splash {
flex: 1;
display: grid;
place-items: center;
}
.ring {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid var(--rule);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View file

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

133
web/src/routes/+page.svelte Normal file
View file

@ -0,0 +1,133 @@
<script lang="ts">
import { exhibit, stops } from '$lib/stops';
import { precacheState } from '$lib/precache.svelte';
const pre = $derived(precacheState());
</script>
<header class="hero">
<h1>{exhibit.title}</h1>
{#if exhibit.subtitle}
<p class="subtitle">{exhibit.subtitle}</p>
{/if}
</header>
{#if pre.state === 'running'}
<div class="precache" role="status" aria-live="polite">
<span class="dot" aria-hidden="true"></span>
<span class="text">
Loading exhibit for offline listening… {pre.done}/{pre.total}
</span>
</div>
{/if}
<main class="grid" aria-label="Exhibit stops">
{#each stops as stop (stop.id)}
<a class="tile" href="/stop/{stop.id}" data-sveltekit-preload-data="hover">
<div class="tile-image">
<img src={stop.image} alt="" loading="lazy" />
</div>
<div class="tile-meta">
<span class="tile-num">{String(stop.id).padStart(2, '0')}</span>
<span class="tile-title">{stop.title}</span>
</div>
</a>
{/each}
</main>
<style>
.hero {
padding: 2rem 1.25rem 1rem;
text-align: center;
}
.precache {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0 auto 0.5rem;
padding: 0.5rem 1rem;
max-width: 32rem;
font-size: 0.9rem;
color: var(--ink-mute);
background: var(--bg-elev);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.precache .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.4s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.1); }
}
@media (prefers-reduced-motion: reduce) {
.precache .dot { animation: none; opacity: 0.7; }
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3rem);
}
.subtitle {
margin: 0.5rem 0 0;
color: var(--ink-mute);
}
.grid {
display: grid;
gap: 1rem;
padding: 1rem;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
@media (min-width: 768px) {
.grid {
gap: 1.25rem;
padding: 1.25rem 2rem 3rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
}
.tile {
display: flex;
flex-direction: column;
gap: 0.75rem;
color: inherit;
border-radius: var(--radius-lg);
background: var(--bg-elev);
box-shadow: var(--shadow);
overflow: hidden;
transition: transform 0.15s ease;
}
.tile:active {
transform: scale(0.98);
}
.tile-image {
aspect-ratio: 4 / 3;
background: var(--rule);
overflow: hidden;
}
.tile-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.tile-meta {
display: flex;
align-items: baseline;
gap: 0.6rem;
padding: 0.25rem 1rem 1rem;
}
.tile-num {
font-family: var(--font-serif);
font-size: 0.95rem;
color: var(--ink-mute);
font-variant-numeric: tabular-nums;
}
.tile-title {
font-family: var(--font-serif);
font-weight: 600;
font-size: 1.1rem;
line-height: 1.2;
}
</style>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { exhibit } from '$lib/stops';
import { login } from '$lib/auth.svelte';
let password = $state('');
let error = $state(
$page.url.searchParams.get('error') === 'qr'
? "That QR code didn't work — try the password posted at the exhibit."
: ''
);
let busy = $state(false);
function safeNext(raw: string | null): string {
if (!raw || !raw.startsWith('/') || raw.startsWith('//')) return '/';
return raw;
}
async function submit(e: SubmitEvent) {
e.preventDefault();
if (busy || !password) return;
busy = true;
error = '';
const ok = await login(password);
busy = false;
if (!ok) {
error = "That password didn't work — try again.";
password = '';
return;
}
await goto(safeNext($page.url.searchParams.get('next')), { replaceState: true });
}
</script>
<main>
<div class="card">
<h1>{exhibit.title}</h1>
<p>Enter the password posted at the exhibit to begin.</p>
<form onsubmit={submit}>
<label for="pw" class="visually-hidden">Password</label>
<input
id="pw"
type="password"
inputmode="text"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
bind:value={password}
placeholder="Password"
disabled={busy}
required
/>
<button type="submit" disabled={busy || !password}>
{busy ? 'Checking…' : 'Enter'}
</button>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
</form>
</div>
</main>
<style>
main {
flex: 1;
display: grid;
place-items: center;
padding: 1.5rem;
}
.card {
width: 100%;
max-width: 26rem;
background: var(--bg-elev);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: 2rem 1.75rem;
text-align: center;
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
p {
color: var(--ink-mute);
margin: 0 0 1.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
input {
padding: 0.9rem 1rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--bg);
font-size: 1.05rem;
text-align: center;
}
input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
button[type='submit'] {
padding: 0.9rem 1rem;
border-radius: var(--radius);
background: var(--ink);
color: var(--bg);
font-weight: 600;
font-size: 1.05rem;
}
button[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #b53a25;
margin: 0.25rem 0 0;
font-size: 0.95rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,373 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { stops, stopById } from '$lib/stops';
const id = $derived(Number($page.params.id));
const stop = $derived(stopById(id));
const index = $derived(stops.findIndex((s) => s.id === id));
const prev = $derived(index > 0 ? stops[index - 1] : null);
const next = $derived(index >= 0 && index < stops.length - 1 ? stops[index + 1] : null);
let audio: HTMLAudioElement | undefined = $state();
let paused = $state(true);
let volume = $state(1);
let muted = $state(false);
// iOS Safari treats audio.volume as read-only. Probe a throwaway element
// so we can hide the slider where it would be a no-op.
const volumeWritable = browser
? (() => {
const probe = new Audio();
probe.volume = 0.42;
return probe.volume === 0.42;
})()
: true;
function toggle() {
if (!audio) return;
if (audio.paused) {
audio.play().catch((err) => console.warn('audio play blocked:', err));
} else {
audio.pause();
}
}
function toggleMute() {
muted = !muted;
if (!muted && volume === 0) volume = 0.5;
}
</script>
{#if !stop}
<main class="missing">
<p>Stop not found.</p>
<a href="/">Back to all stops</a>
</main>
{:else}
<header class="bar">
<a class="back" href="/" aria-label="Back to all stops">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<span class="counter">{String(stop.id).padStart(2, '0')}</span>
</header>
<main class="stop">
<button
type="button"
class="hero"
class:playing={!paused}
onclick={toggle}
aria-label={paused ? `Play ${stop.title}` : `Pause ${stop.title}`}
aria-pressed={!paused}
>
<img src={stop.image} alt="" />
<span class="overlay" aria-hidden="true">
<span class="icon">
{#if paused}
<svg viewBox="0 0 24 24" width="32" height="32">
<path d="M8 5l11 7-11 7V5z" fill="currentColor" />
</svg>
{:else}
<svg viewBox="0 0 24 24" width="32" height="32">
<path d="M7 5h3v14H7zM14 5h3v14h-3z" fill="currentColor" />
</svg>
{/if}
</span>
</span>
</button>
{#if volumeWritable}
<div class="volume" role="group" aria-label="Volume">
<button
type="button"
class="mute-btn"
onclick={toggleMute}
aria-label={muted || volume === 0 ? 'Unmute' : 'Mute'}
aria-pressed={muted}
>
{#if muted || volume === 0}
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
<path d="M16.5 12l3-3-1.4-1.4-3 3-3-3L10.7 9l3 3-3 3 1.4 1.4 3-3 3 3 1.4-1.4z" fill="currentColor" />
</svg>
{:else if volume < 0.5}
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
<path d="M14 8.83v6.34a3 3 0 000-6.34z" fill="currentColor" />
</svg>
{:else}
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
<path d="M14 8.83v6.34a3 3 0 000-6.34z" fill="currentColor" />
<path d="M14 4.5v2.06a5.5 5.5 0 010 10.88v2.06a7.5 7.5 0 000-15z" fill="currentColor" />
</svg>
{/if}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
bind:value={volume}
aria-label="Volume level"
/>
</div>
{/if}
<audio
bind:this={audio}
bind:paused
bind:volume
bind:muted
preload="auto"
src={stop.audio}
>
Your browser does not support the audio element.
</audio>
<h1>{stop.title}</h1>
{#if stop.caption}
<p class="caption">{stop.caption}</p>
{/if}
{#if stop.description}
<div class="description">
{#each stop.description.split(/\n\n+/) as para}
<p>{para}</p>
{/each}
</div>
{/if}
</main>
<nav class="pager" aria-label="Stop navigation">
<button class="pager-btn" disabled={!prev} onclick={() => prev && goto(`/stop/${prev.id}`)}>
<span class="arrow"></span>
<span class="label">{prev ? `${String(prev.id).padStart(2, '0')} ${prev.title}` : ''}</span>
</button>
<button class="pager-btn right" disabled={!next} onclick={() => next && goto(`/stop/${next.id}`)}>
<span class="label">{next ? `${String(next.id).padStart(2, '0')} ${next.title}` : ''}</span>
<span class="arrow"></span>
</button>
</nav>
{/if}
<style>
.missing {
flex: 1;
display: grid;
place-items: center;
gap: 1rem;
text-align: center;
padding: 2rem;
}
.bar {
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: color-mix(in oklab, var(--bg) 80%, transparent);
backdrop-filter: blur(8px);
z-index: 10;
}
.back {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
color: var(--ink);
}
.back:hover {
background: var(--rule);
}
.counter {
font-family: var(--font-serif);
color: var(--ink-mute);
font-variant-numeric: tabular-nums;
}
.stop {
max-width: 720px;
margin: 0 auto;
padding: 0.5rem 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.hero {
position: relative;
display: block;
width: 100%;
padding: 0;
margin: 0;
border: 0;
background: var(--rule);
border-radius: var(--radius-lg);
overflow: hidden;
aspect-ratio: 4 / 3;
box-shadow: var(--shadow);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.hero img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.hero .overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(20, 16, 12, 0.32);
transition: background 0.25s ease, opacity 0.25s ease;
}
.hero.playing .overlay {
background: rgba(20, 16, 12, 0);
opacity: 0;
}
.hero:hover .overlay,
.hero:focus-visible .overlay {
opacity: 1;
background: rgba(20, 16, 12, 0.32);
}
.hero .icon {
display: grid;
place-items: center;
width: 88px;
height: 88px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.94);
color: var(--ink);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
transition: transform 0.15s ease;
}
.hero:active .icon {
transform: scale(0.94);
}
.hero:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.hero .overlay,
.hero .icon {
transition: none;
}
}
h1 {
font-size: clamp(1.6rem, 4vw, 2.25rem);
}
.caption {
margin: 0;
color: var(--ink-mute);
}
.volume {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.25rem 0.25rem;
}
.mute-btn {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
color: var(--ink);
flex-shrink: 0;
}
.mute-btn:hover,
.mute-btn:focus-visible {
background: var(--rule);
}
.mute-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.volume input[type='range'] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: var(--rule);
border-radius: 3px;
outline: none;
margin: 0;
}
.volume input[type='range']:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
}
.volume input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--ink);
cursor: pointer;
border: 0;
box-shadow: var(--shadow);
}
.volume input[type='range']::-moz-range-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--ink);
cursor: pointer;
border: 0;
box-shadow: var(--shadow);
}
.description {
line-height: 1.6;
}
.description p {
margin: 0 0 1em;
}
.pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding: 1rem 1.25rem 1.5rem;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.pager-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.9rem 1rem;
border-radius: var(--radius);
background: var(--bg-elev);
box-shadow: var(--shadow);
color: var(--ink);
text-align: left;
min-height: 56px;
}
.pager-btn.right {
justify-content: flex-end;
text-align: right;
}
.pager-btn[disabled] {
opacity: 0.35;
cursor: not-allowed;
}
.arrow {
font-size: 1.25rem;
color: var(--ink-mute);
}
.label {
font-family: var(--font-serif);
font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

159
web/src/service-worker.ts Normal file
View file

@ -0,0 +1,159 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
// Production service worker for Docent.
//
// Behavior:
// - Install: precache the SvelteKit shell (build + small static files).
// - Activate: drop caches that don't match the current version.
// - Fetch:
// /api/* → network only (auth state must not be cached)
// navigation → cached index.html as offline fallback
// /audio /img → stale-while-revalidate from ASSET_CACHE
// everything → cache-first from SHELL_CACHE
// - Messages:
// { type: 'precache-stops', urls: string[] }
// Bulk-cache a list of URLs into ASSET_CACHE, posting progress back to
// the sender via { type: 'precache-progress', done, total } and a final
// { type: 'precache-done' } when finished.
//
// In dev (`serviceWorker.register: false` + the layout's dev-mode unregister),
// this file is bundled but never registered.
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const SHELL_CACHE = `docent-shell-${version}`;
const ASSET_CACHE = `docent-assets-v1`; // version-stable so audio survives shell upgrades
const shellAssets = [
...build,
...files.filter((f) => !f.startsWith('/audio/') && !f.startsWith('/images/'))
];
sw.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
await cache.addAll(shellAssets);
await sw.skipWaiting();
})()
);
});
sw.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE)
.map((k) => caches.delete(k))
);
await sw.clients.claim();
})()
);
});
sw.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== location.origin) return;
// Never cache API responses — auth state must always be authoritative.
if (url.pathname.startsWith('/api/')) return;
const isContent = url.pathname.startsWith('/audio/') || url.pathname.startsWith('/images/');
event.respondWith(
(async () => {
// Navigation: prefer cached shell; fall back to network.
if (request.mode === 'navigate') {
const shell = await caches.open(SHELL_CACHE);
const cached = (await shell.match('/')) || (await shell.match('/index.html'));
if (cached) return cached;
try {
return await fetch(request);
} catch (err) {
return new Response('offline', { status: 503, statusText: 'offline' });
}
}
const cacheName = isContent ? ASSET_CACHE : SHELL_CACHE;
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
if (isContent) {
// Stale-while-revalidate: return cached, refresh in background.
event.waitUntil(refresh(cache, request));
}
return cached;
}
try {
const res = await fetch(request);
if (res.ok && res.type === 'basic') {
cache.put(request, res.clone());
}
return res;
} catch (err) {
return new Response('offline', { status: 503, statusText: 'offline' });
}
})()
);
});
async function refresh(cache: Cache, request: Request) {
try {
const res = await fetch(request, { credentials: 'same-origin' });
if (res.ok && res.type === 'basic') await cache.put(request, res);
} catch {
// Offline; keep the cached version.
}
}
sw.addEventListener('message', (event) => {
const data = event.data;
if (data?.type !== 'precache-stops' || !Array.isArray(data.urls)) return;
const urls: string[] = data.urls;
const source = event.source;
event.waitUntil(precacheStops(urls, source));
});
async function precacheStops(urls: string[], source: Client | ServiceWorker | MessagePort | null) {
const cache = await caches.open(ASSET_CACHE);
let done = 0;
const total = urls.length;
for (const url of urls) {
try {
const cached = await cache.match(url);
if (!cached) {
const res = await fetch(url, { credentials: 'same-origin' });
if (res.ok && res.type === 'basic') await cache.put(url, res);
}
} catch {
// Network error — skip and continue. UI shows the count.
}
done++;
try {
(source as Client | null)?.postMessage?.({
type: 'precache-progress',
done,
total
});
} catch {
// Sender went away.
}
}
try {
(source as Client | null)?.postMessage?.({ type: 'precache-done', done, total });
} catch {
// Sender went away.
}
}

View file

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

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

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

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

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

14
web/tsconfig.json Normal file
View file

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

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

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