diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 6b30fa2..43a3533 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 diff --git a/docs/USER_JOURNEYS.md b/docs/USER_JOURNEYS.md index e1deed7..aaee438 100644 --- a/docs/USER_JOURNEYS.md +++ b/docs/USER_JOURNEYS.md @@ -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 (`
`-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.