a428fe6957bda6f52dc1f6baed4912cc7f7b2d41
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fffa2d556c |
fix(upload): stop the queue from wedging, and tell the guest when it fails
A STALLED UPLOAD BLOCKED EVERYTHING, FOREVER. The XHR set no timeout and had no stall detection, so a half-open connection from an AP roam left the item `uploading` indefinitely — which kept `processQueue`'s `processing` flag set, so the whole rest of the queue stopped draining. The UI offered no control at all for an `uploading` item. The guest saw "Wird hochgeladen 43%" all evening with four photos stuck behind it and no button to press; the only escape was force-quitting the PWA, which nobody guesses. Now: a watchdog aborts when no bytes move for 90s, disarmed on `loadend` so the server may take its time storing a file it already has; a size-scaled timeout as a generous backstop that will not kill slow-but-progressing LTE; and a cancel button. FAILURES WERE INVISIBLE. `handleSubmit` navigates to /feed immediately, and the queue component is mounted only on /upload — so a 5xx, a captive-portal error or an uploads-locked 403 wrote a German message into an item that nothing ever rendered. The guest believed the photo was uploading; it never appeared. Same for the documented rate-limit countdown banner, which lives in that same unreachable component and is now also rendered from the layout. RETRIES WERE UNCAPPED. `requeueRetriable` flipped every errored item back to pending on the `online` event AND on every `feed-delta` — i.e. every SSE reconnect — with no attempt counter and no backoff. On a flapping network a large failing video was re-uploaded from byte zero all evening, saturating the AP for everyone. Now a persisted attempt count, exponential backoff and a cap of five. INDEXEDDB COULD STRAND THE COMPOSER. `openDB` had no `blocked` handler, so a second tab holding an older version made it never settle, and it rejects outright on iOS private mode; `handleSubmit` had no try/catch and never reset `submitting`, so both buttons stayed disabled reading "Wird hochgeladen…" permanently, with no error and nothing queued. There is now a `blocked` handler plus a settle timeout, an in-memory fallback so uploading still works when persistence is unavailable, and a `finally`. A 401 during a background upload cleared the session without redirecting — and api.ts documents exactly why that strands a guest: the nav and FAB are gated on `isAuthenticated` so they vanish, route guards only run on mount, and a standalone PWA has no URL bar. Three early-return paths wrote status only to memory and never to IndexedDB, leaving blob-less error rows that could never be evicted and held the red FAB badge lit all night. A banned guest was offered the entire upload flow — FAB, camera, staging — and only the POST 403'd, while the new read-only banner told them uploading was disabled. The sheet now consults the ban, the layout subscribes to `user-hidden` so a live ban reaches the UI instead of arriving as a stream of 403 toasts, and the banner clears `env(safe-area-inset-bottom)` so the bottom nav stops covering it on notched iPhones. The /upload submit bar gets the same inset — it sat in the home-indicator zone, where the system swallows the first tap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c0eed885a |
fix(audit-1): media gating, per-user limits, moderation UI, upload integrity
First audit round: a blocker and eight high-severity findings across the media
access gate, the guest-facing rate limiters, host moderation, and the upload
pipeline — plus the backup commands and the e2e/production divergences that hid
several of them.
Squashed from 7 commits, original messages preserved below.
──────── fix(export): let the keepsake download through X-Frame-Options on iOS
The keepsake download navigates a hidden, same-origin iframe (deliberately:
a top-level navigation to a 404/429 would unload the PWA). Caddy stamped a
site-wide `X-Frame-Options: DENY` that also covered the proxied `/api/*`.
Blink hands a `Content-Disposition: attachment` response to the download
manager at the network layer, so Chromium never noticed. WebKit enforces XFO
on the frame navigation first and aborts the load — so on iOS Safari, the
app's primary platform, tapping Download did nothing at all, silently.
Carve the two export endpoints out to SAMEORIGIN, which still blocks
cross-origin framing. Implemented as two disjoint matchers rather than an
override: Caddy applies the FIRST `header` directive outermost, so it wins on
write and a later, more specific `header` is silently ignored (verified
against the running test stack).
Also close the test gap that let this ship:
- `06-export` ran on chromium-desktop only; add it to `webkit-iphone`, the
only engine that enforces XFO on the download frame.
- No test in the suite ever clicked a download button — every archive
assertion used Node `fetch`, which has no frame and no XFO enforcement.
Add a spec that clicks it and awaits a real `download` event. Verified
falsifiable: with the blanket DENY reinstated it fails and reports the
WebKit refusal as the cause.
- Fix `ExportPage`'s card-scoped locators, which matched nothing: the cards
carry `class="card p-5"` (a Tailwind `@apply` component class), never the
`rounded-xl` the page object looked for. This had left the "shows enabled
download buttons" test red on main.
──────── fix(media): close the percent-escape bypass of the media gate
`/media/%70reviews/{id}.jpg` served a taken-down photo to anyone,
unauthenticated. Verified against the running stack: the literal path 404s,
the escaped one returned 200 with the full image. Same for displays,
thumbnails and originals, and any escaped byte in any position works.
Cause: the block was four `nest_service("/media/previews", 404)` route
matches sitting above a `ServeDir` on `/media`. axum matches on the RAW path
(matchit does no percent-decoding), while `ServeDir` percent-decodes when it
resolves the file. So `%70reviews` missed every blocker, fell through to the
ServeDir, and was decoded back to `previews/` on disk — reaching the bytes
with no soft-delete and no ban-hide check. That defeats a host takedown,
which is the entire point of the gate.
Remove the `/media` route tree outright instead of racing the decoder.
Nothing needs it: every media URL the backend emits is already a gated
`/api/v1/upload/{id}/{original,preview,display,thumbnail}` alias
(handlers::feed), the frontend contains zero `/media/` references, and the
`/media` in config.rs/disk.rs is the filesystem path while `media/` in
export.rs is a path inside the zip. `/media/**` now 404s regardless of
encoding. The route's own comment already said it "serves nothing" — it
wasn't a backstop, it was the vector.
Caddy keeps proxying /media/* deliberately: the app 404s it, and forwarding
means the e2e gating specs exercise the app's refusal exactly as production
would rather than being masked by the SvelteKit 404 page.
Extend the gating spec with the encoded variants — asserting only the literal
spelling is what let this sit undetected.
──────── fix(rate-limit): key the guest-facing limiters per user, not per IP
At a venue every guest is behind one NAT, so an IP-keyed limiter hands the
whole party a single bucket. On a fresh deploy 12 guests arriving together
meant 5 joined and 7 were turned away, with no Retry-After telling them when
to retry. `/feed` (60/min) and `/export` (3 per DAY — the fourth guest to
fetch their keepsake locked out until tomorrow) had the same defect.
`feed_delta` was already keyed per-user and its comment states the exact
rationale ("so one client can't starve others behind a shared NAT"); this
makes its siblings match.
- feed:{ip} -> feed:{user_id} (auth was already in scope)
- export:{ip} -> export:{user_id} (resolved from the download ticket's
session, which was previously looked up and discarded)
- join:{ip}: pre-auth, so there is no user to key on. Split in two — a loose
per-IP ceiling that only bounds raw volume (new `join_ip_rate_per_min`,
default 60, migration 017), plus the real 5/60s anti-spam bucket keyed
per (ip, name), mirroring the existing `recover:{ip}:{name}`.
admin_login / recover / pin_reset_req stay IP-keyed on purpose and are now
commented as such: they guard credential guessing, where a per-user or
per-name key would just hand an attacker a fresh bucket per guess.
Retry-After: the machinery existed but 7 of 8 sites called `check()` and
hard-coded `None`, so a throttled client was told to back off but never for
how long. Delete the bool `check()` wrapper entirely so `check_with_retry`
is the only entry point and the delay cannot be discarded by accident. Also
surface it for the PIN lockout, where the deadline was already known.
Fix the "unknown" fallback while here: every client_ip() caller passed that
literal, so any request without X-Forwarded-For — anything reaching the app
directly rather than through Caddy — shared ONE global bucket. Serve with
connect-info and use the peer address.
Tests: the reseed forces every limiter toggle off before each test, which is
why this whole class was invisible. Add 01-auth/rate-limit-shared-nat, which
enables them and asserts 12 guests share an IP without collision, that one
guest hammering their own name IS still throttled (so the fix re-keys rather
than removes the limit), and that feed/export buckets are per-user. Retarget
the ddos join test at the new per-IP ceiling — it asserted the defect.
Also seed `admin_login_rate_enabled` (read by the handler, seeded by no
migration and no reseed) and register `join_ip_rate_per_min` in the admin
config allowlist. Unrelated pre-existing red test fixed: 01-auth/join
asserted a "Willkommen!" heading the wedding redesign removed.
──────── feat(moderation): let a host remove a guest's photo or comment from the UI
`DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were complete on the
backend — transactional, SSE-broadcasting, audit-logged — and had zero frontend
callers. The feed context sheet offered "Löschen" only when
`target.user_id === myUserId`, so the only lever a host actually had against an
unwanted photo was banning the uploader.
That is both disproportionate and ineffective. A ban doesn't retract what was
already posted, and it makes things strictly worse for comments: the ban check
runs BEFORE the ownership check on the guest delete route, so banning an abusive
author leaves their comment on screen and permanently undeletable by them. With
no host affordance, nobody could remove it at all.
- feed: hosts/admins get "Beitrag entfernen" on other people's posts, routed to
the host endpoint (the guest route 403s anything the caller doesn't own) with
moderation-specific confirm copy. Own-post "Löschen" is unchanged.
- lightbox: same for comments, via /host/comment/{id}.
- Ban semantics are deliberately untouched (USER_JOURNEYS §10 — banned users keep
read access and cannot write). The deadlock is broken by giving the host a way
in, not by loosening the ban.
Live role (this had to come first). `getRole()` decodes the JWT claim, but the
token is never reissued — the backend slides the session row forward and treats
the DB row as authoritative. The claim is therefore frozen for the token's
lifetime: up to 30 days. A guest promoted at the party saw no Host-Dashboard and
no moderation actions until they signed out and back in, even though
`/me/context` had been returning their real role on every page load and 4 of its
6 call sites dropped the field on the floor.
Add `role-store.ts`: seeded from the claim so there's no flash of the wrong nav,
then corrected by every `/me/context` response. Point the ad-hoc `getRole()`
callers at it (account, upload, host, admin, and the new feed gate). The host and
admin dashboards now derive `myRole` reactively, so a demotion disables their
controls immediately instead of at next login.
Tests: 04-host/moderation-ui drives the real UI — host removes a guest photo and
it's gone from /feed server-side; a plain guest is offered nothing on someone
else's post (the mirror that keeps the first test honest); a promoted guest gains
the dashboard on reload while their token still carries `role: guest`; and a host
removes the comment of an already-banned guest, asserting first that the author's
own delete 403s so the deadlock is real.
──────── fix(upload): stop destroying originals, apply EXIF orientation, surface rejections
Three defects in the same pipeline, each of which loses a photo or misrepresents
one.
1. A transient error destroyed the guest's only copy.
`process`'s error arm unconditionally `remove_file`d the original. Every failure
routed there: `create_dir_all`, both derivative `save_with_format` calls (disk
full is the canonical case, and it arrives exactly when many guests upload at
once), a panic inside the image codec, or a momentary DB-pool exhaustion. The
row is only SOFT-deleted, so the bytes were the sole unrecoverable part — and
they were the part we deleted. The author already knew this was wrong next door:
`backfill_missing_display` says it "must NEVER soft-delete an upload that already
has a working preview".
Retry up to 3 times with backoff (re-checking the e2e generation guard after each
sleep), and on final failure keep the refund + soft-delete but leave the original
on disk, logging its path. A failed upload is now recoverable instead of gone.
2. Every portrait photo was stored sideways.
Phones don't rotate sensor data — they record the camera orientation in EXIF and
store the pixels as shot. `decode()` returns those raw pixels and the JPEG
re-encode writes no EXIF, so the 800px preview, the 2048px diashow display and
the keepsake were all rotated 90°, while "Original anzeigen" rendered upright
because the original keeps its tag. That asymmetry is why it reads as a viewer
bug. There was no EXIF handling anywhere in the repo and no exif crate.
Read the tag via `into_decoder()` (which carries the decode Limits through, so
the decompression-bomb cap is untouched) and apply it. Missing/malformed tags
fall back to NoTransforms — most images have none.
Existing derivatives are already baked wrong, so migration 018 adds
`derivatives_rev` and `backfill_missing_display` becomes
`backfill_stale_derivatives`: it now also picks up anything below the current rev
and regenerates it once from the original, which still carries its EXIF. Videos
are marked current in the migration — ffmpeg already honours the rotation matrix.
Bump DERIVATIVES_REV for any future change that invalidates derivatives.
3. A rejected upload vanished without a word.
`UploadQueue.svelte` — 162 lines holding the ONLY renderer of an item's error
text, the only "Erneut" retry button and the only rate-limit countdown — was
never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were
unreachable at runtime. On a terminal rejection the store purged the blob and
wrote a clear German reason into `entry.error` "so the UI shows a clear reason".
There was no such UI. And `uploadBadgeCount` counted only pending/uploading, so
the badge decremented exactly as if the upload had succeeded.
Mount the queue on /upload, toast the reason immediately (the flow sends the user
to /feed straight after staging, so the list alone would still miss them), and
count blocked/error in the badge so a failure can't read as success.
Tests: 02-upload/exif-orientation uploads a 40x20 fixture tagged Orientation=6
and asserts both derivatives come back PORTRAIT, with a sanity check that the
source really is stored landscape. 02-upload/rejection-visible bans the uploader
between staging and sending, then asserts the toast, the queue row with the
server's reason, and that the item is still counted.
Note: 02-upload/quota's 4 failures are pre-existing and unrelated — see the next
commit.
──────── docs(backup): make the backup commands work; fix the e2e/prod divergences
Backup. Both documented commands failed on the shipped stack, and the sentence
explaining them was wrong too:
- `pg_dump $DATABASE_URL` — `DATABASE_URL` is only ever in the compose
environment, never an operator's shell, and it points at `db:5432`, which is
compose-internal DNS. The app image has no postgres client either.
- `> /media/backups/…` — `/media` is a named volume mounted inside the app
container, not a host path, and nothing ever creates a `backups` subdirectory.
- `rsync /opt/eventsnap/media/` — that path does not exist anywhere.
- "a single path to back up" — false, and dangerously so: exports were moved to
their own `exports_data` volume precisely so a keepsake (which contains every
photo in the event) can't be served off the media tree. Backing up only
`media_data` silently loses every generated keepsake.
Rewritten as three commands — db via `docker compose exec -T db pg_dump`, and one
`docker run … tar` per volume — all verified against the running stack. The
volume mounts use `/src`, not `/media`: I hit the footgun while testing this.
Docker pre-populates an EMPTY volume from the image's own directory and chowns it
to match, so `-v media_data:/media alpine` tars alpine's cdrom/floppy/usb, writes
them into the volume, and leaves it root-owned so the non-root app can no longer
write. Mounting where the image has nothing avoids all of it. Documented inline
so the next person doesn't rediscover it.
Also correct the architecture notes: `/media/*` no longer routes to the backend
(that static tree was removed as a gating bypass), and `exports_data` was missing
from the volume list — the one volume an operator most needs to know about.
e2e stack: add the `EXPORT_PATH` + `/exports` volume it was missing. The file
says "mirrors production layout"; without these, exports landed on the container's
writable layer at the default path, so export-leak and export-video wrote real
archives into ephemeral storage and the "exports live outside media" invariant
was never actually exercised.
Pre-existing red test, unrelated to the audit: all four 02-upload/quota tests
have been failing since
|
||
|
|
44641473ea |
fix(quota): stop /me/quota leaking raw disk to guests
The per-user quota widget was shown to everyone and the /me/quota payload returned free_disk_bytes (raw server free space) and active_uploaders to any authenticated guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero the server-wide telemetry fields for non-staff in the handler. Guests still get their own used/limit so enforcement stays transparent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f243bfe89a |
feat(ui): elegant wedding white/silver/gold redesign
Reskin the whole app via design tokens in tailwind-theme.css (remapped color ramps) plus a define-once component layer in lib/styles/components.css (.btn, .card, .input, .chip, .badge, .sheet, …). Buttons are muted/outlined gold rather than flat fills. Self-host Inter + Fraunces (woff2) under the existing font-src 'self' CSP. Restyle the shared components and the account, admin, host, join, recover and upload screens against the new tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f8cba95e49 |
chore(frontend): add ESLint + Prettier; fix real findings; format the tree
The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
36fe59caa5 |
fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session
Follows the perf + security + user-flow work with a role/persona audit (guest, host, admin, projector) and fixes across three review rounds. Highlights: HIGH - Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has no replay, so a client that missed it (esp. the unattended diashow) kept cycling a banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in /feed/delta; feed + diashow evict those users. Applied even on a truncated delta. MEDIUM - Locked-upload data loss: a photo staged offline during a lock/release was purged as a terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code; the queue keeps the blob and auto-resumes on the `event-opened` SSE. - Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake. - Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host race can't hand out a conflicting PIN. - Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren hidden on peer-host rows for non-admins (they always 403'd). - Host dashboard shows live keepsake generation progress / ready state + link to /export. - Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices. Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq` (migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation temp/final paths, download follows `file_path`, prune only strictly-older generations. LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401; delta `>=` tie-break + 429 retry; misc copy/labels. Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a data-completeness test. Reconciles USER_JOURNEYS §9/§11. Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit tests, 155 e2e passing on chromium-desktop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e79e020566 |
feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend - Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and bottom nav working. List = dynamic measured heights keyed by upload id + anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured square rows. Drops the content-visibility band-aid and the old FeedGrid. measureElement(null) on row unmount prevents ResizeObserver retention; stable option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches. - Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest + maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers. - Feed reactivity: like/comment SSE patch the single card in place; upload-processed debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock. - CLS: list cards reserve the skeleton aspect box. - Destructive actions (promote/demote/unban, gallery release) routed through ConfirmSheet (host + admin). - Export OOM: streamed download via single-use ticket instead of res.blob(). - Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y. - Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry, ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons. Backend - social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients patch one card in place instead of refetching page 1. - admin.rs / main.rs: supporting changes for the above. Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed scroll-feel validation still owed (documented in FOLLOWUPS.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
309c25bc06 |
feat(frontend): UX review followups — primitives + a11y/UX fixes across 4 passes
New shared primitives: - Toaster + toast-store, ConfirmSheet, Modal, focusTrap action, pullToRefresh action, avatarPalette + initials helper, Skeleton, HeartBurst, haptics, export-status store with onClearAuth hook Critical UX/a11y: - Replaced window.confirm with branded ConfirmSheet - Focus management + Escape on every modal (PIN, Lightbox, Onboarding, ContextSheet, data-mode sheet, leave-confirm, HTML guide, host/admin ban + PIN-display modals) - Sheet backdrops are real buttons with aria-label - Silent ApiError catches now surface via global Toaster Major polish: - Dark-mode parity on HashtagChips + avatars (shared palette) - Conditional Export tab in BottomNav (badge dot when ZIP ready) - Back chevrons on /recover (history-aware) and /export - Upload composer discard confirmation when content is staged - Camera segmented Photo/Video shutter - PIN auto-submit on 4th digit, paste-flash-free (controlled input) - Welcome-back toast on /feed after PIN recovery Minor: - Skeleton states on feed; pull-to-refresh with live drag indicator - Haptics on like / capture / submit / PIN-copy / onboarding complete - Comment 500-char counter; quota "Fast voll" / "Limit erreicht" labels - Onboarding pip ≥24px tap targets; long-press hint step - overscroll-behavior lock on <html> while feed mounted - teardownExportStatus wired via onClearAuth (covers 401 + explicit logout) - ConfirmSheet per-instance titleId; Modal requires titleId or ariaLabel Tests (7 new Playwright specs): - 01-auth/pin-auto-submit, 01-auth/back-chevron - 03-feed/confirm-sheet-delete, 03-feed/toast-on-failure - 09-mobile/focus-trap, 09-mobile/sheet-escape, 09-mobile/upload-cancel-confirm FOLLOWUPS.md captures the deferred AT inert containment work with acceptance criteria + implementation sketches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1cdab21514 |
fix(frontend): a11y backdrop, ≥44px PIN button, test-ids on auth & upload
- account/+page.svelte: remove `aria-hidden="true"` from the leave-confirm and data-mode-warning bottom-sheet backdrops. The attribute cascaded into the dialog children, making the inner Abmelden/Aktivieren/Abbrechen buttons unreachable in the accessibility tree (and to Playwright's `getByRole`). Discovered while writing the E2E suite; the visual layout is unchanged. - join/+page.svelte: bump the PIN-copy button from `py-1` (28px tall) to `min-h-11 min-w-11 py-2` so it clears the ≥44px touch-target floor on mobile. Touch-target audit revealed the gap. - data-testid attributes on stable interactive elements (join name input, join submit, PIN modal + copy + continue, recovery PIN + submit + try- different-name, admin login password + submit + error, recover name + PIN + submit + error, upload header submit + sticky submit + caption textarea). Targeted at ~20 spots where semantic locators were ambiguous (e.g. two "Hochladen" buttons on /upload, German strings that may iterate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e619a3bd64 |
feat(ui): v0.16 features + dark mode across every page
Wires up everything from the previous commits into actual UI surfaces, and applies Tailwind dark: variants throughout. All pages now support the 'system' / 'light' / 'dark' preference set in the onboarding step or in Mein Konto → Design. Layout & nav: - routes/+layout.svelte: initTheme(), global pin-reset SSE handler that filters by user_id and calls clearPin(), one-shot /me/context fetch on boot to hydrate privacyNote + quota. - components/BottomNav.svelte: dark variants on the frosted-glass bar. - components/UploadSheet.svelte: dark variants on backdrop, sheet, source buttons. - components/OnboardingGuide.svelte: new "Helles oder dunkles Design?" step (3-option custom-radio grid), reactive currentStep with proper type narrowing, dark variants throughout. Privacy-note nudge appears on the PIN step only when one is configured. Feed: - routes/feed/+page.svelte: diashow entry icon (tablet/desktop only), long-press → ContextSheet (Löschen for own posts, Original anzeigen for all), upload-deleted + feed-delta SSE handlers, dark variants on header, search, autocomplete, filter chips, empty states. - components/FeedListCard.svelte: long-press wireup, double-tap-to-like, data-mode-aware mediaSrc via pickMediaUrl, kebab fallback for desktop, isOwn prop, dark variants. - components/FeedGrid.svelte: long-press wireup, dark variants. - components/LightboxModal.svelte: data-mode-aware src, double-tap heart burst, dark variants on card / comments / input. - components/HashtagChips.svelte: dark variants. Account: - routes/account/+page.svelte: theme picker (3-button radio grid), data mode picker (with confirm sheet for Original), live quota widget, preformatted Datenschutzhinweis block, diashow tile (mobile only), pin now sourced from the $currentPin store so a global pin-reset clears it live, clearQueue() on explicit logout, dark variants across every card + both bottom sheets. Upload: - routes/upload/+page.svelte: per-user quota progress bar above the submit button, dark variants. Host & Admin: - routes/host/+page.svelte: PIN-reset confirm + one-time PIN modal, hosts may demote other hosts, canResetPinFor() helper, dark variants on all cards, modals, stats, toast. - routes/admin/+page.svelte: Config form rebuilt as CONFIG_GROUPS with per-field kind (number / bool / text), renders toggles for the rate-limit + quota switches and a textarea for the privacy_note; Nutzer tab gains PIN reset + hosts-may-demote-hosts wiring; same one-time PIN modal; dark variants everywhere. - routes/admin/login/+page.svelte: dark variants. Join / Recover / Export: - routes/join/+page.svelte: rename inline link to "Ich habe bereits einen Account", dark variants. - routes/recover/+page.svelte: dark variants. - routes/export/+page.svelte: dark variants on status cards + HTML guide modal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4a5506f32d |
feat: mobile-first UI redesign (v0.15.0)
- Persistent bottom tab bar (Feed · FAB · Account) on all authenticated pages - Upload FAB triggers bottom sheet (Galerie / Kamera) → navigates to composer - Upload page redesigned as full-screen composer with thumbnail strip, textarea, quick-tag chips, sticky submit button; bottom nav suppressed while composing - Slim upload progress bar above bottom nav driven by queue state - Feed: list/grid view toggle; list = chronological full-width FeedListCard; grid = 3-col with search bar, autocomplete from loaded posts, filter chips - Account page: role-gated dashboard links (Host / Admin); Konto section with leave-confirm bottom sheet; no more per-page header nav icons - Host dashboard: back arrow, collapsible sections, 2-col stats, user search - Admin dashboard: back arrow, inner tab bar (Stats/Config/Export/Nutzer), stacked config inputs with sticky save, new Nutzer tab - BottomNav hidden on unauthenticated pages via isAuthenticated store - FeedGrid: threeCol prop; OnboardingGuide upload step updated for FAB - Concept docs added to docs/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
25f4fb1810 |
feat: implement camera capture step
Add in-app camera capture to the upload flow. Guests can now take photos and record videos directly via getUserMedia without leaving the app. The captured media is immediately queued through the existing IndexedDB upload pipeline alongside library-picked files. - CameraCapture.svelte: fullscreen overlay with live preview, photo capture (JPEG via canvas), video recording (WebM/MP4 via MediaRecorder), front/back camera toggle, recording timer, and permission-denied error state - Upload page: side-by-side "Gallery" and "Camera" pickers; shared caption/hashtags fields apply to both sources; Blob→File conversion with timestamped filename before enqueue - .env.test: reference environment config for local testing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
4e1f1d6426 |
feat: implement client-side upload queue with IndexedDB persistence
- upload-queue.ts: IndexedDB-backed queue manager using idb library - File blobs stored in IndexedDB (survives page reloads) - Sequential upload processing (one file at a time) - XHR-based upload with per-file progress tracking - Retry failed uploads, remove/clear completed items - Auto-resumes pending items on page load - UploadQueue.svelte: queue progress UI component - Per-file: filename, size, progress bar, status badge - Retry button on failed items, remove button, clear completed - Processing indicator with pulse animation - /upload page: file picker (multiple, image/video) with caption + hashtags - Drop zone UI with drag-and-drop styling - Caption supports inline #hashtags - Separate comma-separated hashtags field - Link to gallery feed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |