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>
This commit is contained in:
Fabian Hamm (Privat)
2026-08-03 18:37:09 +02:00
parent 2b1500e624
commit 46bb2e5174
2 changed files with 98 additions and 57 deletions

View File

@@ -14,12 +14,12 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
| **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` |
| 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 / "Leave event" | ✓ | ✓ | ✓ | Confirmation bottom-sheet; invalidates the session row |
| 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 |
@@ -42,7 +42,7 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
| 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 |
| 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 |
@@ -56,11 +56,11 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
| | | | | |
| **Moderation (Host)** | | | | |
| List all event users | | ✓ | ✓ | Includes upload count, total bytes |
| Ban / unban a user | | ✓ | ✓ | Modal asks: hide their existing uploads, or keep visible? |
| 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 | | | ✓ | Hosts may demote other Hosts. Cannot demote self. Admins cannot be demoted by hosts. |
| 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 | | ✓ | ✓ | |
@@ -78,18 +78,18 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
| 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 |
| 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 (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 |
| **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-IP export download rate limit (3 / day) | ✓ | ✓ | ✓ | |
| 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 |
@@ -143,7 +143,7 @@ email, no password, no account portal.
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
- **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
@@ -153,9 +153,13 @@ email, no password, no account portal.
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.
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
@@ -188,8 +192,9 @@ The upload pipeline is built for flaky mobile networks:
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.
- **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.
@@ -197,15 +202,18 @@ The upload pipeline is built for flaky mobile networks:
### 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,
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 compression concurrency — all stored in the `config` table and read on
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.
`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 (planned)
### 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.
@@ -218,7 +226,7 @@ guest can be on Saver on their phone and Original on their laptop.
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)
### 2.6 Rate limits and quotas — toggleable
The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:
@@ -230,13 +238,14 @@ The Admin Config tab gains explicit on/off toggles in addition to the numeric in
- **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.
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, planned)
### 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

View File

@@ -19,8 +19,9 @@ can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
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).
7. The **first-visit onboarding overlay** appears: six dismissible steps (welcome, upload,
hashtags, long-press for more actions, hell/dunkel design pick, and PIN — the PIN step
also points at the **Datenschutzhinweis** in My Account when the admin has set one).
`localStorage('eventsnap_guide_seen') = 'true'` after dismiss.
8. Guest sees the bottom nav: **🏠 Feed · [📷+ FAB] · 👤 Account**.
@@ -41,7 +42,7 @@ can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
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)
## 4. PIN forgotten — Host or Admin resets it
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.
@@ -126,14 +127,23 @@ the Host can clean up later).
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.
viewer). Progress is visible on the Host dashboard *and* in the Admin dashboard's Export
tab; SSE `export-progress` keeps it live; `export-available` notifies all guests when
ready. Once released the button is disabled and reads *"Galerie bereits freigegeben"*
a second release is a 409. If a keepsake half fails, both dashboards show the reason and
offer **Erneut versuchen** (rebuild); a ready keepsake can also be rebuilt behind a
confirm, during which guest downloads are briefly unavailable.
5. **Nutzerverwaltung** — search users; per-user controls:
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
calls `POST /host/users/{id}/ban` (no body).
- **Entsperren** lifts the ban. Same authority boundary as ban (below): a plain Host may
only unban Guests; only an Admin may unban a Host.
- **Entsperren** lifts the ban — and does two further things the confirm sheet now spells
out, because both are visible to the whole party: it clears `uploads_hidden`, so **all**
of that user's previously hidden uploads reappear in the gallery, the diashow and the
export; and it invalidates and rebuilds a released keepsake (`invalidate_and_arm`), so
every guest's download is unavailable for as long as that takes. A ban does the mirror
image of both. Same authority boundary as ban: a plain Host may only unban Guests; only
an Admin may unban a Host.
- **Host** promotes a guest to host (Hosts and Admins may do this).
- **Degradieren** — demote a Host back to guest. **Only an Admin may change a Host's
role.** A plain Host may *not* demote a peer Host: doing so would let them then ban or
@@ -181,14 +191,18 @@ the Host can clean up later).
- **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.
plus the colour-theme picker, the **Datenschutzhinweis** free-text editor and
**on/off toggles** for the rate limiters and quotas (see §18). Whitelist on the
server side rejects unknown keys. Values are read from the `config` table on each
request — no restart needed.
- **Export**: the gallery-release control plus the list of export jobs with status
badges (pending / running / done / failed) and progress bars; refresh button
re-polls. The release control mirrors the Host dashboard exactly — disabled once
released (a second tap would only 409), live keepsake status with progress and the
failure reason, and a **Neu erstellen / Erneut versuchen** rebuild as the recovery
path for a failed or stale keepsake.
- **Nutzer**: same user list as Host, with the additional Demote action (admin-only,
see §9) and PIN-reset on host rows.
## 12. Releasing the export and downloading
@@ -224,11 +238,14 @@ the Host can clean up later).
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).
7. Downloads are rate-limited **per user** (default 3 / day), keyed on the user id in
`enforce_export_rate` — deliberately not per IP. At a venue where a hundred guests share
one NAT'd wifi, a per-IP budget would be exhausted by the third person to tap
**Herunterladen** and lock everyone else out of their own keepsake.
## 13. Diashow (planned)
## 13. Diashow
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the flow:
1. User taps a **Diashow / Präsentation** action (feed header on tablet/desktop, Account
on mobile).
@@ -240,7 +257,7 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
immediately.
6. Tap or Escape reveals an overlay (pause, dwell selector, exit).
## 14. Picking a data mode (planned)
## 14. Picking a data mode
1. Guest opens **My Account** → scrolls to **Datennutzung**.
2. Two options: **Datensparer (empfohlen)** and **Original**. Saver is the default.
@@ -252,14 +269,24 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
5. The viewer (offline HTML export) is unaffected — it already ships with its own pre-
bundled `_thumb` / `_full` variants.
## 15. Leaving an event
## 15. Signing out
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.
The wording matters here: nothing is *left* and nothing is deleted. The account, the PIN
and every uploaded photo survive — the user signs back in with name + PIN whenever they
like. ("Event verlassen" read as leaving for good, which the confirmation sheet then
contradicted by saying you can come back.)
## 16. Reading the Datenschutzhinweis (planned)
1. User opens **My Account** → Konto section. Two separate actions:
- **Abmelden** — this device only (`DELETE /api/v1/session`).
- **Auf allen Geräten abmelden** — every session of theirs (`DELETE /api/v1/sessions`),
for a lost or borrowed phone.
2. Bottom-sheet confirmation naming which of the two it is, with **Abmelden** / **Abbrechen**.
3. Confirming invalidates the session row(s), wipes the local JWT, PIN and the IndexedDB
upload queue (so the next guest on a shared phone doesn't inherit pending uploads), and
redirects to the join page. The session-delete call is best-effort: the token is gone
locally either way, so a network failure never traps the user on the page.
## 16. Reading the Datenschutzhinweis
1. User opens **My Account** → scrolls to **Datenschutzhinweis**.
2. The note is rendered inside a preformatted block (`<pre>`-style: monospace, whitespace
@@ -270,11 +297,15 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
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)
## 17. Mobile-first gestures (partly shipped)
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.
Shipped today: long-press context sheets on posts and comments, and lightbox navigation
(on-screen prev/next controls, ← / → arrow keys, and left/right swipe). The remaining rows
below are still planned.
| Gesture | Action |
|-------------------------------------------|-------------------------------------------------------|
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
@@ -294,7 +325,7 @@ Inspiration: Instagram (double-tap heart, swipe stories), WhatsApp (long-press f
context), Telegram (swipe-to-reply on messages — could inform comment threads if those
land).
## 18. Admin toggles a rate limit or quota off (planned)
## 18. Admin toggles a rate limit or quota off
1. Admin opens **Admin Dashboard → Config**.
2. **Rate-Limits** section: a master switch and per-endpoint switches (upload / feed /
@@ -305,8 +336,9 @@ land).
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).
6. When the storage-quota toggle is off, the **"Speicher: X / Y"** widget in My Account and
on the upload screen hides itself (no quota → no number to show). That widget is
staff-only in any case — guests never see it.
Suggested defaults at deploy time: all toggles **on**, sensible numeric limits.
Toggling off is the explicit escape hatch for testing or trusted internal events.