docs: realign blueprint with shipped state + add feature/journey/ideas docs

- PROJECT.md, README.md, TEST_GUIDE.md: status line refreshed; rate-limiter
  doc-vs-code drift fixed; HTML export section rewritten for the SvelteKit-
  static viewer; SSE event names + new events documented; config seed block
  extended with planned toggles + privacy_note; decision log entries added.
- docs/CONCEPT_HTML_VIEWER.md, docs/CONCEPT_MOBILE_UI.md: banner the design
  intent as shipped; point at the source-of-truth code paths.
- docs/CONCEPT_DIASHOW.md: planned-then-shipped design for the live diashow
  (two-queue policy, pluggable transitions, data-mode aware).
- docs/FEATURES.md: capability matrix by role (Guest / Host / Admin) plus
  prose per area (auth, posting, feed, moderation, admin, export, gestures,
  data mode, quotas, privacy note, extensibility).
- docs/USER_JOURNEYS.md: step-by-step flows for every supported scenario,
  including PIN reset by host, data mode, privacy note, gestures, and the
  admin toggles.
- docs/IDEAS.md: speculative extensions (global diashow, reactions,
  multi-tenancy, animation pack, etc.) — explicitly out of v0.16 scope.
- backend/migrations/README.md, frontend/src/lib/README.md: codify the
  "never edit a shipped migration" rule and the lib/ conventions
  (one store per concern, gestures via actions, sheets via ContextSheet,
  transitions as drop-in components).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 14:31:06 +02:00
parent 1685bf105c
commit 9a0ceeced7
11 changed files with 1241 additions and 106 deletions

View File

@@ -42,7 +42,7 @@ A guest scans the QR code on their way in, types their name, and is immediately
Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.
### Status
Idea / Planning phase. Greenfield personal project.
Implementation in progress (~v0.16). Core flows + new features all wired: auth, feed, upload, host/admin dashboards, ZIP + HTML-viewer export, SSE with delta-fetch on reconnect, toggleable rate limits + quotas with live per-user estimate, host PIN reset with one-time modal, data-mode (Saver/Original), Datenschutzhinweis, mobile gestures (long-press context sheet, double-tap to like), and the live Diashow with pluggable transitions. Open items: low-disk alert, event banner UI, chunked resumable upload for very large videos. See [FEATURES.md](docs/FEATURES.md) for the capability matrix.
---
@@ -208,9 +208,9 @@ Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 f
│ Axum HTTP Server (Rust — Single Binary) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ REST API │ │ SSE Engine │ │ Static File Server │ │
│ │ /api/v1/* │ │ /api/v1/ │ │ (SvelteKit build │ │
│ │ │ │ stream │ │ output, embedded) │ │
│ │ REST API │ │ SSE Engine │ │ Media Static Server │ │
│ │ /api/v1/* │ │ /api/v1/ │ │ /media/* (originals, │ │
│ │ │ │ stream │ │ previews, thumbnails) │ │
│ └──────┬──────┘ └──────┬───────┘ └────────────────────────┘ │
│ │ │ │
│ ┌──────▼──────────────────────┐ ┌──────────────────────────┐ │
@@ -245,36 +245,14 @@ Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 f
### Docker Compose Stack
Four services: Postgres, the Rust API (`app`), the SvelteKit Node server (`frontend`), and Caddy. Caddy routes `/api/*` and `/media/*` to the Rust binary and everything else to the SvelteKit server. See [docker-compose.yml](docker-compose.yml) for the authoritative definition.
```yaml
services:
app:
build: ./backend # Multi-stage Rust Dockerfile
env_file: .env
depends_on: [db]
volumes:
- media_data:/media
restart: unless-stopped
db:
image: postgres:16-alpine
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on: [app]
restart: unless-stopped
volumes:
postgres_data:
media_data:
caddy_data:
db: # postgres:16-alpine, persisted in postgres_data volume
app: # ./backend — Rust API on :3000, mounts media_data:/media
frontend: # ./frontend — SvelteKit (adapter-node) on :3001
caddy: # caddy:2-alpine — terminates TLS on :80/:443, proxies app + frontend
```
### Caddyfile
@@ -345,11 +323,14 @@ COMPRESSION_WORKER_CONCURRENCY=2
|-------|---------|---------|
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
| `new-comment` | `{ id, upload_id, body, uploader, created_at }` | Comment posted |
| `new-like` | `{ upload_id, like_count }` | Like toggled |
| `like-update` | `{ upload_id, like_count }` | Like toggled |
| `upload-deleted` | `{ upload_id }` | Upload deleted |
| `event-closed` | `{}` | Host locks uploads |
| `event-opened` | `{}` | Host unlocks uploads |
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete |
| `upload-processed` | `{ upload_id, preview_url, thumbnail_url }` | Server-side compression / preview generation finished |
| `upload-error` | `{ upload_id, message }` | Compression / preview generation failed |
| `export-progress` | `{ type, progress_pct }` | Periodic progress update from an export job |
**Client SSE lifecycle:** `visibilitychange: hidden` → close connection · `visible` → reconnect + delta-fetch via `GET /api/v1/feed/delta?since=`
@@ -372,7 +353,7 @@ COMPRESSION_WORKER_CONCURRENCY=2
| Real-Time | Axum SSE + `tokio::sync::broadcast` | Native, lightweight, perfect for fan-out at this scale |
| ZIP Export | `async-zip` crate | Streaming ZIP generation without buffering the full archive in RAM |
| HTML Export | `minijinja` (Rust templating) | Generates `Memories.html` as a single self-contained file |
| Rate Limiting | `tower-governor` | Token-bucket per IP / per user; config from DB; hot-reloadable |
| Rate Limiting | Custom in-memory sliding-window limiter ([services/rate_limiter.rs](backend/src/services/rate_limiter.rs)) | Per IP / per user; limits read from `config` DB table on each request; hot-reloadable without restart |
| Reverse Proxy | Caddy 2 | Automatic HTTPS via Let's Encrypt; zero certificate management |
| Containerisation | Docker + Docker Compose | Full stack in one file; `.env` for all config; single-command deploy |
| Infrastructure | Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD, 20 TB traffic) | Well-sized; 20 TB/month means post-event bulk downloads are no issue |
@@ -417,9 +398,9 @@ No paid third-party services required.
| Role | Permissions |
|------|------------|
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release) |
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, lock/unlock uploads, release gallery export |
| Admin | All Host permissions + configure storage/file/rate limits, quota tolerance, view disk usage, manage app config, trigger export generation |
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release), pick data mode, read privacy note |
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, demote *other* Hosts to guest (never self), reset guest PINs (planned), lock/unlock uploads, release gallery export |
| Admin | All Host permissions + reset any non-admin PIN, configure storage/file/rate limits with on/off toggles, edit quota tolerance and per-area quota toggles, edit the Datenschutzhinweis, view disk usage, manage app config, trigger export generation |
| Banned Guest | View feed only — cannot upload, like, comment, or export |
### Compliance
@@ -473,37 +454,33 @@ Full-quality originals only. File naming: `{date}_{time}_{username}_{original_fi
### Export Type 2: HTML Offline Viewer (`Memories.zip`)
The HTML export is a **pre-built SvelteKit static app** (`adapter-static`, `ssr=false`) shipped together with the event data. It is a non-interactive, read-only clone of the live feed — same components, same Tailwind tokens, same look — minus auth, upload, comment, and any dashboards. Full design rationale in [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md).
```
Memories/
Memories.html ← single entry point (all CSS + JS inlined; no external deps)
README.txt ← plain-text setup guide (in German, as the UI language)
Photos/ ...
Videos/ ...
index.html ← entry point; open this in any browser
_app/
immutable/... ← hashed JS/CSS bundles (viewer SPA)
data.json ← event metadata, posts, comments, likes, hashtags
media/
{id}_thumb.jpg ← grid thumbnails (≈400 px wide)
{id}_full.jpg/.mp4 ← full-size media for the lightbox
```
**Fully self-contained / true offline:** `Memories.html` is a single file with all CSS and JS inlined as `<style>` and `<script>` tags — no external stylesheets, no CDN scripts, no network requests. All images and videos are referenced via **relative paths** to the sibling `Photos/` and `Videos/` folders — not base64-embedded (that would make the HTML file unworkably large). The ZIP must be unzipped first; relative paths resolve correctly from any location on disk.
**How it works:** open `index.html` in any modern browser. The viewer hydrates client-side, `fetch('./data.json')` loads the event snapshot, all media references are relative paths into `media/`. No network calls, no service required. The ZIP must be unzipped first; the viewer does not run from inside an archive.
**`Memories.html` features:** responsive photo/video grid, fullscreen lightbox, client-side hashtag filter chips, comments + like counts per upload, uploader name + timestamp, warm keepsake album aesthetic — all in self-contained vanilla JS + CSS.
**Viewer feature parity with the live app:**
- List view (chronological) and 3-column grid view with the same toggle as the live app
- Lightbox with swipe navigation
- Hashtag filter chips and grid-view search/autocomplete
- Like counts and comment lists shown as a static snapshot from export time
- All UI strings in German
**`README.txt`** (in German, as the app's UI language):
```
Willkommen in der Event-Galerie!
**Build flow:** The viewer lives at [frontend/export-viewer/](frontend/export-viewer/) and is built ahead of time into [backend/static/export-viewer/](backend/static/export-viewer/) (committed to the repo). The export job embeds those assets via `include_dir!`, generates `data.json` from the database, processes thumbnails/full-sized variants, and streams the ZIP.
So geht's:
1. Entpacke diese ZIP-Datei
(Windows: Rechtsklick > "Alle extrahieren"; Mac: Doppelklick;
Handy: Dateimanager-App verwenden).
2. Öffne die Datei "Memories.html" in deinem Browser
(z. B. Chrome, Safari oder Firefox).
3. Stöbere durch alle Fotos und Videos.
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.
4. Eine Internetverbindung ist nicht nötig.
Alles ist lokal auf deinem Gerät gespeichert.
**Source files (ZIP archive export, see below)** still contain the unmodified originals — the viewer is the polished read-only experience, the ZIP is the raw archive.
Viel Freude mit den Erinnerungen!
```
For video-heavy events the ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")
For video-heavy events the viewer ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")
---
@@ -625,7 +602,9 @@ CREATE TABLE "user" (
recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN)
total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- No UNIQUE(event_id, display_name) — PIN disambiguates name collisions
-- Case-insensitive UNIQUE on (event_id, LOWER(display_name)) added by migration 007.
-- Name collisions are rejected on join; the user is prompted to recover with their PIN
-- (or to pick a different name).
);
-- ─────────────────────────────────────────
@@ -735,7 +714,18 @@ INSERT INTO config (key, value) VALUES
('export_rate_per_day', '3'),
('quota_tolerance', '0.75'),
('estimated_guest_count', '100'),
('compression_concurrency', '2')
('compression_concurrency', '2'),
-- Planned (see docs/FEATURES.md §2.6 and §2.7):
-- on/off switches for rate limits and quotas, and the privacy note text
('rate_limits_enabled', 'true'),
('upload_rate_enabled', 'true'),
('feed_rate_enabled', 'true'),
('export_rate_enabled', 'true'),
('join_rate_enabled', 'true'),
('quota_enabled', 'true'),
('storage_quota_enabled', 'true'),
('upload_count_quota_enabled', 'true'),
('privacy_note', '') -- free text, whitespace + newlines preserved, no HTML
ON CONFLICT (key) DO NOTHING;
```
@@ -1173,7 +1163,11 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
| Decision | Chosen | Rationale |
|----------|--------|-----------|
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page | Simple for non-technical guests; no email required |
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page; Host/Admin can issue a fresh PIN via the user list when a guest loses it entirely | Simple for non-technical guests; no email required; Host-mediated reset preserves the no-email identity model |
| Host demotion authority | Hosts can demote other Hosts (never themselves); Admin can demote anyone non-admin | Avoids requiring an Admin for every staffing change at the event |
| Privacy note | Free-text, plain (no HTML), admin-edited, rendered preformatted in My Account | Many events need a per-event privacy statement; preformatted text avoids any markup-injection risk |
| Data mode | Per-device `localStorage` setting (Saver / Original), default Saver | A guest can be on cellular on one device and Wi-Fi on another; per-device is the right scope |
| Rate-limit & quota toggles | On/off switches plus numeric values in the `config` table | Lets the Admin disable enforcement for testing or trusted events without redeploying |
| Admin dashboard path | `/admin` (standard route) | Correct auth checks are the security; obscure paths add no meaningful protection |
| ZIP contents | Full-quality originals only (Photos + Videos folders) | Clean and simple; no metadata JSON |
| HTML export assets | Fully offline (relative paths, CSS/JS inlined) | True offline experience; no external dependencies |
@@ -1215,7 +1209,7 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
| `uuid` | UUID v7 (time-sortable) |
| `serde` / `serde_json` | Serialisation |
| `tower` / `tower-http` | Middleware stack (CORS, compression, static files, request tracing) |
| `tower-governor` | Token-bucket rate limiting (per IP and per user) |
| (custom limiter, no crate) | Token-bucket / sliding window built in-tree at [services/rate_limiter.rs](backend/src/services/rate_limiter.rs) |
| `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
| `minijinja` | HTML export template rendering (`Memories.html`) |