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`) |

View File

@@ -144,18 +144,18 @@ See [.env.example](.env.example) for the full list with descriptions and default
┌───▼────┐ ┌─────▼──────┐
│ app │ │ frontend │
│ :3000 │ │ :3001 │
│ (Rust) │ │ (SvelteKit)│
│ (Rust) │ │(SvelteKit)
└───┬────┘ └────────────┘
┌───▼────┐
│ db │
│ :5432 │
│(Postgres│
│(Postgres)
└────────┘
```
- `/api/*` and `/media/*` → Rust backend
- Everything else → SvelteKit frontend
- Everything else → SvelteKit frontend (`adapter-node`)
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
---
@@ -176,19 +176,35 @@ The `/media` volume holds originals, previews, thumbnails, exports, and DB backu
## Development Roadmap
Done:
- [x] Project blueprint & architecture
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
- [ ] DB schema + SQLx migrations
- [ ] Auth flow (join, JWT, PIN recovery)
- [ ] Upload pipeline (multipart → compression worker → SSE broadcast)
- [ ] Client upload queue (IndexedDB, progress, retry)
- [ ] Gallery feed (grid, SSE, hashtag filters)
- [ ] Camera capture (`getUserMedia`)
- [ ] Host Dashboard
- [ ] Admin Dashboard
- [ ] Export engine (ZIP + offline HTML)
- [ ] Rate limiting middleware
- [ ] End-to-end test event (10+ real devices)
- [x] DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
- [x] Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
- [x] Upload pipeline (multipart → compression worker via `tokio::sync::Semaphore` → SSE broadcast)
- [x] Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
- [x] Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
- [x] Camera capture (`getUserMedia` with front/back toggle, photo + `MediaRecorder` video)
- [x] Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
- [x] Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
- [x] Export engine: streaming ZIP + SvelteKit-static HTML viewer (see [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md))
- [x] Custom rate limiter (per-endpoint, hot-reloadable from `config` table)
- [x] Mobile-first redesign (bottom nav + FAB, see [docs/CONCEPT_MOBILE_UI.md](docs/CONCEPT_MOBILE_UI.md))
Open:
- [ ] Dynamic per-user storage quota enforcement (formula in [PROJECT.md §12](PROJECT.md); only tracking exists today)
- [ ] Own-upload deletion UI in the lightbox (backend route exists)
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
- [ ] Individual file download button per post
- [ ] Low-disk alert (< 10 GB free)
- [ ] Event banner / cover image
- [ ] Chunked resumable upload for files > 100 MB
- [ ] Shared Tailwind config between main app and export-viewer
- [ ] End-to-end test event (10+ real devices on cellular)
See [docs/FEATURES.md](docs/FEATURES.md) for the up-to-date capability matrix by role.
Speculative / v2+ ideas live in [docs/IDEAS.md](docs/IDEAS.md).
---

View File

@@ -16,45 +16,47 @@ Please test each step in order and report any errors (console errors, wrong text
9. ✅ Expected: Overlay disappears
### Step 3 — Feed & navigation
10. ✅ Expected: Feed shows "Noch keine Fotos." empty state with an upload button
11. ✅ Expected: Top-right has an **upload button** (blue) and a **person icon** link
10. ✅ Expected: Feed shows the empty state ("Noch keine Fotos." or similar) with a hint to upload
11. ✅ Expected: A **persistent bottom nav** is visible with three slots — 🏠 **Feed** on the left, an elevated 📷+ **FAB** in the center, 👤 **Account** on the right
### Step 4 — My Account page
12. Click the **person icon** in the top-right
12. Tap the **👤 Account** tab in the bottom nav
13. ✅ Expected: `/account` page shows your name (`Max`), a blue "Gast" badge, session expiry date, and your PIN displayed large in an amber box
14. Click **Kopieren** — check clipboard contains your PIN
14. Tap **Kopieren** — check the clipboard contains your PIN
15. ✅ Expected: Button briefly shows "Kopiert!"
16. Click **Zur Galerie** to go back to the feed
16. Tap the 🏠 **Feed** tab to go back
### Step 5 — Upload
17. Click **Hochladen** — this takes you to `/upload`
18. Try uploading a photo from your device library
19. ✅ Expected: Photo appears in queue with a progress bar, then completes
20. Go back to `/feed` — ✅ Expected: your photo appears in the feed grid
17. Tap the central **📷+ FAB** in the bottom nav
18. ✅ Expected: A bottom sheet slides up offering **Kamera** and **Galerie** options
19. Tap **Galerie** → pick a photo from your device library
20. ✅ Expected: Preview screen (`/upload`) shows the staged file with an optional caption / hashtag editor
21. Tap **Hochladen**
22. ✅ Expected: You return to the feed immediately; the FAB shows a small badge while uploading; the photo appears in the feed once processing completes
### Step 6 — Onboarding guide not shown again
21. Reload the page at `/feed`
22. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
23. Reload the page at `/feed`
24. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
### Step 7 — Recover (open a private/incognito window)
23. Open a new **private/incognito** window at **http://localhost:5173/recover**
24. Enter the same name (`Max`) and the PIN you copied
25. ✅ Expected: You're redirected to the feed with the same account
25. Open a new **private/incognito** window at **http://localhost:5173/recover**
26. Enter the same name (`Max`) and the PIN you copied
27. ✅ Expected: You're redirected to the feed with the same account
### Step 8 — Upload rate-limit auto-retry
26. Upload more than 20 photos in one hour to trigger the rate limit
27. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
28. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
29. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
28. Upload more than the per-hour limit of photos in quick succession to trigger the rate limit
29. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
30. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
31. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
### Step 9 — Name uniqueness (case-insensitive)
30. In a private/incognito window go to **http://localhost:5173/join**
31. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
32. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
33. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
34. Enter your PIN from Step 1 and click **Anmelden**
35. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
36. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
32. In a private/incognito window go to **http://localhost:5173/join**
33. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
34. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
35. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
36. Enter your PIN from Step 1 and click **Anmelden**
37. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
38. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
---

View File

@@ -0,0 +1,28 @@
# Migrations
SQLx-managed Postgres migrations. Each `NNN_topic.up.sql` has a matching
`NNN_topic.down.sql`. Run by `sqlx::migrate!()` at app start.
## Rules
1. **Never edit a shipped migration.** If a column needs to change or a fix needs to
land, write a new migration. Production has already applied the old one and SQLx
tracks each by checksum — editing in place will fail to apply on existing databases.
2. **Always pair `.up.sql` with a `.down.sql`.** Reverts may not be perfect (data
loss is sometimes unavoidable) but the file must exist and do the best it can.
3. **Prefer additive changes.** New columns, new tables, new keys in `config`. Drop /
rename only when there is no alternative.
4. **No business logic in migrations.** Schema + seeds only. Anything that needs Rust
code goes in a one-off binary, not a migration file.
5. **One concern per migration.** Easier to revert. Easier to read in `git log`.
## Numbering
Zero-padded three digits, monotonically increasing. The next free number lives at the
bottom of the directory listing — pick that.
## Seed-only migrations
When you only need to add `config` keys (feature flags, defaults), use
`INSERT … ON CONFLICT DO NOTHING` so existing operator overrides survive. See
`009_feature_toggles.up.sql` for the canonical shape.

196
docs/CONCEPT_DIASHOW.md Normal file
View File

@@ -0,0 +1,196 @@
# Live Diashow Concept
> **Status: SHIPPED.** Implementation lives at
> [frontend/src/lib/diashow/](../frontend/src/lib/diashow/) and
> [frontend/src/routes/diashow/+page.svelte](../frontend/src/routes/diashow/+page.svelte).
> Treat this doc as the design reference; code is the source of truth.
## Goal
A fullscreen, auto-advancing slideshow that any user can start from their device. Suitable
for a venue projector or TV running off a single phone/laptop. Two behaviours combine in one
view:
1. **Live drain** — when a new post is uploaded mid-event, it appears on the next slide
transition.
2. **Shuffle fallback** — between new uploads (and they will be rare in quiet stretches), the
diashow rotates through everything posted so far, in shuffled order.
The user does **not** need to be Host or Admin. Any guest can start the diashow on their own
device. There is no global "the room's diashow" — each device runs its own (though they will
look very similar if started at the same time).
---
## Behavioural model
Two FIFO queues live in the client:
| Queue | Source | Drain priority |
|----------------|-------------------------------------------|----------------|
| `liveQueue` | SSE events for new processed uploads | First |
| `shuffleQueue` | Snapshot of all known uploads, shuffled | When live empty |
### Slide-advance algorithm
```ts
function nextSlide(): Slide | null {
// 1. Drain live posts first (FIFO).
if (liveQueue.length) return liveQueue.shift()!;
// 2. Refill shuffle queue from `allKnown` if drained.
if (!shuffleQueue.length) {
shuffleQueue = shuffle(
[...allKnown.values()].filter(s => !recentlyShown.has(s.id))
);
}
return shuffleQueue.shift() ?? null;
}
```
A small ring buffer `recentlyShown` (last ~5 IDs) prevents the same picture coming back
within seconds when the shuffle queue is rebuilt.
### Live insertion
```ts
sseClient.on('upload-processed', (msg) => {
if (allKnown.has(msg.upload_id)) return;
const slide = await fetchUpload(msg.upload_id); // or use payload directly
allKnown.set(slide.id, slide);
liveQueue.push(slide);
});
```
Listen on **`upload-processed`**, not `new-upload` — the preview/thumbnail must exist before
we try to display the slide.
### Deletion / hiding
```ts
sseClient.on('upload-deleted', ({ upload_id }) => {
allKnown.delete(upload_id);
liveQueue = liveQueue.filter(s => s.id !== upload_id);
shuffleQueue = shuffleQueue.filter(s => s.id !== upload_id);
if (currentSlide?.id === upload_id) advanceImmediately();
});
```
Hidden uploads (banned user with `uploads_hidden=true`) need either a new SSE event or to
piggyback `upload-deleted` for diashow purposes. Simplest path: emit `upload-deleted` for
hidden posts to all subscribers (the live feed already filters them via `v_feed`, so this is
a backwards-compatible signal).
---
## Initial pool
On start:
1. Call `GET /api/v1/feed?limit=200` (or paginate-and-drain in the background while the
diashow runs).
2. Push every returned upload into `allKnown`.
3. Build the first `shuffleQueue` from it.
4. Open the SSE stream and route `upload-processed` / `upload-deleted` into the queues.
If the event is empty, show a friendly placeholder:
*"Noch keine Beiträge — neue erscheinen automatisch."*
---
## Frontend surface
### Entry point
A small **Diashow / "Präsentation starten"** action visible:
- In the feed header (icon next to the list/grid toggle) on tablet/desktop layouts.
- In the Account page on mobile (less prominent — diashow is primarily a venue-screen
feature).
Tapping it navigates to the `/diashow` route (full-screen, bottom nav hidden).
### Route: `/diashow`
- Fullscreen request via `element.requestFullscreen()` after first user gesture.
- **Screen Wake Lock**: `navigator.wakeLock.request('screen')` to keep the screen on during
long shows. Renew on `visibilitychange` if needed.
- Default dwell: **6 seconds** per slide. Configurable via overlay control: 3 / 6 / 10 s.
- Tap or `Escape` reveals an overlay with: pause/resume, dwell selector, **transition
picker**, exit.
- Transitions: crossfade (≈400 ms) by default; Ken Burns, zoom, slide, etc. available as
pluggable components — see "Pluggable transitions" below.
- Videos: autoplay muted, fit-to-screen, advance on `ended` or after `max(dwell, 12 s)`,
whichever first.
- Preload the next slide's media into a hidden `<img>`/`<video>` to avoid flashing.
- **Media source respects the user's data mode**
(see [FEATURES.md §2.5](FEATURES.md)). In Saver mode the diashow loads `preview_url`;
in Original mode it loads the original. The data-usage warning is shown once when the
mode is toggled in My Account — the diashow itself stays silent.
### Pluggable transitions
Each transition is a **drop-in Svelte component** under
`frontend/src/lib/diashow/transitions/` (path finalised at implementation time). The
interface is intentionally tiny:
```ts
// pseudocode — the real shape lands with the feature
export interface SlideTransition {
id: string; // 'crossfade', 'kenburns', ...
label: string; // shown in the dwell/transition picker
durationMs: number; // default; can be overridden per-event
// The actual motion is implemented by mounting the component with `from` / `to` slides.
}
```
A small registry maps `id → component`; the settings popover renders that registry as a
dropdown. **Adding a new animation is one new file plus one line in the registry — no
other changes required.** This is the maintainability target called out in
[FEATURES.md §2.9](FEATURES.md) and [IDEAS.md](IDEAS.md) ("Animation pack").
The same pattern is a candidate for whole-event "themes" later — a bundle of (transition
+ dwell + optional background-music defaults).
### Edge cases
| Case | Behaviour |
|--------------------------------------------|--------------------------------------------------------|
| Empty event | Placeholder card; live SSE will trigger the first show |
| All known uploads are still compressing | Same placeholder — wait for `upload-processed` |
| Network drop / SSE reconnect | EventSource auto-reconnects; queues survive |
| Current slide gets deleted | Advance immediately |
| Event is closed (no new uploads possible) | Diashow keeps running on shuffle queue indefinitely |
| Banned user's content (`uploads_hidden`) | Removed via `upload-deleted` signal (see Deletion) |
---
## Backend changes
**Essentially none.** The diashow reuses:
- `GET /api/v1/feed` (initial pool)
- `GET /api/v1/stream` SSE (`upload-processed`, `upload-deleted`)
Optional small additions:
1. Emit `upload-deleted` (or a new `upload-hidden`) when a host bans a user with
`hide_uploads=true`, so that diashow clients can scrub the relevant slides without
reloading.
2. Consider raising the cap on `GET /api/v1/feed?limit=` for diashow clients (or paginate
the initial pool in the background — preferred, no API change needed).
---
## Future extensions (not in scope for v1)
The big ones live in [IDEAS.md](IDEAS.md) under "Diashow extensions" — most notably the
**global / synchronised diashow** where multiple screens share one server-side cursor.
Short list of others kept here for context:
- **Curated highlights mode** — only show uploads tagged with a specific hashtag, or
Host-pinned "Story" uploads (depends on the story-highlights feature).
- **Audio bed** — host can pick a background track; mute videos so they don't fight the
music.
- **Slide caption / uploader chyron** — small lower-third with the uploader's name and
caption. Out by default to keep the visual clean.

View File

@@ -1,5 +1,15 @@
# HTML Viewer Export Concept
> **Status: IMPLEMENTED.** Viewer source: [frontend/export-viewer/](../frontend/export-viewer/).
> Pre-built output committed to [backend/static/export-viewer/](../backend/static/export-viewer/).
> Backend export pipeline: [backend/src/services/export.rs](../backend/src/services/export.rs).
>
> Outstanding follow-ups:
> - The export-viewer's `tailwind.config.js` does not yet extend the main app's config. Visual
> drift risk — see "Shared Tailwind Config" section below.
> - Service-worker (offline PWA caching) is still "Future" — fine for v1 since the ZIP is
> already fully offline by virtue of relative paths.
## Overview
The HTML Viewer export produces a **self-contained offline ZIP** that is a read-only clone

View File

@@ -1,10 +1,20 @@
# Mobile-First UI/UX Redesign Concept
> **Status: IMPLEMENTED (v0.15).** This document captures the design intent. The redesign
> has shipped — see [BottomNav.svelte](../frontend/src/lib/components/BottomNav.svelte),
> [UploadSheet.svelte](../frontend/src/lib/components/UploadSheet.svelte),
> [CameraCapture.svelte](../frontend/src/lib/components/CameraCapture.svelte),
> [feed/+page.svelte](../frontend/src/routes/feed/+page.svelte),
> [account/+page.svelte](../frontend/src/routes/account/+page.svelte),
> [host/+page.svelte](../frontend/src/routes/host/+page.svelte),
> [admin/+page.svelte](../frontend/src/routes/admin/+page.svelte). Use this doc as the design
> reference; treat code as the source of truth for current behaviour.
## Overview
EventSnap is intended for mobile use at live events, but the current UI is desktop-oriented.
This document describes a full mobile-first redesign covering navigation, the feed/gallery,
account page, host dashboard, and admin dashboard.
EventSnap is intended for mobile use at live events. This document describes the full
mobile-first design covering navigation, the feed/gallery, account page, host dashboard,
and admin dashboard.
---
@@ -406,6 +416,46 @@ Same structure as Host Nutzerverwaltung, with any additional admin-only actions
---
## Touch gestures vs. desktop buttons (planned extension)
Where a gesture is more ergonomic on mobile than a button, EventSnap prefers the gesture
on touch and mirrors it as an explicit button on desktop. Inspired by Instagram, WhatsApp
and Telegram — long-press for context, swipe to dismiss, double-tap to react.
| Surface | Touch gesture | Desktop equivalent |
|-----------------------------------------|-------------------------------------|------------------------------------------|
| Post card | Long-press → context bottom sheet | ⋯ kebab in the card corner |
| Comment row | Long-press → bottom sheet | ⋯ next to the comment timestamp |
| User row (Host / Admin dashboards) | Long-press → bottom sheet | Inline buttons (ban, promote, reset PIN) |
| Lightbox | Swipe left / right | ←/→ arrow keys + on-screen chevrons |
| Lightbox | Swipe down to close | Esc + ✕ button |
| Bottom sheet | Swipe down to dismiss | Click backdrop or × in the sheet header |
| Feed | Pull to refresh | Refresh icon next to the view toggle |
| Post (any) | Double-tap → like | Click the heart icon |
**Discoverability rule:** every gesture must have a visible button equivalent on the same
page. Gestures are never the *only* path to an action. Helps with stylus users,
accessibility, and people who don't know the gesture vocabulary.
**Context bottom-sheet pattern** (used by every long-press above):
```
┌──────────────────────────────────┐
│ ▬ (drag handle) │
│ │
│ 🗑 Löschen │ ← destructive action red
│ 📥 Original anzeigen │
│ 🔗 Teilen │
│ 🚩 Melden │ (only on others' content)
│ │
│ [ Abbrechen ] │
└──────────────────────────────────┘
```
Each sheet is composed from a shared `<ContextSheet>` component (planned) with a single
`actions: ContextAction[]` prop. Adding a new gesture context = define the actions array
where needed. Drop-in, one file.
## Design Principles Summary
| Principle | Application |
@@ -418,3 +468,4 @@ Same structure as Host Nutzerverwaltung, with any additional admin-only actions
| No role clutter in nav | Role links only in Account, bar stays clean |
| Collapsible sections | Long management pages stay usable on small phones |
| Inner tabs for complex pages | Admin dashboard split across 4 focused tabs |
| Gestures over chrome | Long-press for context menus, swipe to dismiss, double-tap to react — always with a button fallback for desktop and accessibility |

313
docs/FEATURES.md Normal file
View File

@@ -0,0 +1,313 @@
# EventSnap — Feature Set & Capability Matrix
This document is the authoritative, code-cross-checked summary of what EventSnap can do today
and what is planned. For the design rationale of each area see [PROJECT.md](../PROJECT.md);
for journeys / step-by-step flows see [USER_JOURNEYS.md](USER_JOURNEYS.md).
Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ out of scope**
---
## 1. Capability matrix by role
| Capability | Guest | Host | Admin | Notes |
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
| **Onboarding & sessions** | | | | |
| Join via shared event link / QR code | ✓ | ✓ | ✓ | Name-only registration; server issues JWT + 4-digit PIN |
| First-visit guided tour (4 steps) | ✓ | ✓ | ✓ | Dismissed once, flag in `localStorage` |
| Persistent 30-day session | ✓ | ✓ | ✓ | JWT in `localStorage`; refreshed on activity |
| Sign in on another device using name + PIN | ✓ | ✓ | ✓ | 3 wrong PINs → 15-min lockout |
| "Ich habe bereits einen Account" link on the join page | ✓ | ✓ | ✓ | Small inline link → `/recover` (name + PIN) |
| View / copy own PIN any time ("My Account") | ✓ | ✓ | ✓ | Read from `localStorage`; never sent back from the server |
| Log out / "Leave event" | ✓ | ✓ | ✓ | Confirmation bottom-sheet; invalidates the session row |
| Rename own display name | ◯ | ◯ | ◯ | Not yet wired; PIN-protected change |
| Pick **data mode** (Saver / Original) in My Account | ✓ | ✓ | ✓ | Saver = compressed (default). Original = full files + data-usage warning. Applies to feed and diashow. Per-device, in `localStorage` |
| Read the **Datenschutzhinweis** (privacy note) | ✓ | ✓ | ✓ | Free text set by Admin during setup; rendered preformatted in My Account; first-visit guide briefly points to it |
| Admin password login (separate route) | | | ✓ | 1-day token; lives in `sessionStorage` |
| Reset another user's PIN (one-time display modal) | | ✓* | ✓ | Host: guests only. Admin: hosts + guests. New PIN shown once to the requester; user signs in with it; PIN is stored on their device on next login. \* Host cannot reset another Host's PIN |
| | | | | |
| **Posting** | | | | |
| Pick photos/videos from device library (multi-select) | ✓ | ✓ | ✓ | Bottom-sheet source picker |
| In-app camera capture (`getUserMedia`) | ✓ | ✓ | ✓ | Front/back toggle, photo, `MediaRecorder` video |
| Caption + `#hashtag` extraction | ✓ | ✓ | ✓ | Optional; hashtags parsed server-side |
| Edit own caption / hashtags after upload | ✓ | ✓ | ✓ | `PATCH /api/v1/upload/{id}` |
| Delete own upload | ✓ | ✓ | ✓ | Long-press on the card (or the kebab menu on desktop) → **Löschen** in the context sheet. Comment-style trash icon also available on each post elsewhere as it's added. |
| Delete own comment | ✓ | ✓ | ✓ | Trash icon in lightbox |
| Background upload queue (survives reload) | ✓ | ✓ | ✓ | IndexedDB-persisted, sequential, retry |
| Rate-limit auto-resume banner | ✓ | ✓ | ✓ | Countdown above bottom nav; resumes when window opens |
| Chunked / resumable upload for > 100 MB | ◯ | ◯ | ◯ | Planned (v1.x) |
| | | | | |
| **Feed & social** | | | | |
| Chronological list feed (full-width cards) | ✓ | ✓ | ✓ | Default view, infinite scroll |
| 3-column grid feed with toggle | ✓ | ✓ | ✓ | Video play badges, duration |
| Search & autocomplete (uploader + hashtag) | ✓ | ✓ | ✓ | Grid view; derived in-memory, no extra API calls |
| Active filter chips (OR within type, AND across types) | ✓ | ✓ | ✓ | Multiple hashtags = OR; uploader + hashtag = AND |
| Fullscreen lightbox with swipe | ✓ | ✓ | ✓ | Swipe navigates the filtered set |
| Like / unlike any post | ✓ | ✓ | ✓ | Single toggle; SSE `like-update` |
| Read comments on any post | ✓ | ✓ | ✓ | |
| Add a comment | ✓ | ✓ | ✓ | Hashtags in comments also parsed |
| Real-time feed via SSE | ✓ | ✓ | ✓ | `new-upload`, `new-comment`, `like-update`, `upload-processed`, `pin-reset`, `event-updated`, etc. |
| Pause SSE when app is backgrounded | ✓ | ✓ | ✓ | Page Visibility API; reconnect on foreground |
| Delta-fetch (`/feed/delta?since=`) on reconnect | ✓ | ✓ | ✓ | Runs on every visibility-restore; merges new + deleted uploads |
| Individual file download button per post | ✓ | ✓ | ✓ | "Original anzeigen" in the post context sheet — streams via `/api/v1/upload/{id}/original` |
| | | | | |
| **Live diashow** (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) | | | | |
| Start fullscreen auto-advancing slideshow | ✓ | ✓ | ✓ | Two queues: live (SSE) drains first, shuffle as fallback. Crossfade + Ken Burns transitions; pluggable. Respects data mode. |
| | | | | |
| **Moderation (Host)** | | | | |
| List all event users | | ✓ | ✓ | Includes upload count, total bytes |
| Ban / unban a user | | ✓ | ✓ | Modal asks: hide their existing uploads, or keep visible? |
| Delete any upload | | ✓ | ✓ | |
| Delete any comment | | ✓ | ✓ | |
| Promote guest to Host | | ✓ | ✓ | |
| Demote Host to guest | | ✓ | ✓ | Hosts may demote other Hosts. Cannot demote self. Admins cannot be demoted by hosts. |
| Reset a guest's PIN (Host) / any non-admin PIN (Admin) | | ✓ | ✓ | New PIN shown once in modal; Host shows/shares it with the guest |
| Lock new uploads ("Event schließen") | | ✓ | ✓ | Likes + comments + browsing remain open |
| Unlock new uploads | | ✓ | ✓ | |
| Release gallery → trigger export generation | | ✓ | ✓ | Enqueues both ZIP and HTML-viewer jobs |
| | | | | |
| **Instance configuration (Admin)** | | | | |
| Live disk-usage / user / upload / banned stats | | | ✓ | Stats tab; queries `sysinfo` |
| Edit per-file limits (image MB / video MB) | | | ✓ | Config tab; hot-reloadable from DB |
| Edit per-endpoint rate limits | | | ✓ | Upload/hour, feed/min, export/day |
| Toggle **all** rate limits on/off | | | ✓ | Master switch — when off, every limiter passes through |
| Toggle **individual** rate limits on/off | | | ✓ | Per-endpoint switch (upload / feed / export / join) |
| Toggle quota enforcement on/off (master + per-area) | | | ✓ | Master switch + per-area (storage / upload count). When off, nothing is enforced |
| Edit quota tolerance | | | ✓ | Live `(free_disk × tolerance) / active_uploaders` formula enforced on upload |
| Edit estimated guest count | | | ✓ | |
| Edit compression-worker concurrency | | | ✓ | |
| Edit **Datenschutzhinweis** (privacy note, free text) | | | ✓ | Plain text, whitespace + newlines preserved, no HTML. SSE `event-updated` broadcasts edits live. |
| Inspect export job list & progress | | | ✓ | |
| Low-disk alert (< 10 GB free) | | | ◯ | Planned |
| Event banner / cover image | | | ◯ | DB column exists, no UI |
| | | | | |
| **Quota visibility (Guest-facing)** | | | | |
| Show current per-user quota estimate | ✓ | ✓ | ✓ | "Du hast X MB von Y MB genutzt." in My Account and on the upload screen. Computed from the live formula. Hidden when quota enforcement is toggled off |
| | | | | |
| **Export** | | | | |
| Wait at locked export page until released | ✓ | ✓ | ✓ | Friendly "not yet available" copy |
| Download `Gallery.zip` (full-quality originals) | ✓ | ✓ | ✓ | Streamed via `async-zip`; `Photos/` + `Videos/` folders |
| Download `Memories.zip` (offline HTML viewer) | ✓ | ✓ | ✓ | Self-contained SvelteKit-static app + `data.json` + `media/` |
| HTML-export in-app guide modal before download | ✓ | ✓ | ✓ | Explains: unzip first, open `index.html` |
| Per-IP export download rate limit (3 / day) | ✓ | ✓ | ✓ | |
| | | | | |
| **Banned guest** (subset) | | | | |
| Cannot upload, like, or comment | ✗ | | | Returns HTTP 403 |
| Can browse the feed | ✓ | | | |
| Can still download the export once released | ✓ | | | Spec design choice |
---
## 2. Feature areas in detail
### 2.0 Touch-first interactions (mobile) vs. buttons (desktop)
EventSnap is mobile-first. Where it makes the UI cleaner, primary actions are reached via
**gestures** on touch devices, with conventional **buttons** mirrored on tablet/desktop:
- **Long-press on a post** → context bottom sheet ("Löschen", "Original anzeigen", report,
share). On desktop the same actions are a kebab/⋯ menu in the card's corner.
- **Long-press on a comment** → context sheet with "Löschen" (own comments only) and
"Kopieren".
- **Swipe left/right in the lightbox** → navigate the filtered set.
- **Swipe down on a bottom sheet** → dismiss.
- **Pull-to-refresh on the feed** → force a delta-fetch even when SSE is up.
- **Double-tap on a post** → like (Instagram-style), with a heart-burst animation. Tap the
heart icon as the explicit alternative.
Design rule: **gestures should always have a discoverable button equivalent** somewhere on
the page, so the app stays usable on a stylus, mouse, or for users who don't know the
gesture vocabulary. Take inspiration from Instagram, WhatsApp, and Telegram for the
"feels right" baseline — long-press for context, swipe to dismiss, double-tap to react.
### 2.1 Authentication and identity
EventSnap's identity model is "**a name + a 4-digit PIN, scoped to one event**". There is no
email, no password, no account portal.
- **Joining.** On the join page the user types a display name. The server creates a `user`
row, generates a 4-digit PIN, stores `bcrypt(pin)`, signs a 30-day JWT, and returns the
PIN in clear text **once** in the response. The client persists the JWT and the PIN to
`localStorage`.
- **PIN visibility.** The PIN is shown to the user *prominently* once at registration, and
remains visible in the My-Account page (read directly from `localStorage` — never sent
back from the server).
- **Returning on the same device.** A valid JWT in `localStorage` → straight to the feed.
- **Returning on a new device.** Type the name on the join page → server detects the
existing user → user is prompted for their PIN. `bcrypt.verify` → new JWT, fresh device
is now bound to the same account.
- **Lockout.** 3 wrong PIN attempts → 15-minute lockout per user (`pin_locked_until` column,
migration 006).
- **Name collisions.** Names are unique per event (case-insensitive, migration 007). If
someone tries to join with a name already taken, the join page automatically presents the
PIN-recovery form for that account ("Already taken — sign in instead, or pick another
name"). The join page also surfaces an explicit **"Ich habe bereits einen Account"**
link routing to `/recover` for users who already know they want to sign in.
- **PIN reset by Host / Admin.** Planned. If a guest loses their PIN and `localStorage` is
gone everywhere, a Host (for guests) or Admin (for hosts and guests) can hit a
**PIN zurücksetzen** action in the user list. A fresh PIN is generated server-side, its
bcrypt stored, and the plaintext is shown **once** in a modal to the requesting
operator. The operator shows / sends the new PIN to the user, who then signs in via
`/recover` — the PIN is persisted to `localStorage` on that device on a successful
recovery, exactly like a brand-new join. Host cannot reset another Host's PIN; only
Admins can.
- **Roles.** `guest` (default), `host`, `admin`. The Admin role is seeded from the
`ADMIN_PASSWORD_HASH` env var; admins log in at `/admin/login` with a password (separate
JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by an admin. **Hosts
may also demote other Hosts to guests** (planned) — but never themselves, to avoid
locking the event out of moderation. Admins can demote anyone except admins.
### 2.2 Posting pipeline
The upload pipeline is built for flaky mobile networks:
1. **Source picker** (bottom sheet from the FAB): camera or gallery.
2. **Preview screen** — staged files appear as thumbnails; user can remove individuals, add
a caption (with `#hashtags`), and tap quick-tag chips derived from the caption.
3. **Submit** — the client immediately returns to the feed (optimistic UX). Files enter an
IndexedDB-persisted queue.
4. **Queue worker** — runs sequentially (one upload at a time), per-file progress via XHR.
Survives reloads and app backgrounding. A red badge on the FAB indicates active uploads.
5. **Server-side processing** — multipart received → MIME-sniffed via `infer` → size
validated → original stored → compression worker (bounded by a `tokio::sync::Semaphore`)
resizes to an 800-px preview (images via the `image` crate + `oxipng` for PNG) or
extracts a frame at the 1-second mark (videos via `ffmpeg`). Status is tracked in the new
`compression_status` column (migration 008).
6. **Real-time fan-out**`new-upload` SSE first (no preview yet), then `upload-processed`
when the preview/thumbnail is ready, so clients can swap a placeholder for the real image
without re-fetching the feed.
7. **Rate-limit-aware client** — when the server returns HTTP 429 with `Retry-After`, the
queue parks remaining items and shows an inline countdown banner; uploads resume
automatically.
### 2.3 Feed
- **Two layouts** — chronological list (default) and 3-column grid. Toggle in the header.
- **List view** has no search; it's the consumption-focused mode (like an Instagram feed).
- **Grid view** has the search bar — autocomplete suggestions are computed in-memory from
the loaded uploads, so typing never hits the server.
- **Filter chips** — multiple hashtags combine with OR; multiple uploaders combine with OR;
hashtag + uploader combine with AND. Matches the redesign concept exactly.
- **Lightbox** — fullscreen view, swipe navigates the *filtered* set, with embedded
like/comment UI.
- **Real-time** — SSE delivers `new-upload`, `upload-processed`, `like-update`,
`new-comment`, `upload-deleted`, `event-closed`/`event-opened`, `export-progress`,
`export-available`. Client pauses SSE on `visibilitychange: hidden` and reopens on visible.
### 2.4 Host / Admin tooling
- **Host dashboard** — three collapsible sections: Stats, Event-Einstellungen,
Nutzerverwaltung. Ban modal asks explicitly whether to hide the user's existing uploads
from the public feed. Promote/demote, lock/unlock, release-gallery are one-tap.
- **Admin dashboard** — same dashboard plus three more inner tabs (Stats, Config, Export,
Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated
guest count, and compression concurrency — all stored in the `config` table and read on
each request, so changes take effect without a restart. Disk widget pulls from the
`sysinfo` crate live.
### 2.5 Data mode (planned)
Each device picks a **data mode** in My Account; the setting lives in `localStorage` so a
guest can be on Saver on their phone and Original on their laptop.
| Mode | Default? | Feed loads... | Lightbox / diashow loads... | Warning shown? |
|------------|:--------:|-----------------------------|------------------------------|:-------------:|
| Datensparer (Saver) | ✓ | preview (compressed) | preview | no |
| Original | | original | original | yes — "kann mobile Datennutzung erhöhen" once on enable |
Applies uniformly to the live app's feed/lightbox **and** the diashow. The viewer (offline
HTML export) is unaffected — it's already a snapshot of pre-bundled media variants.
### 2.6 Rate limits and quotas — toggleable (planned)
The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:
- **Master switch — all rate limits.** When off, every limiter middleware short-circuits to
pass-through. Useful for testing or trusted internal events.
- **Per-endpoint switches.** Upload / feed / export / join each have their own toggle. The
numeric input becomes informational while the toggle is off.
- **Master switch — quotas.** When off, no quota check ever runs.
- **Per-area quota switch.** Storage-bytes quota and upload-count quota can be disabled
independently.
When a feature is toggled off, the relevant UI in the guest-facing app should adapt: e.g.
the "Du hast X von Y MB genutzt" widget hides itself when storage quota is disabled. The
quota estimate is computed from the same formula the server uses
(`(free_disk × tolerance) / max(active_uploaders, 1)`) — surfaced in My Account *and* on
the upload preview screen so guests know before they pick files.
### 2.7 Privacy note (Datenschutzhinweis, planned)
Admin sets a free-text **Datenschutzhinweis** during instance setup (Admin Dashboard →
Config). It's stored as a single config key (plain text, whitespace and newlines
preserved, no HTML). Guests see it in their **My Account** page, rendered inside a
preformatted block — no parsing, no markdown, just exactly what the admin typed. The
first-visit onboarding guide gains a one-line nudge: *"Datenschutzhinweis findest du in
deinem Account."*
Rationale: many real events (in Germany especially) need a per-event privacy statement
without the operator wanting to ship a separate static page or rebuild the app.
### 2.8 Export
Two artifacts, both generated on demand after the host taps "Release gallery":
- **Gallery.zip** — full-quality originals only, structured into `Photos/` and `Videos/`,
filenames `{date}_{time}_{username}_{id}.{ext}`, streamed via `async-zip` with no full
archive in memory.
- **Memories.zip** — the offline HTML viewer. Pre-built SvelteKit-static app from
[frontend/export-viewer/](../frontend/export-viewer/), bundled with a generated
`data.json` snapshot and a `media/` folder of thumbnails + full-size variants. Open
`index.html` in any browser — no server required, no internet required. List/grid views,
lightbox, hashtag chips, like counts, comments — all visually matched to the live app.
The export page shows live progress (SSE) while jobs run, then becomes a download button
when complete.
---
### 2.9 Maintainability and extensibility
EventSnap is small enough to be a single-developer project; it should stay easy to extend.
A few principles to keep adding features cheap:
- **Diashow transitions are drop-in components.** Each animation implements a small
interface and lives under `frontend/src/lib/diashow/transitions/`. Adding a transition is
one file + one entry in a registry.
- **Feature toggles live in the `config` table.** Today's rate-limit and quota switches
follow the same pattern any new opt-in feature would use — no redeploy to flip
behaviour.
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
diashow state — composable rather than copy-pasted into each route.
- **Migrations are append-only.** Never edit a shipped migration; always add a new pair.
- **Background jobs share one pipeline.** Export and compression already publish progress
via the `export_job` row + SSE; future long-running work (analytics, archival) should
plug into the same shape.
See [IDEAS.md](IDEAS.md) for a longer riff on these patterns.
## 3. Out of scope (intentionally not built)
These are explicit non-goals from [PROJECT.md §4](../PROJECT.md):
- Native iOS / Android apps
- Multiple simultaneous events (multi-tenancy)
- Email-based auth / password reset
- Push notifications
- User-to-user direct messaging
- Payment / monetisation
- CI/CD pipeline
- "Save to camera roll" automation on iOS/Android — guests download the ZIP and use their
platform file manager
---
## 4. See also
- [USER_JOURNEYS.md](USER_JOURNEYS.md) — step-by-step flows for every supported scenario.
- [CONCEPT_MOBILE_UI.md](CONCEPT_MOBILE_UI.md) — design reference for the mobile layout.
- [CONCEPT_HTML_VIEWER.md](CONCEPT_HTML_VIEWER.md) — export-viewer design.
- [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md) — planned diashow design.
- [IDEAS.md](IDEAS.md) — speculative extensions (global diashow, reactions, multi-tenancy, ...).
- [PROJECT.md](../PROJECT.md) — full architectural blueprint and rationale.
- [TEST_GUIDE.md](../TEST_GUIDE.md) — manual smoke-test script for the main flows.

199
docs/IDEAS.md Normal file
View File

@@ -0,0 +1,199 @@
# EventSnap — Ideas & Future Extensions
A dumping ground for design ideas that are **not yet on the roadmap**. Everything here is a
v2+ candidate, brainstormed once the core experience is stable. For shipped or actively
planned scope see [FEATURES.md](FEATURES.md) and the `CONCEPT_*.md` design docs.
The bar to land here is low: "would be cool one day" qualifies. The bar to graduate to a
`CONCEPT_*.md` is much higher (design committed, ready to build).
---
## Diashow extensions
### Global / synchronised diashow
Multiple devices show **the same slide at the same time** (e.g. a projector in the main
hall plus tablets behind the bar plus a screen by the photo booth).
Sketch:
- Server holds a single authoritative "current slide" cursor for the event.
- New SSE event `diashow-tick` broadcasts `{ slide_id, started_at, next_at }`.
- Each subscribed client renders locally — server only chooses ordering and pace.
- Live-queue / shuffle-queue logic (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) lives
server-side instead of client-side.
- A "leader device" can claim the diashow, or the server runs it head­lessly. Host UI lets
Host start / stop the global diashow.
- Plays well with venues that already have multiple displays — no need for HDMI splitters
or chromecast hacks.
### Audio bed
- Host uploads or selects a background track (per-event).
- Videos in the diashow auto-mute so they don't fight the music.
- Optional ducking when a video has speech.
### Curated diashow mode
- Diashow filtered by a hashtag (`#highlights`) or to a Host-pinned set ("Story" feature).
- Useful for the end-of-evening recap reel.
### Animation pack
- More transitions out of the box: zoom, slide, mosaic, dip-to-black, push.
- Per-event "theme" preset — wedding-elegant, party-energetic, minimal, gallery-classic.
- Builds on the maintainability principle below: each transition is a drop-in Svelte
component, so growing the pack is trivial.
### Lower-third metadata
- Subtle chyron at the bottom of each slide: uploader name + timestamp + caption.
- Off by default; toggle in the diashow settings popover.
### Smart pacing
- Detect video duration and let videos play their full length (with a cap), pause stills
for the remainder. Avoids choppy 6-second cuts on a clip with key content at 0:08.
- "Action density" heuristic — slow down for portraits, speed up for landscapes.
---
## Social
### Per-guest gallery
A first-class "All posts by Anna" view, navigable from a guest's avatar — not just a
filter chip. Doubles as a personal "what did I post?" page.
### Story-style highlights
Host curates a best-of timeline pinned at the top of the feed (already in PROJECT.md's
"Should Have"). Tap-through, fullscreen, ~5 s per story, like Instagram. Could double as
the source for the curated-diashow mode above.
### Reactions beyond like
Multiple emoji reactions (❤️ 😂 😍 🎉 🥲) instead of just like. The DB design already keys
the `like` table on `(upload_id, user_id)` — generalising to `(upload_id, user_id, kind)`
is a small migration.
### Mentions and reply-threads in comments
- `@anna` in a comment becomes a tap-through to her profile / posts.
- Threaded replies under each top-level comment.
- Combined with PWA push, drives engagement.
### Collaborative captions
Co-authored captions when multiple uploaders are in the same photo — second tagger
contributes their `#hashtags` to the same post.
---
## Notifications
- PWA push for new comments on a guest's own posts.
- Per-user opt-in; granular per-event preference (mute event, mute uploader, etc.).
- Email digest after the event (1 message per guest) — optional, controversial vs. the
"no email" identity model. Could be opt-in only.
---
## Capture & posting
- **Live-photo mode** — capture a 12 s clip alongside each still (Apple-style). Diashow
could animate stills using the live clip as the Ken Burns source.
- **Boomerang / GIF capture** — short looping clips.
- **Client-side filters and stickers** — Instagram-style.
- **Voice notes** attached to a photo — "first dance" voice memo + the photo.
- **Bulk-upload presets** — pre-fill a caption for a batch ("Photos from the ceremony").
---
## Privacy & moderation
- **Per-post visibility** — "only visible to people with this hashtag" or
"private to my friend group".
- **Pre-moderation queue** — Host approves posts before they hit the public feed (default
off; for sensitive events).
- **Auto-blur** of detected faces of non-guests, or NSFW detection.
- **Per-uploader watermark** on full-quality downloads.
---
## Multi-tenancy
- **Multiple events per instance** — picked by URL slug. Today the binary is single-event.
- **Org accounts** — a wedding photographer running 4 weddings a month against the same
deployment.
- **Per-event admin** vs. **instance admin** roles.
---
## Internationalisation
- Localisation beyond German — English, French, Spanish, ...
- Admin picks UI language during setup; per-user override.
- Strings extracted into a small JSON catalogue — works well with `svelte-i18n` or similar.
---
## Export
- **Year-in-pictures PDF** — host-curated layout, printable.
- **ICS calendar attachment** of the event, included in the export ZIP.
- **Direct upload to a guest's chosen cloud** (iCloud, Google Photos) — needs OAuth, adds
a third-party integration where today there are none.
---
## Resilience / infrastructure
- **Distributed rate limiting** (Redis) for multi-instance / multi-event deploys.
- **Object-storage backend** (S3 / MinIO) behind a feature flag — out of scope for the
single-VPS use case but easy to add if multi-tenancy is ever pursued.
- **Read replicas** for very large events.
---
## Maintainability principles to keep adding features cheap
The codebase is small today and should stay friendly to extension. A few patterns to lean
into as the surface grows:
- **Diashow transitions as drop-in components.** Each transition implements a tiny
interface (`enter`, `leave`, optional `duration`). Adding a new animation is one file in
`frontend/src/lib/diashow/transitions/` and one line in a registry. Same idea for
hashtag-filter operators.
- **Per-feature toggle flags in the `config` table.** Today rate limits and quotas are
individually toggleable (see [FEATURES.md](FEATURES.md)). The same pattern fits for any
future opt-in feature — no need to redeploy to flip behaviour.
- **Background-task trait on the server.** Export, compression, and (future) analytics
jobs would all share a `BackgroundJob` interface that wires into the existing
`export_job` progress + SSE pipeline. New long-running work plugs in by implementing the
trait — no bespoke worker code per feature.
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
diashow state — each lives in its own store under `frontend/src/lib/`. New UI features
consume the stores; cross-feature behaviour is composed, not copy-pasted.
- **DTOs in one file** ([frontend/src/lib/types.ts](../frontend/src/lib/types.ts)),
mirrored to the Rust DTOs. Changing a contract is exactly two edits.
- **Migration-first schema evolution** — never edit an old migration; always add a new
`0NN_*.up.sql` / `.down.sql` pair. Already the discipline; just keep it.
---
## Speculative / "would be cool"
Lower bar of plausibility — keep these around as conversation seeds:
- **AI-generated event summary** at release time (3-paragraph recap, key moments,
funniest comment).
- **AI auto-tagging** — suggested hashtags based on image content, opt-in per upload.
- **Guest-of-honour mode** — special UI for the couple / birthday person showing
*everything they're in*, prioritised by face detection.
- **Live caption translation** for international weddings — auto-translate comments
inline.
- **Sound-reactive diashow** — slides advance in sync with music BPM picked up via the
device mic.
- **Photo-booth integration** — a fixed iPad at the venue posts to the feed with a single
tap, no PIN.

292
docs/USER_JOURNEYS.md Normal file
View File

@@ -0,0 +1,292 @@
# EventSnap — User Journeys
This document walks through every supported user scenario step-by-step. For a quick "who
can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
[TEST_GUIDE.md](../TEST_GUIDE.md).
---
## 1. First-time guest (the happy path)
1. Guest scans the QR code / opens the event link.
2. Lands on the **join page** (`/join`), sees the event name. A small
*"Ich habe bereits einen Account"* link is visible below the form for returning users
— it routes to `/recover`.
3. Types display name → taps **Beitreten**.
4. Server creates the account, generates a 4-digit PIN, stores `bcrypt(PIN)`, signs a
30-day JWT.
5. A **PIN modal** appears: large monospace digits, a **Kopieren** button, a warning that
this PIN is the only way to sign in on another device. PIN is also written to
`localStorage`.
6. Guest taps **Weiter zur Galerie** → lands in the feed (`/feed`).
7. The **first-visit onboarding overlay** appears: dismissible steps (welcome, upload,
hashtags, PIN, and a brief pointer to the **Datenschutzhinweis** in My Account).
`localStorage('eventsnap_guide_seen') = 'true'` after dismiss.
8. Guest sees the bottom nav: **🏠 Feed · [📷+ FAB] · 👤 Account**.
## 2. Returning guest, same device
1. App finds a valid JWT in `localStorage`.
2. Redirected straight to `/feed`, no input required.
## 3. Returning guest, new device or cleared storage
1. Guest opens the event link on the new device → join page.
2. Types the **same name** they used before.
3. Server detects the existing account → the join page transforms into a recovery prompt:
*"„Name" ist bereits vergeben"* with a **PIN input** and an **Anmelden** button, plus
an **Anderen Namen wählen** escape hatch.
4. Guest types their PIN → `bcrypt.verify` succeeds → new JWT issued for the existing
`user_id`. PIN is written to `localStorage` on this device too.
5. Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes
(`pin_locked_until` is set; further attempts return HTTP 429 with a localized message).
## 4. PIN forgotten — Host or Admin resets it (planned)
The PIN is visible in **My Account** as long as `localStorage` is intact on at least one
of the user's devices. If lost everywhere, the user asks a Host (or Admin) for a reset.
1. Guest approaches the Host: *"I can't sign in on my new phone."*
2. Host opens the **Host Dashboard → Nutzerverwaltung** and finds the user.
3. Host taps **PIN zurücksetzen** on that row.
4. A confirmation prompt explains what happens; on confirm the server generates a fresh
4-digit PIN, replaces `recovery_pin_hash` with the new bcrypt, clears any active
`pin_locked_until`, and returns the new plaintext PIN in the response.
5. A **modal shows the new PIN ONCE** — large, with a copy button. The Host shows the
screen to the guest or sends it via another channel (SMS, slip of paper, …). Closing
the modal forgets the plaintext on the operator's device too.
6. Guest goes to `/recover` (or taps "Ich habe bereits einen Account" on `/join`), enters
their name + the new PIN, signs in, and the PIN is persisted to `localStorage` on
their device — exactly like a fresh join.
**Permission rules:**
- Host can reset PINs for **guests** only.
- Admin can reset PINs for **hosts and guests** (not other admins; admins use the
password login).
- Anyone whose PIN was reset retains all their uploads, comments, and likes — only the
PIN changes.
**If no Host or Admin is reachable**, the guest can still re-join under a new name (a
clean account; their previous uploads remain attributed to the abandoned account, which
the Host can clean up later).
## 5. Posting a photo / video
1. Guest taps the central **📷+ FAB** in the bottom nav.
2. A **bottom sheet** slides up offering **Kamera** (in-app capture) or **Galerie** (file
picker, multi-select).
3a. **Camera path** — [CameraCapture](../frontend/src/lib/components/CameraCapture.svelte)
opens the back camera (`facingMode: 'environment'`), with toggle for front camera,
photo button, and a video-record button using `MediaRecorder`.
3b. **Gallery path** — native picker, multiple selection.
4. **Preview screen** (`/upload`) shows staged files as horizontal thumbnails. The user can:
- Remove individual files.
- Type a caption with `#hashtags`.
- Tap quick-tag chips (derived from the caption) to copy a hashtag into the caption.
5. Taps **Hochladen** → returns immediately to the feed (optimistic UX). The slim progress
bar above the bottom nav and the red badge on the FAB indicate active uploads.
6. The client uploads files **one at a time** (XHR with progress) from an IndexedDB queue.
7. Each upload triggers a server-side compression job; once the preview is ready the feed
updates via `upload-processed` SSE — placeholders swap for actual previews.
## 6. Posting under rate limits
1. Hit the per-hour upload limit (default 10 / hour, configurable).
2. Server returns **HTTP 429** with a `Retry-After` header on the next upload attempt.
3. Client parks pending items in **Wartend** state and shows an amber banner:
*"Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."*
4. Countdown ticks down. When it reaches 0, the queue resumes automatically.
## 7. Liking and commenting
1. Tap the heart icon on a card or in the lightbox → like is recorded; count increments
optimistically; server returns the canonical count via `like-update` SSE.
2. Tap the comment icon → opens the lightbox with the comments list.
3. Type a comment → `POST /api/v1/upload/{id}/comment`. Hashtags inside the comment are
parsed and attached.
4. The user can delete their own comments (trash icon next to them).
## 8. Filtering the gallery
1. Toggle to **grid view** (icon top-right of the feed header).
2. A search bar appears below the header (auto-focused).
3. Type a name or `#hashtag` — autocomplete suggestions are derived **in memory** from the
loaded uploads.
4. Tap a suggestion → it becomes an **active filter chip** and the search bar clears.
5. Filter logic:
- Multiple hashtag chips: OR
- Multiple uploader chips: OR
- One uploader + one hashtag: AND
6. Open a post → swipe in the lightbox navigates the **filtered set**, not the full feed.
## 9. Hosting the event — moderation
1. Host opens **My Account** → taps **⭐ Host-Dashboard**.
2. **Stats section** — guest count, upload count, lock status, release status.
3. **Event settings** — toggle to lock new uploads (likes / comments / browsing stay open;
broadcasts `event-closed` SSE so all clients show a "uploads are locked" banner).
4. **Galerie freigeben** — releases the export. Enqueues two export jobs (ZIP + HTML
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
`export-progress` keeps it live; `export-available` notifies all guests when ready.
5. **Nutzerverwaltung** — search users; per-user controls:
- **Sperren** opens a confirmation modal with a checkbox "Uploads aus der Galerie
ausblenden" — Host chooses whether to hide the user's existing uploads or leave them
visible. Submitting calls `POST /host/users/{id}/ban` with `hide_uploads`.
- **Entsperren** lifts the ban.
- **Host** promotes a guest to host.
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
guest (planned). The button is hidden on the Host's own row to prevent self-lockout;
only an Admin can demote themselves out of moderation. Admins see Degradieren on
every Host row.
- **PIN zurücksetzen** (planned) — generates a new PIN and shows it once in a modal.
See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host
rows.
6. **Deleting content** — Host can delete any upload or comment via the moderation routes
(`DELETE /host/upload/{id}`, `DELETE /host/comment/{id}`). On mobile this is also
reachable by long-pressing the content (planned, see §15).
## 10. Banned-guest experience
1. The banned user's next authenticated request returns HTTP 403 with a clear message
("Du bist gesperrt.").
2. They can still browse the read-only feed (and download the export once it's released).
3. They cannot upload, like, or comment.
4. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the
feed for everyone (the `v_feed` view already enforces this).
## 11. Admin — instance configuration
1. Admin opens `/admin/login`, types the admin password (compared against
`ADMIN_PASSWORD_HASH`). Receives a separate 1-day admin JWT (in `sessionStorage`).
2. Admin dashboard has four inner tabs:
- **Stats**: live counts and disk-usage widget (via `sysinfo`).
- **Config**: per-file limits (image MB / video MB), rate limits (upload / feed /
export), quota tolerance, estimated guest count, compression-worker concurrency,
plus the **Datenschutzhinweis** free-text editor and **on/off toggles** for the rate
limiters and quotas (planned — see §16). Whitelist on the server side rejects
unknown keys. Values are read from the `config` table on each request — no restart
needed.
- **Export**: list of past export jobs with status badges (pending / running / done /
failed) and progress bars; refresh button re-polls.
- **Nutzer**: same user list as Host, with the additional Demote action and (planned)
PIN-reset on host rows.
## 12. Releasing the export and downloading
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
2. Server sets `event.export_released_at` and enqueues two background jobs.
3. ZIP job: streams `Gallery.zip` (`Photos/` + `Videos/`, full-quality originals) directly
to disk via `async-zip`. Progress updates via `export-progress` SSE.
4. HTML-viewer job: copies the pre-built viewer assets from
[backend/static/export-viewer/](../backend/static/export-viewer/) (embedded via
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
variants for each upload, and assembles `Memories.zip`.
5. Both jobs complete → server broadcasts `export-available` SSE.
6. Any user opens `/export`:
- Before release: friendly "Export not yet available" banner.
- During generation: progress bars per artifact.
- After completion: two cards (**ZIP-Archiv** and **HTML-Viewer**) with download
buttons. Tapping the HTML download first shows an in-app guide modal explaining:
"Entpacke die ZIP, öffne `index.html`". Tapping **Herunterladen** triggers the
browser download.
7. Downloads are rate-limited per IP (default 3 / day).
## 13. Diashow (planned)
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
1. User taps a **Diashow / Präsentation** action (feed header on tablet/desktop, Account
on mobile).
2. Navigates to `/diashow` — fullscreen, bottom nav hidden, screen wake-lock acquired.
3. Initial pool fetched from `GET /api/v1/feed`. Slides crossfade every ~6 s.
4. New uploads (`upload-processed` SSE) push to a live queue; the next slide transition
pops from the live queue first, otherwise from a shuffled queue.
5. `upload-deleted` removes that ID from both queues; if it's the current slide, advance
immediately.
6. Tap or Escape reveals an overlay (pause, dwell selector, exit).
## 14. Picking a data mode (planned)
1. Guest opens **My Account** → scrolls to **Datennutzung**.
2. Two options: **Datensparer (empfohlen)** and **Original**. Saver is the default.
3. Selecting **Original** shows a one-time warning bottom-sheet:
*"Original-Dateien werden geladen — das kann deine mobile Datennutzung deutlich
erhöhen. Trotzdem aktivieren?"* with **Abbrechen** / **Aktivieren** buttons.
4. Choice persists in `localStorage` (per-device). The feed, lightbox, and diashow all
read this flag and load originals instead of compressed previews when Original is on.
5. The viewer (offline HTML export) is unaffected — it already ships with its own pre-
bundled `_thumb` / `_full` variants.
## 15. Leaving an event
1. User opens **My Account** → taps **🚪 Event verlassen**.
2. Bottom-sheet confirmation: "Event verlassen?" with **Abmelden** and **Bleiben**.
3. Confirming calls `DELETE /api/v1/session` (invalidates the session row), clears the JWT
and PIN from `localStorage`, and redirects to the join page.
## 16. Reading the Datenschutzhinweis (planned)
1. User opens **My Account** → scrolls to **Datenschutzhinweis**.
2. The note is rendered inside a preformatted block (`<pre>`-style: monospace, whitespace
and newlines preserved exactly as the Admin typed them). No HTML, no markdown — the
admin's plain text is shown verbatim.
3. The first-visit onboarding overlay carries a one-line reminder of where to find this:
*"Datenschutzhinweis findest du in deinem Account."*
4. Admin sets / edits the note in **Admin Dashboard → Config → Datenschutzhinweis**: a
tall textarea with a save button. Saved to a single `config` key.
## 17. Mobile-first gestures (planned)
EventSnap's UI is mobile-first; gestures replace explicit buttons where they're more
ergonomic. Buttons are always present as fallback for desktop and accessibility.
| Gesture | Action |
|-------------------------------------------|-------------------------------------------------------|
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
| Long-press on a post (other) | Bottom sheet → Original anzeigen, Teilen, Melden (planned) |
| Long-press on a comment (own) | Bottom sheet → Löschen |
| Long-press on a comment (other) | Bottom sheet → Kopieren |
| Long-press on a user row (Host) | Bottom sheet → Sperren, Promote/Demote, PIN zurücksetzen |
| Swipe left/right in the lightbox | Navigate the filtered set |
| Swipe down on any bottom sheet | Dismiss |
| Pull-to-refresh on the feed | Force a delta-fetch |
| Double-tap on a post | Like (heart-burst animation) |
On desktop the same actions surface as kebab/⋯ menus, click-able icons in card corners,
and keyboard shortcuts in the lightbox (← → for navigate, Esc to close).
Inspiration: Instagram (double-tap heart, swipe stories), WhatsApp (long-press for
context), Telegram (swipe-to-reply on messages — could inform comment threads if those
land).
## 18. Admin toggles a rate limit or quota off (planned)
1. Admin opens **Admin Dashboard → Config**.
2. **Rate-Limits** section: a master switch and per-endpoint switches (upload / feed /
export / join).
3. Admin flips, e.g., **Upload-Limit aktiv** off. The numeric input for "uploads per hour"
stays visible but greyed out (still editable for when the toggle goes back on).
4. **Speichern** persists to the `config` table. The next upload request bypasses the
limiter entirely.
5. **Quoten** section mirrors the pattern: master toggle plus per-area toggles (storage
bytes / upload count).
6. When the storage-quota toggle is off, the **"X von Y MB genutzt"** widget in the
guest's My Account and upload screen hides itself (no quota → no number to show).
Suggested defaults at deploy time: all toggles **on**, sensible numeric limits.
Toggling off is the explicit escape hatch for testing or trusted internal events.
---
## Edge cases worth knowing
| Case | Behaviour |
|-------------------------------------------------------|---------------------------------------------------------------------------------|
| Browser tab backgrounded for > 5 min | SSE closes on `visibilitychange: hidden`; reopens on visible |
| Upload finishes while user is on `/account` | Feed updates anyway — the queue + SSE are global stores |
| Event "closed" while files are still in the queue | Server rejects with a friendly error; client surfaces it in the queue UI |
| Network drops mid-upload | Queue retries the file; retry button available on permanent failure |
| New device but the PIN was lost | Either re-join under a new name, or Host manually re-links (no self-service) |
| Two guests pick the same name | Second one is offered the PIN-recovery form (case-insensitive UNIQUE, mig. 007) |
| Compression fails for a file | Server emits `upload-error` SSE; the upload is still listed but marked degraded |
| User deletes their own post (once UI is shipped) | Soft delete (`deleted_at`); SSE `upload-deleted`; vanishes from feed everywhere |

View File

@@ -0,0 +1,34 @@
# `lib/` conventions
Short rules. The patterns we already follow as of v0.16 — write new code that fits.
**One store per cross-cutting concern.** A single `*-store.ts` file owns each one:
- `auth.ts` — JWT / PIN in `localStorage`, `isAuthenticated` writable
- `ui-store.ts` — bottom-nav visibility, upload-sheet open state, FAB badge count
- `data-mode-store.ts` — Saver vs Original media-loading preference
- `privacy-note-store.ts` — admin-configured Datenschutzhinweis text
- `quota-store.ts` — live per-user storage snapshot
- `upload-queue.ts` — IndexedDB-persisted upload queue + processing state
Don't import these into other stores unless strictly necessary; let pages compose them.
**DTOs mirror Rust types.** All TS interfaces live in `types.ts`. Each one carries a
`// mirrors backend/src/path::TypeName` comment so the two stay searchable. If you add
a Rust DTO, add the TS twin in the same PR.
**Gestures via Svelte actions in `actions/`.** Long-press, double-tap, future swipe —
each is a `use:` action that fires a CustomEvent. Components stay free of gesture
plumbing.
**Reusable bottom sheets via `ContextSheet.svelte`.** Pass an `actions: ContextAction[]`
array. Any page that needs a long-press / kebab context menu uses the same primitive.
**SSE relays are listed in `sse.ts::KNOWN_EVENTS`.** New server event → add one entry
to that array, that's it.
**Diashow transitions live in `diashow/transitions/`.** Each is a Svelte component
plus one entry in `transitions/index.ts`. Adding a new animation is two-line work; no
diashow code needs to change.
**No new global stores** beyond the list above unless the new concept is genuinely
app-wide. Page state belongs in the page.