Files
EventSnap/docs/FEATURES.md
Fabian Hamm (Privat) 46bb2e5174 docs: correct the claims that no longer match the code
Checked each against the implementation and fixed the doc, never the code:

- FEATURES claimed the ban modal offers a choice about hiding existing uploads. There
  is no such choice — a ban always hides. USER_JOURNEYS §9 was already right.
- FEATURES claimed hosts may demote other hosts. It is admin-only, enforced in the
  backend, and the two documents contradicted each other on it.
- FEATURES showed the quota widget as guest-facing; it is deliberately staff-only.
- The first-visit tour has six steps, not four.
- USER_JOURNEYS §12.7 said export downloads are rate-limited per IP. They are per USER
  — a materially different thing at a shared-NAT venue, where per-IP would have locked
  out the fourth guest to fetch their keepsake.
- §15 still described "Event verlassen"; that button is now Abmelden / Auf allen
  Geräten abmelden.
- §4, §13, §14, §16 and §18 were marked "(planned)" and have shipped.
- The lightbox row now describes what exists after this branch: prev/next controls,
  arrow keys and swipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:37:09 +02:00

28 KiB
Raw Blame History

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 (6 steps) Welcome, upload, hashtags, long-press, hell/dunkel, PIN (+ privacy-note pointer when one is set). 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 ("Abmelden" / "Auf allen Geräten abmelden") Confirmation bottom-sheet; DELETE /session (this device) or DELETE /sessions (all devices). Nothing is deleted — account, PIN and uploads stay
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 prev/next navigation Navigates the filtered set. On-screen prev/next controls, ← / → arrow keys, and swipe left/right on touch
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 Ban always hides their existing uploads — no opt-out, the modal is a plain confirm. Unban restores them. Both invalidate and rebuild a released keepsake. See USER_JOURNEYS §9/§10
Delete any upload
Delete any comment
Promote guest to Host
Demote Host to guest Admin only — the backend 403s a Host demoting a peer Host (F1: it would launder past the ban / PIN-reset peer guards). Nobody may change their own role; Admins cannot be demoted at all. The button is hidden for non-admin 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 Red banner at the top of the Host dashboard when disk_low, naming free space and the keepsake's required bytes. Fails closed to "not low" on an unreadable mount
Event banner / cover image DB column exists, no UI
Quota visibility (staff-only)
Show current per-user quota estimate Deliberately not shown to guests — the storage widget in My Account and on the upload screen is gated on host/admin. Computed from the live formula; also 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-user export download rate limit (3 / day) Keyed on the user id, not the IP — a venue behind one NAT would otherwise share a 3/day budget across every guest
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. 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 a host or an admin. Only an Admin may change a Host's role — a plain Host may promote guests but may not demote a peer Host, because demoting one would then let them ban or PIN-reset (→ /recover takeover) that ex-peer, the guards keying off the target's current role (F1). Nobody may change their own role, so the event can't be locked out of moderation, and Admins are un-demotable and un-bannable. Enforced in handlers::host::set_role; the button is hidden for non-admin Hosts.

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-outnew-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 with embedded like/comment UI. Navigation across the filtered set is available three ways: on-screen prev/next controls, the ← / → arrow keys, and a left/right swipe on touch.
  • 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. The ban modal is a plain confirm: banning always hides the user's existing uploads, there is no keep-visible option. Promote/demote/unban/release are each behind a confirmation sheet that spells out the consequence, not one-tap.
  • Admin dashboard — same user list plus four inner tabs (Stats, Config, Export, Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated guest count and the colour theme — 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. The Export tab mirrors the host dashboard's release controls: the release button is disabled once released, the live keepsake status (progress / ready / failure reason) is shown, and a rebuild is offered as the recovery path.

2.5 Data mode

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

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 dependent UI adapts: the "Speicher: X / Y" widget hides itself when the storage quota is disabled. The quota estimate is computed from the same formula the server uses ((free_disk × tolerance) / max(active_uploaders, 1)) and is surfaced in My Account and on the upload preview screen — but only to hosts and admins. Guests never see it: the number moves as other people upload and as disk frees up, which reads as a broken or unfair limit to someone who can't see why.

2.7 Privacy note (Datenschutzhinweis)

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/, 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 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