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