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:
122
PROJECT.md
122
PROJECT.md
@@ -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.
|
Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.
|
||||||
|
|
||||||
### Status
|
### 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) │
|
│ Axum HTTP Server (Rust — Single Binary) │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
||||||
│ │ REST API │ │ SSE Engine │ │ Static File Server │ │
|
│ │ REST API │ │ SSE Engine │ │ Media Static Server │ │
|
||||||
│ │ /api/v1/* │ │ /api/v1/ │ │ (SvelteKit build │ │
|
│ │ /api/v1/* │ │ /api/v1/ │ │ /media/* (originals, │ │
|
||||||
│ │ │ │ stream │ │ output, embedded) │ │
|
│ │ │ │ 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
|
### 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
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
db: # postgres:16-alpine, persisted in postgres_data volume
|
||||||
build: ./backend # Multi-stage Rust Dockerfile
|
app: # ./backend — Rust API on :3000, mounts media_data:/media
|
||||||
env_file: .env
|
frontend: # ./frontend — SvelteKit (adapter-node) on :3001
|
||||||
depends_on: [db]
|
caddy: # caddy:2-alpine — terminates TLS on :80/:443, proxies app + frontend
|
||||||
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:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Caddyfile
|
### Caddyfile
|
||||||
@@ -345,11 +323,14 @@ COMPRESSION_WORKER_CONCURRENCY=2
|
|||||||
|-------|---------|---------|
|
|-------|---------|---------|
|
||||||
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
|
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
|
||||||
| `new-comment` | `{ id, upload_id, body, uploader, created_at }` | Comment posted |
|
| `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 |
|
| `upload-deleted` | `{ upload_id }` | Upload deleted |
|
||||||
| `event-closed` | `{}` | Host locks uploads |
|
| `event-closed` | `{}` | Host locks uploads |
|
||||||
| `event-opened` | `{}` | Host unlocks uploads |
|
| `event-opened` | `{}` | Host unlocks uploads |
|
||||||
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete |
|
| `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=`
|
**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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Role | Permissions |
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release) |
|
| 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, lock/unlock uploads, release gallery export |
|
| 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 + configure storage/file/rate limits, quota tolerance, view disk usage, manage app config, trigger export generation |
|
| 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 |
|
| Banned Guest | View feed only — cannot upload, like, comment, or export |
|
||||||
|
|
||||||
### Compliance
|
### Compliance
|
||||||
@@ -473,37 +454,33 @@ Full-quality originals only. File naming: `{date}_{time}_{username}_{original_fi
|
|||||||
|
|
||||||
### Export Type 2: HTML Offline Viewer (`Memories.zip`)
|
### 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/
|
||||||
Memories.html ← single entry point (all CSS + JS inlined; no external deps)
|
index.html ← entry point; open this in any browser
|
||||||
README.txt ← plain-text setup guide (in German, as the UI language)
|
_app/
|
||||||
Photos/ ...
|
immutable/... ← hashed JS/CSS bundles (viewer SPA)
|
||||||
Videos/ ...
|
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):
|
**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.
|
||||||
```
|
|
||||||
Willkommen in der Event-Galerie!
|
|
||||||
|
|
||||||
So geht's:
|
**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.
|
||||||
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.
|
|
||||||
|
|
||||||
Viel Freude mit den Erinnerungen!
|
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.")
|
||||||
```
|
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -625,7 +602,9 @@ CREATE TABLE "user" (
|
|||||||
recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN)
|
recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN)
|
||||||
total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks
|
total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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'),
|
('export_rate_per_day', '3'),
|
||||||
('quota_tolerance', '0.75'),
|
('quota_tolerance', '0.75'),
|
||||||
('estimated_guest_count', '100'),
|
('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;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1173,7 +1163,11 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
|
|||||||
|
|
||||||
| Decision | Chosen | Rationale |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| `uuid` | UUID v7 (time-sortable) |
|
||||||
| `serde` / `serde_json` | Serialisation |
|
| `serde` / `serde_json` | Serialisation |
|
||||||
| `tower` / `tower-http` | Middleware stack (CORS, compression, static files, request tracing) |
|
| `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 |
|
| `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
|
||||||
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
|
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
|
||||||
| `minijinja` | HTML export template rendering (`Memories.html`) |
|
| `minijinja` | HTML export template rendering (`Memories.html`) |
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -150,12 +150,12 @@ See [.env.example](.env.example) for the full list with descriptions and default
|
|||||||
┌───▼────┐
|
┌───▼────┐
|
||||||
│ db │
|
│ db │
|
||||||
│ :5432 │
|
│ :5432 │
|
||||||
│(Postgres│
|
│(Postgres)│
|
||||||
└────────┘
|
└────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/api/*` and `/media/*` → Rust backend
|
- `/api/*` and `/media/*` → Rust backend
|
||||||
- Everything else → SvelteKit frontend
|
- Everything else → SvelteKit frontend (`adapter-node`)
|
||||||
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
|
- 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
|
## Development Roadmap
|
||||||
|
|
||||||
|
Done:
|
||||||
- [x] Project blueprint & architecture
|
- [x] Project blueprint & architecture
|
||||||
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
|
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
|
||||||
- [ ] DB schema + SQLx migrations
|
- [x] DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
|
||||||
- [ ] Auth flow (join, JWT, PIN recovery)
|
- [x] Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
|
||||||
- [ ] Upload pipeline (multipart → compression worker → SSE broadcast)
|
- [x] Upload pipeline (multipart → compression worker via `tokio::sync::Semaphore` → SSE broadcast)
|
||||||
- [ ] Client upload queue (IndexedDB, progress, retry)
|
- [x] Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
|
||||||
- [ ] Gallery feed (grid, SSE, hashtag filters)
|
- [x] Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
|
||||||
- [ ] Camera capture (`getUserMedia`)
|
- [x] Camera capture (`getUserMedia` with front/back toggle, photo + `MediaRecorder` video)
|
||||||
- [ ] Host Dashboard
|
- [x] Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
|
||||||
- [ ] Admin Dashboard
|
- [x] Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
|
||||||
- [ ] Export engine (ZIP + offline HTML)
|
- [x] Export engine: streaming ZIP + SvelteKit-static HTML viewer (see [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md))
|
||||||
- [ ] Rate limiting middleware
|
- [x] Custom rate limiter (per-endpoint, hot-reloadable from `config` table)
|
||||||
- [ ] End-to-end test event (10+ real devices)
|
- [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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,45 +16,47 @@ Please test each step in order and report any errors (console errors, wrong text
|
|||||||
9. ✅ Expected: Overlay disappears
|
9. ✅ Expected: Overlay disappears
|
||||||
|
|
||||||
### Step 3 — Feed & navigation
|
### Step 3 — Feed & navigation
|
||||||
10. ✅ Expected: Feed shows "Noch keine Fotos." empty state with an upload button
|
10. ✅ Expected: Feed shows the empty state ("Noch keine Fotos." or similar) with a hint to upload
|
||||||
11. ✅ Expected: Top-right has an **upload button** (blue) and a **person icon** link
|
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
|
### 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
|
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!"
|
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
|
### Step 5 — Upload
|
||||||
17. Click **Hochladen** — this takes you to `/upload`
|
17. Tap the central **📷+ FAB** in the bottom nav
|
||||||
18. Try uploading a photo from your device library
|
18. ✅ Expected: A bottom sheet slides up offering **Kamera** and **Galerie** options
|
||||||
19. ✅ Expected: Photo appears in queue with a progress bar, then completes
|
19. Tap **Galerie** → pick a photo from your device library
|
||||||
20. Go back to `/feed` — ✅ Expected: your photo appears in the feed grid
|
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
|
### Step 6 — Onboarding guide not shown again
|
||||||
21. Reload the page at `/feed`
|
23. Reload the page at `/feed`
|
||||||
22. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
|
24. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
|
||||||
|
|
||||||
### Step 7 — Recover (open a private/incognito window)
|
### Step 7 — Recover (open a private/incognito window)
|
||||||
23. Open a new **private/incognito** window at **http://localhost:5173/recover**
|
25. Open a new **private/incognito** window at **http://localhost:5173/recover**
|
||||||
24. Enter the same name (`Max`) and the PIN you copied
|
26. Enter the same name (`Max`) and the PIN you copied
|
||||||
25. ✅ Expected: You're redirected to the feed with the same account
|
27. ✅ Expected: You're redirected to the feed with the same account
|
||||||
|
|
||||||
### Step 8 — Upload rate-limit auto-retry
|
### Step 8 — Upload rate-limit auto-retry
|
||||||
26. Upload more than 20 photos in one hour to trigger the rate limit
|
28. Upload more than the per-hour limit of photos in quick succession to trigger the rate limit
|
||||||
27. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
|
29. ✅ 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."
|
30. ✅ 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
|
31. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
|
||||||
|
|
||||||
### Step 9 — Name uniqueness (case-insensitive)
|
### Step 9 — Name uniqueness (case-insensitive)
|
||||||
30. In a private/incognito window go to **http://localhost:5173/join**
|
32. 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)
|
33. 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
|
34. ✅ 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
|
35. ✅ 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**
|
36. 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
|
37. ✅ 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
|
38. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
28
backend/migrations/README.md
Normal file
28
backend/migrations/README.md
Normal 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
196
docs/CONCEPT_DIASHOW.md
Normal 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.
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# HTML Viewer Export Concept
|
# 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
|
## Overview
|
||||||
|
|
||||||
The HTML Viewer export produces a **self-contained offline ZIP** that is a read-only clone
|
The HTML Viewer export produces a **self-contained offline ZIP** that is a read-only clone
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
# Mobile-First UI/UX Redesign Concept
|
# 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
|
## Overview
|
||||||
|
|
||||||
EventSnap is intended for mobile use at live events, but the current UI is desktop-oriented.
|
EventSnap is intended for mobile use at live events. This document describes the full
|
||||||
This document describes a full mobile-first redesign covering navigation, the feed/gallery,
|
mobile-first design covering navigation, the feed/gallery, account page, host dashboard,
|
||||||
account page, host dashboard, and admin 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
|
## Design Principles Summary
|
||||||
|
|
||||||
| Principle | Application |
|
| 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 |
|
| No role clutter in nav | Role links only in Account, bar stays clean |
|
||||||
| Collapsible sections | Long management pages stay usable on small phones |
|
| Collapsible sections | Long management pages stay usable on small phones |
|
||||||
| Inner tabs for complex pages | Admin dashboard split across 4 focused tabs |
|
| 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
313
docs/FEATURES.md
Normal 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
199
docs/IDEAS.md
Normal 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 headlessly. 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 1–2 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
292
docs/USER_JOURNEYS.md
Normal 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 |
|
||||||
34
frontend/src/lib/README.md
Normal file
34
frontend/src/lib/README.md
Normal 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.
|
||||||
Reference in New Issue
Block a user