- 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>
26 KiB
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; for journeys / step-by-step flows see 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) | ||||
| 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
userrow, generates a 4-digit PIN, storesbcrypt(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 tolocalStorage. - 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_untilcolumn, 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
/recoverfor users who already know they want to sign in. - PIN reset by Host / Admin. Planned. If a guest loses their PIN and
localStorageis 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 tolocalStorageon 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 theADMIN_PASSWORD_HASHenv var; admins log in at/admin/loginwith a password (separate JWT, 1-day expiry, insessionStorage). 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:
- Source picker (bottom sheet from the FAB): camera or gallery.
- 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. - Submit — the client immediately returns to the feed (optimistic UX). Files enter an IndexedDB-persisted queue.
- 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.
- Server-side processing — multipart received → MIME-sniffed via
infer→ size validated → original stored → compression worker (bounded by atokio::sync::Semaphore) resizes to an 800-px preview (images via theimagecrate +oxipngfor PNG) or extracts a frame at the 1-second mark (videos viaffmpeg). Status is tracked in the newcompression_statuscolumn (migration 008). - Real-time fan-out —
new-uploadSSE first (no preview yet), thenupload-processedwhen the preview/thumbnail is ready, so clients can swap a placeholder for the real image without re-fetching the feed. - 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 onvisibilitychange: hiddenand 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
configtable and read on each request, so changes take effect without a restart. Disk widget pulls from thesysinfocrate 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/andVideos/, filenames{date}_{time}_{username}_{id}.{ext}, streamed viaasync-zipwith no full archive in memory. - Memories.zip — the offline HTML viewer. Pre-built SvelteKit-static app from
frontend/export-viewer/, bundled with a generated
data.jsonsnapshot and amedia/folder of thumbnails + full-size variants. Openindex.htmlin 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
configtable. 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_jobrow + SSE; future long-running work (analytics, archival) should plug into the same shape.
See 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:
- 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 — step-by-step flows for every supported scenario.
- CONCEPT_MOBILE_UI.md — design reference for the mobile layout.
- CONCEPT_HTML_VIEWER.md — export-viewer design.
- CONCEPT_DIASHOW.md — planned diashow design.
- IDEAS.md — speculative extensions (global diashow, reactions, multi-tenancy, ...).
- PROJECT.md — full architectural blueprint and rationale.
- TEST_GUIDE.md — manual smoke-test script for the main flows.