Commit Graph

33 Commits

Author SHA1 Message Date
MechaCat02
89b8785a40 bugfix: reader-nav is fully fixed; no settle-on-scroll (0.21.3) 2026-05-17 20:57:05 +02:00
MechaCat02
64ccc0ba84 bugfix: measure bar heights with ResizeObserver instead of magic numbers (0.21.2) 2026-05-17 20:47:32 +02:00
MechaCat02
215325ad2f bugfix: reader nav sticks under the app header instead of behind it (0.21.1)
$(top offset was 44px (header's 60px minus var(--space-4)), placing the bar inside the layout header. Now sticks at var(--app-header-h).)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:42:38 +02:00
MechaCat02
7aa6e7e6d9 feat: chapter chevrons, sticky app frame, and focus mode (0.21.0)
Reader gets chapter-aware chevrons + a persistent app frame +
distraction-free focus mode.

- Single-mode chevrons (and ArrowLeft/Right + j/k) advance pages
  within the chapter and fall through to the adjacent chapter at the
  boundaries. Last page of last chapter / first page of first
  chapter disables the chevron and silent-no-ops on the keypress.
- Continuous-mode gets a fixed bottom bar with prev/next chapter
  buttons; arrows + j/k jump chapters directly.
- `?page=N` and `?page=last` URL query lets the prev-chapter jump
  land on the previous chapter's last page.
- Layout header is fixed at the top; reader nav is sticky just
  below it; both stay visible while scrolling so reading settings
  are always reachable.
- New "Focus" toggle in the reader nav hides the layout header,
  reader nav, and bottom chapter bar with smooth 220ms slide
  animations. Exit via Esc or a small floating Minimize2 button at
  the top-right (low resting opacity, full on hover). Reset on
  reader unmount so it doesn't leak to other pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:39:03 +02:00
MechaCat02
c95c1805df feat: upload flow revamp (0.20.0)
- `/upload` is now manga-only with optional N initial chapters
  staged inline.
- Additional chapters from a new `/manga/[id]/upload-chapter` route,
  reached via an "Upload chapter" button on the manga page.
- New `ChapterPagesEditor` component: thumbnails next to each row,
  click-to-preview-modal, drag-drop + reorder.
- Pages renamed to `page-NNN.<ext>` before multipart submission;
  original filenames shown as dimmed reference text during upload
  and dropped on submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:59:22 +02:00
MechaCat02
21f44cea3f bugfix: GET /me/bookmarks returns total count (0.19.2)
The profile overview's bookmark counter showed 0 even when the user had bookmarks because /me/bookmarks left page.total null. Repo now returns the count alongside the rows; handler uses with_total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:41:27 +02:00
MechaCat02
58e637085d bugfix: don't JSON.parse empty 200/201 bodies (0.19.1)
$(addMangaToCollection crashed when the backend returned 201/200 with no body — the shared client only short-circuited 204. Now any empty body returns undefined.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:30:39 +02:00
MechaCat02
19c1276490 feat: read & upload history (0.19.0)
Per-user reading progress and uploader attribution.

Schema (migration 0011): `read_progress` table (one row per (user,
manga); chapter_id nullable on chapter delete) and nullable
`uploaded_by` columns on mangas + chapters with partial indexes
scoped to non-null rows.

Endpoints (all `/me/*`, auth-scoped):
- PUT `/v1/me/read-progress` upserts. FK violations + cross-manga
  chapter ids both surface as 4xx (404 / 422) so the API can't be
  used to write logically invalid rows.
- GET `/v1/me/read-progress` paged newest-first list.
- GET `/v1/me/read-progress/:manga_id` enriched with chapter_number
  for the manga page's Continue CTA.
- DELETE `/v1/me/read-progress/:manga_id` idempotent.
- GET `/v1/me/uploads` interleaved manga + chapter uploads as a
  tagged union; limit-only pagination.

Existing manga + chapter upload handlers stamp `uploaded_by`.

Frontend:
- Reader emits progress on mount + page change (debounce) and via
  IntersectionObserver in continuous mode. High-water mark is seeded
  from the persisted server value so re-opening a chapter doesn't
  regress to page 1. Tab close survives via `sendBeacon` (fallback
  `keepalive` fetch); SPA navigation flushes via regular fetch.
- Manga detail page shows "Continue reading Chapter N — page M"
  above the chapters list, working even for mangas with >50
  chapters.
- New `/profile/history` tab with reading history (clear-per-row,
  inline error on failure) and uploads (mangas + chapters mixed
  chronologically with type-aware rendering).

171 backend tests (incl. 16 history tests covering ownership, FK
race, cross-link guard, chapter SET NULL behaviour) and 97 frontend
tests + svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:19:52 +02:00
MechaCat02
7560d59616 feat: /profile dashboard with tabbed preferences, account, bookmarks, collections (0.18.0)
Tabbed user dashboard at `/profile` that absorbs `/settings` and
surfaces bookmarks + collections in one place.

- New `/profile` shell with tabs: Overview (counts), Preferences
  (theme + reader prefs, ported from /settings; works for guests
  via localStorage), Account (password change; auth-gated),
  Bookmarks, Collections. Guest tab list is filtered to what they
  can actually use.
- `/settings` is a 308 redirect to `/profile/preferences` so old
  bookmarks land cleanly. The "Settings" link in the top nav is
  replaced by a Profile link between Upload and Bookmarks; Bookmarks
  + Collections stay as shortcuts per the user spec.
- Extracts `lib/components/BookmarkList.svelte` and
  `lib/components/CollectionsGrid.svelte` so the top-level
  /bookmarks + /collections routes and the new profile tabs render
  the same UI without duplication. Both layers use a three-state
  load (authenticated / guest / error) to handle network hiccups
  inline.
- Deep links preserved via `?next=` on every sign-in CTA.

88 frontend unit tests + svelte-check clean; 12 of 12 e2e tests in
profile.spec.ts and reader-mode.spec.ts pass (8 other e2e failures
predate this branch and stay flagged for cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:59:29 +02:00
MechaCat02
274cc819ca feat: manga collections (0.17.0)
User-owned named lists of mangas with an add-to-collection modal on
the manga page and dedicated /collections and /collections/:id pages.

- Schema (0010): `collections` (per-user case-insensitive name
  uniqueness) + `collection_mangas` join with cascade FKs.
- Endpoints: full CRUD on `/v1/collections`, idempotent add/remove
  for `/v1/collections/:id/mangas`, and `/v1/mangas/:id/my-collections`
  for the modal's pre-checked state. Owner-mismatch surfaces as 404
  (not 403) so the API doesn't disclose collection existence to
  non-owners; the frontend funnels 401 to /login. Three-state PATCH
  via a new shared `domain::patch::Patch<T>` lets clients distinguish
  "leave alone", "clear", and "set" for description.
- Frontend: reusable `Modal` component (focus trap, opt-in
  backdrop close, ESC) and `AddToCollectionModal` with optimistic
  toggling that's race-safe under fast clicks. /collections page
  renders cover-collage cards; /collections/:id is editable with
  per-card remove. Top nav gets a Collections link.

155 backend tests (incl. 21 collection tests covering ownership,
idempotence, sample-cover enrichment, three-state PATCH, FK race);
88 frontend tests; svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:43:06 +02:00
MechaCat02
5e92a2c450 feat: author pages with /authors/:id route (0.16.0)
- `GET /v1/authors/:id` returns `AuthorWithCount` (id, name, manga_count).
- `GET /v1/authors/:id/mangas` paged works by that author.
- `GET /v1/authors?search=` autocomplete (already used by Phase 1 forms;
  now formally exposed).
- New `/authors/:id` page on the frontend; author chips on the manga
  detail page (added in Phase 1) now link to a real page.
- Extracts `lib/components/MangaCard.svelte` — already used by the home
  page, ready for the collection page in Phase 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:39:11 +02:00
MechaCat02
59d380b6d7 feat: manga metadata with status, authors, genres, tags, and search filters (0.15.0)
Adds first-class manga metadata across the stack:

- **Status** (ongoing / completed), **alternative titles**, normalized
  **multi-author** support, **curated genres** (13 seeded), and
  **free-form user tags** (case-insensitive, globally shared). Each is
  modelled as its own table joined to mangas; `mangas.author` is
  backfilled into `authors` + `manga_authors` and dropped.
- New endpoints: `PATCH /v1/mangas/:id` (three-state `description`),
  `POST/DELETE /v1/mangas/:id/tags[/:tag_id]`, `GET /v1/genres`,
  `GET /v1/tags?search=`.
- `GET /v1/mangas` now returns `MangaCard` (with authors + genres
  batched in) and supports `?status=`, `?author_id=`, `?genre_id=`,
  `?tag_id=` filters — AND across facets, with empty-array no-op
  semantics for the unnest primitive.
- `GET /v1/mangas/:id` returns the enriched `MangaDetail` with tags.
- Frontend: reusable `Chip` component; manga detail page renders
  authors as chips linking to `/authors/:id` (Phase 2), a status
  badge, alt titles, genres, and tags with inline add/remove (only
  the attacher sees remove); upload form supports multi-author /
  multi-genre / alt titles / status; search page gets a collapsible
  URL-synced filter panel with keyboard-navigable tag autocomplete.
- 126 backend tests (incl. AND-across-facets primitive, case-insens
  author/tag de-dup, transactional create rollback, PATCH semantics
  for missing / null / set on description); 72 frontend tests +
  svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:32:03 +02:00
MechaCat02
60cc7712fa feat: continuous reader mode with persisted preference
Add a vertical-scroll continuous mode to the reader alongside the
existing single-page mode. A segmented toggle in the reader top bar
switches between them; in continuous mode a gap selector
(None/Small/Medium/Large → 0/12/32/64px) controls the spacing
between stacked pages. Settings page mirrors the same controls.

Backend: new user_preferences table (one row per user, lazily
inserted, ON DELETE CASCADE) and GET/PATCH /api/v1/auth/me/preferences
gated by the existing CurrentUser extractor. Allowed values are
enforced both by API validation and table-level CHECK constraints.
Eight integration tests cover defaults, persistence, partial
updates, validation errors, auth, per-user isolation, and cascade.

Frontend: a new preferences store mirrors the theme-store pattern
with a localStorage shadow so anonymous browsers get a consistent
experience and logged-in users don't flash defaults while the
server response is in flight. Server values that the frontend
doesn't recognize (forward-compat) are ignored rather than poisoning
the UI; non-401 PATCH errors revert the optimistic local update;
logout clears the shadow so user A's settings don't follow user B
on a shared browser.

In continuous mode native scrolling handles Space/PageDown/arrows;
Home/End remain wired and call scrollIntoView() so jumping to chapter
bounds stays one keystroke. Single-page mode (chevrons, arrow-key
pagination, next-page preload) is unchanged.

Versions bumped 0.13.0 → 0.14.0 in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:15:03 +02:00
MechaCat02
567d56bfa1 feat: design system with light/dark themes and icon-first UI
Adds a real design system to replace the per-route ad-hoc styling:

- docs/design-system.md is the contract. Semantic CSS custom-property
  tokens (color/type/spacing/radii/shadows/z-index) with verified WCAG
  AA/AAA contrast ratios for both themes.
- frontend/src/lib/styles/tokens.css defines :root tokens + a
  [data-theme="dark"] override + base element resets, a .form-field
  helper, and a global prefers-reduced-motion rule.
- frontend/src/lib/theme.svelte.ts is a Svelte 5 runes store backing
  the theme state machine (system | light | dark). localStorage key
  'mangalord-theme'; matchMedia subscription that re-resolves on OS
  theme change while in 'system' mode; init() / destroy() lifecycle
  wired from +layout.svelte.
- frontend/src/app.html runs a synchronous inline script before
  %sveltekit.head% to set [data-theme] before first paint. No FOUC.
- /settings gains a System / Light / Dark radiogroup (real fieldset +
  legend + radios with lucide icons).
- Every route's <style> block is rewritten to consume tokens — home,
  auth, upload (drop-zone + page list), bookmarks, manga overview,
  reader.
- @lucide/svelte icons replace ad-hoc text controls per the spec:
  Search (icon-only primary), LogOut (icon-only muted), Upload /
  Bookmarks / Settings nav inline icons, ChevronLeft/Right for the
  reader, ArrowUp/Down/Trash2 for the upload page list. The bookmark
  toggle keeps its '☆ Bookmark' / '★ Bookmarked' text verbatim.
- Home search controls split into two rows: input + Search CTA on
  row 1, Sort (and future filters) on row 2.

Accessibility: every icon-only button carries aria-label, every
decorative SVG aria-hidden; existing image alt text preserved;
focus-visible rings reach every interactive element including the
visually-hidden theme radios; color is never the sole conveyor.

Version bump 0.12.0 → 0.13.0 across backend/Cargo.toml and
frontend/package.json (feat: → minor per CLAUDE.md).

Bars: svelte-check 0/0, vitest 51/51, playwright 18/18, cargo test
88/88, clippy -D warnings clean. Two rounds of independent review;
verdict ship-ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:52:58 +02:00
MechaCat02
f0e57b0615 feat: show manga covers everywhere a manga is referenced
Every place that surfaced a manga title used to show *just* the
title — the home page, the reader's back-to-manga link, and the
chapter upload form's manga selector. Adding the cover image
alongside makes the app feel like an actual manga library.

- Home (`/`): manga list switched from a one-line `<a>` per item to
  a responsive grid of cards (`auto-fill, minmax(140px, 1fr)`),
  each card showing the cover (with 📖 placeholder when no cover is
  set), the title (line-clamped to 2 rows), and the author.
- Reader (`/manga/[id]/chapter/[n]`): the back-to-manga link in the
  reader header now shows a 28×42 thumbnail of the manga's cover next
  to the title. Reuses the placeholder pattern for cover-less mangas.
- Upload (`/upload`): the chapter form's manga `<select>` still uses
  a native dropdown (covers don't fit in `<option>`), but a preview
  pane below the select now shows the currently-selected manga's
  cover + title + author so the user can visually confirm which
  manga they're attaching the chapter to.

No backend changes — `cover_image_path` was already in the Manga
JSON; only the frontend needed to read it.

Lockstep version bump to 0.12.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:52:57 +02:00
MechaCat02
dee7f1d160 bugfix: /bookmarks renders manga title and cover
The bookmarks list was rendering "Manga bookmark <date>" with no
indication of which manga the bookmark referred to. The data is
already in the DB — the list query just wasn't pulling it.

Backend:
- BookmarkSummary gains manga_title (String) and
  manga_cover_image_path (Option<String>). Populated by an INNER JOIN
  on `mangas` in `repo::bookmark::list_for_user`. The JOIN is INNER
  because `bookmarks.manga_id` has ON DELETE CASCADE, so a bookmark
  cannot outlive its manga. Chapter LEFT JOIN unchanged.
- The existing list_me_enriches_chapter_bookmarks_with_chapter_number
  test now also asserts manga_title is populated for both chapter-
  and manga-level bookmarks, and that manga_cover_image_path is null
  when no cover was uploaded.

Frontend:
- Bookmark type carries optional manga_title and
  manga_cover_image_path (optional because POST /bookmarks returns
  the bare Bookmark, not the enriched summary).
- /bookmarks page redesigned as a grid: cover thumbnail (64×96 with
  a placeholder when no cover) on the left, then the manga title (as
  the primary link), then either "Chapter N — page M" linked to the
  reader, "(chapter removed)" for orphan chapter bookmarks, or
  "Whole manga" for manga-level bookmarks. Bookmark date moves to a
  subdued footer.
- E2E fixtures track the enriched shape returned by the list endpoint
  (vs. the bare Bookmark returned by POST). The toggle test now
  asserts the manga title appears on the bookmarks card after the
  bookmark is created.

Also: tighten .gitignore. `/data` only catches the compose volume
root; the dev backend writes to `/backend/data` (default STORAGE_DIR
is `./data/storage` relative to backend cwd), so local uploads were
showing as untracked. Adding `/backend/data` keeps test uploads out
of the index.

Lockstep version bump to 0.11.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:46:01 +02:00
MechaCat02
c7cb689984 feat: settings page exercises the password-change endpoint
The 0.10.0 backend endpoint had no UI caller — the audit flagged it
as either-ship-a-form-or-remove-the-endpoint dead code. Shipping the
form, plus the bearer-token-keeps-working regression test the audit
asked for to pin the docstring contract.

Backend:
- New test change_password_via_bearer_leaves_bearer_working asserts
  that PATCH /me/password called with Authorization: Bearer wipes
  cookie sessions but leaves the bearer (api_token) intact and usable
  — matches the docstring claim that bot tokens are opt-in to revoke.

Frontend:
- lib/api/auth.ts: new changePassword(input) wrapping PATCH
  /v1/auth/me/password. Vitest covers happy 204, 401 unauthenticated
  (wrong current), 400 invalid_input (weak new) — same envelope
  parsing shape used elsewhere.
- routes/settings/+page.svelte: minimal form with current /
  new / confirm fields, derived passwordsMatch + canSubmit guards
  (submit stays disabled until current is filled, new is ≥8 chars,
  new == confirm). Shows the API's message inline on failure.
  Documents the "other devices signed out, bot tokens stay" UX in a
  short hint.
- routes/+layout.svelte: new "Settings" link in the session-aware
  nav (between username and Logout) for authed users only.
- e2e/settings.spec.ts (5 cases): nav link reaches the form,
  successful change shows confirmation + clears the form, 401
  surfaces inline, password mismatch keeps submit disabled, anonymous
  user gets a sign-in prompt instead of the form.

Lockstep version bump to 0.11.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:16:22 +02:00
MechaCat02
49f6d4d213 bugfix: third-pass audit follow-ups (F1-F4 + dockerignore)
- F1: backend/Dockerfile now copies Cargo.lock alongside Cargo.toml
  and builds with --locked, so the production image runs against the
  exact crate versions CI tested. Without this, cargo silently
  resolved fresh on each image build and "we tested it" stopped being
  true for the binary you ship.
- F2: POST /api/v1/mangas/{id}/chapters rejects chapter `number < 1`
  with 422 validation_failed. Mirrors the bookmark page>=1 rule from
  0.9.4 — chapter numbers are 1-indexed everywhere (URLs, upload
  form, reader) and 0/negative numbers had no legitimate use. Three
  cases (0, -1, -100) in api_uploads.rs.
- F3: bookmarks/+page.ts no longer re-throws non-401 ApiErrors as
  SvelteKit's generic 500 page. Surfaces the error message inline via
  a new `data.error` field; the page renders an alert when present.
  Same UX shape as the home page's existing error handling.
- F4: dropped Space from the reader keyboard binding. On portrait
  phones and narrow desktop windows the page image overflows the
  viewport and the user expects Space to scroll — preventDefaulting
  it skipped past unread content. ArrowRight + j remain.
- New backend/.dockerignore and frontend/.dockerignore so the local
  target/ and node_modules/ don't get shipped into the build context
  on every `docker compose build`.

Lockstep version bump to 0.10.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:13:14 +02:00
MechaCat02
a8d6da167c bugfix: second-pass audit follow-ups (N1-N4)
Four small follow-ups from the second-pass audit:

- N1: `manga_upload_rolls_back_when_cover_storage_fails` covers the
  manga-side of the transactional rollback path. The chapter case had
  a `FailingStorage` regression test already; this completes the
  symmetric pair. With fail-on-put-index=0, the cover put fails on
  the first call, the transaction aborts, and `SELECT count(*) FROM
  mangas WHERE title = 'Berserk'` is 0.

- N2: The SvelteKit proxy now catches network-layer failures from the
  upstream `fetch` (DNS / connection refused / TLS handshake) and
  returns a 502 with the standard error envelope
  (`code: 'upstream_unavailable'`) instead of letting SvelteKit's
  generic 500 HTML page through. `client.ts` can `.json()` the result
  cleanly so callers see a real ApiError with a meaningful code. The
  underlying cause is logged via `console.error` for the operator.
  Test in hooks.server.test.ts asserts the 502, the JSON envelope, and
  that `resolve` is not called (the proxy short-circuits).

- N3: `GET /api/v1/files/*key` now sets
  `X-Content-Type-Options: nosniff`. The upload-time magic-byte sniff
  is authoritative for what we declare as Content-Type; `nosniff`
  makes the contract explicit so older user-agents can't try to
  re-detect HTML/JS in a polyglot file that survived the sniff. Test
  in api_uploads.rs asserts the header.

- N4: The /bookmarks page used `{#if b.page}` to gate the "— page N"
  display, which falsy-elided a legitimate `page == 0`. Backend now
  rejects `page < 1` for new bookmarks (already shipped in 0.9.4),
  but any pre-0.9.4 row with page=0 still rendered without its
  number. Strengthened to `{#if b.page != null && b.page > 0}`.

Lockstep version bump to 0.10.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:55:53 +02:00
MechaCat02
69eca21fb5 feat: password change endpoint with full session rotation
Adds the pre-1.0 password-change story flagged by the audit. Browser
users and bot owners both go through PATCH /api/v1/auth/me/password
with the current + new password in the body.

Implementation in `api::auth::change_password`:
- CurrentUser-gated: 401 if unauthenticated.
- Verifies current_password against the stored argon2 hash. Wrong
  current → 401 unauthenticated, matching the login contract.
- new_password runs through the same `validate_password` used at
  registration (≥8 chars). Weak → 400 invalid_input.
- On success, wraps the swap in a single transaction:
  - UPDATE users.password_hash with a fresh argon2 hash.
  - DELETE every session for this user (signs out other devices —
    any cookie stolen before the change is dead now).
  - INSERT a new session and mint a fresh cookie so the caller stays
    logged in.
- 204 + Set-Cookie on success.

Bot tokens (api_tokens) are intentionally left alone. They're explicit
opt-in credentials that the user can already audit and revoke
individually via DELETE /auth/tokens/{id}; rotating them on every
password change would surprise CI scripts.

Repo refactor: `repo::session::create` accepts `impl PgExecutor<'_>`
(same pattern feat/uploads used for chapters), and a new
`session::delete_all_for_user` covers the "sign out everywhere"
write. The existing `delete_by_token_hash` (used by logout) is
unchanged.

Coverage in tests/api_auth.rs (4 cases):
- change_password_rotates_sessions_and_swaps_credentials — happy path
  asserts the new cookie differs from the original, that both the
  original cookie AND a second-device cookie become invalid, that the
  new cookie keeps working, that login with the old password fails
  (401) and login with the new password succeeds.
- change_password_rejects_wrong_current_with_401 — wrong current
  password returns 401 unauthenticated.
- change_password_rejects_weak_new_password — new_password "short"
  returns 400 invalid_input.
- change_password_requires_authentication — no cookie returns 401.

README updated with the new endpoint in the auth table.

Lockstep version bump to 0.10.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:52:54 +02:00
MechaCat02
785b9755cf bugfix: case-insensitive usernames, reject non-positive bookmark page
Two related correctness fixes from the audit:

- Username uniqueness was case-sensitive (`username text UNIQUE`), so
  "Alice" and "alice" could both register and then race on login.
  Migration 0006 adds a unique index on `lower(username)`; the
  existing constraint is kept (overlapping but cheap) to avoid a
  destructive migration on any deployments that may already exist.
  `repo::user::find_by_username` now matches on `lower(username) =
  lower($1)` so login is case-insensitive against the same index.
  Test: registering "alice" then "Alice" returns 409 conflict; login
  with "ALICE" succeeds against the existing user.

- `POST /api/v1/bookmarks` silently accepted `page: 0` and `page: -1`
  even though both are nonsense for a 1-indexed page number. Reject
  with 422 `validation_failed` and `details.page` populated, matching
  the pattern used for missing-metadata / empty-title elsewhere. Test
  covers both 0 and -1.

Lockstep version bump to 0.9.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:27:19 +02:00
MechaCat02
80ab119750 bugfix: wrap manga + chapter uploads in a DB transaction
Previously a storage failure mid-chapter-upload left a partial chapter
row pointing at a `page_count` that didn't match what was on disk, plus
any successfully-inserted page rows. Same shape for a manga create
where the cover put or cover_image_path UPDATE failed after the manga
row was already inserted.

Fix at the DB layer: open `pool.begin()` at the start of the create,
do all DB writes against `&mut *tx`, commit only after the full
sequence succeeds. If anything before commit fails, the transaction is
rolled back on drop and the DB stays consistent. Bytes already written
to storage on a rolled-back transaction become orphans on disk; a
future reaper can sweep them, and we prioritise DB consistency over
storage tidiness in this branch.

- repo::manga::create / set_cover_image_path: signature changed to
  `impl PgExecutor<'_>` so handlers can pass either `&PgPool` or
  `&mut *tx`. set_cover_image_path is new — replaces the inline
  `UPDATE` in the manga upload handler so the call site stays
  consistent.
- repo::chapter::create / set_page_count: same shape.
- repo::page::create: same.
- api::mangas::create and api::chapters::create both open a
  transaction around their DB writes; storage puts happen inside the
  transaction window (since they must precede the page-row insert), so
  a failed put aborts before commit.

New integration test (api_uploads::chapter_upload_rolls_back_when_
storage_fails_mid_loop) uses a `FailingStorage` helper that errors on
the N-th `put`. With N=1 (page 2 fails), the handler returns 500 and
the chapter + page tables stay empty.

`harness_with_failing_storage` is exposed alongside the existing
`harness` so future tests can reuse it for other fault-injection
cases.

Lockstep version bump to 0.9.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:25:13 +02:00
MechaCat02
563524d51e bugfix: bookmark chapter links use chapter number, not UUID
The reader route is keyed on chapter number (URL `/manga/{id}/chapter/{n}`,
loaded via `Number(params.n)`), but the bookmarks list was building
hrefs from `chapter_id` (a UUID). Following any chapter bookmark
produced a NaN load on the reader page.

Fix at the API layer so every consumer of /me/bookmarks gets the
information without a follow-up round-trip per bookmark.

- domain::BookmarkSummary: new type, `Bookmark` plus
  `chapter_number: Option<i32>`. Populated by a LEFT JOIN on chapters
  so manga-level bookmarks come back with `chapter_number = null` and
  chapter-level ones get the value. `Bookmark` itself stays minimal
  for POST / DELETE responses.
- repo::bookmark::list_for_user returns Vec<BookmarkSummary>.
- api::bookmarks::list_me returns PagedResponse<BookmarkSummary>.
- Frontend `Bookmark` type carries an optional `chapter_number`.
- /bookmarks page builds `/manga/{manga_id}/chapter/{chapter_number}`
  for chapter bookmarks, falling back to the manga overview if the
  chapter has been deleted out from under the bookmark (chapter_id is
  ON DELETE SET NULL, so this is a real edge case).

New test asserts both branches of the JOIN: a chapter-level bookmark
comes back with the right chapter_number and page, a manga-level one
has a null chapter_number.

Lockstep version bump to 0.9.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:20:45 +02:00
MechaCat02
ea60bd97de bugfix: proxy /api/* through the SvelteKit container
The compose deploy was unreachable because frontend code reads its
API base from `import.meta.env.VITE_API_BASE` at build time, but the
shipped image baked in the fallback `/api` and never picked up the
`PUBLIC_API_BASE` env var. The browser then hit
http://localhost:3000/api/...which the Node adapter doesn't serve, so
every request 404'd.

Fix the topology at the right layer: hooks.server.ts proxies /api/*
requests through to the backend container over docker's internal
network. The browser only ever talks to :3000, cookies stay
same-origin, and CORS can stay empty.

- frontend/src/hooks.server.ts: new proxy. Reads BACKEND_URL (defaults
  to http://localhost:8080 for ad-hoc node builds). Strips `host` and
  `content-length` so the backend sees the real client request and
  recomputes the length. Sets `duplex: 'half'` for streamed POST
  bodies. GET/HEAD have no body. Non-/api paths fall through to
  SvelteKit normally.
- docker-compose.yml: drop the host port mapping on the backend
  (browser doesn't reach it directly anymore — use `ports:` instead of
  `expose:` if you want curl access). Set BACKEND_URL=http://backend:8080
  on the frontend service. Drop PUBLIC_API_BASE which was unused.
- .env.example: replace PUBLIC_API_BASE with BACKEND_URL, with a note
  on what it does.
- README: explain the new topology in Quick start, update the bot
  curl examples to hit :3000 (since that's the only published port in
  the default deploy), and call out that the TLS terminator only needs
  one upstream now.

Lockstep version bump to 0.9.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:17:50 +02:00
MechaCat02
b259d1f571 feat: drag-drop upload page for manga and chapters
Frontend-only branch consuming the multipart endpoints from feat/uploads.

- /upload page with two sections:
  - "Create manga": title (required), author, description, optional
    cover. Submit posts the FormData to POST /api/v1/mangas via the
    existing createManga client.
  - "Upload chapter": manga selector (preloaded via listMangas
    sort=title, limit=200), chapter number, optional title, and a
    drag-drop zone for page images. Pages render in an ordered list
    with up/down/remove controls so the user can fix order without
    re-uploading. The same hidden file input is used by both the
    "browse" link and Playwright's setInputFiles, so the e2e test
    exercises the real submission code path even though it doesn't
    simulate the drag mechanics.

- Client-side preflight in lib/upload-validation.ts (extracted so
  Vitest can target it directly): rejects files over 20 MiB with a
  sized message and rejects MIME types outside the
  jpeg/png/webp/gif/avif whitelist. Files with an empty file.type fall
  through to the backend's magic-byte sniff, which stays the
  authoritative check. The submit button is disabled while any pending
  page has a client-side error, so an oversized file never reaches the
  network.

- API errors are surfaced via the envelope: 401 redirects to /login,
  everything else is rendered as the form's role=alert message. The
  backend's 415/413/422/409 message strings carry enough context that
  the user can act on them without us repeating the field name
  client-side (matches what we already surface for /auth errors).

- /upload requires auth: anonymous users see a "Sign in to upload"
  prompt linking to /login instead of empty forms.

Vitest coverage (10 cases):
- validateImageFile null on small images and on each of the five
  whitelisted MIMEs.
- Oversized files → sized "too large" message that names the file.
- Non-image MIME → "unsupported image type X" naming the type.
- Empty file.type → passes (deferred to backend sniff).
- formatBytes handles B / KiB / MiB.

Playwright coverage (e2e/upload.spec.ts, 4 cases):
- Anonymous user sees the sign-in prompt.
- A "page.png" whose bytes are a PDF (client validator passes because
  it trusts the declared MIME for preflight) reaches the mocked
  backend, which 415s, and the form renders the backend's message.
- Happy path: create a manga, then upload a 2-page chapter, with both
  successes asserted from the mocked 201 responses.
- A 21 MiB file is added to the pages list with a "too large" error,
  the submit button stays disabled, and zero POSTs leave the browser.

Lockstep version bump to 0.9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:54:00 +02:00
MechaCat02
1883356d7d feat: pg_trgm search, sort options, populated total count
Backend:
- Migration 0005_search.sql enables pg_trgm and adds GIN indexes
  (gin_trgm_ops) on mangas.title and on mangas.author (partial, WHERE
  author IS NOT NULL).
- repo::manga::list keeps the existing substring (ILIKE) clause and
  adds the `%` operator on title + author so the search tolerates typos
  ('narto' → 'Naruto'). Both branches share the trgm index. A second
  count(*) query (same WHERE clause, indexed) yields the total without
  scanning twice in any meaningful sense.
- New ListSort enum (Recent / Title) interpolated into ORDER BY from a
  hard-coded match — never from request input, so the format!() is not
  a SQL-injection seam. Default stays Recent (created_at DESC).
- api::mangas accepts `?sort=recent|title` (snake_case) via serde and
  returns `page.total` as a number instead of null.
- api::pagination::PagedResponse gains a `with_total` constructor.

Backend coverage in tests/api_mangas.rs (4 new cases plus the existing
list_is_empty_initially updated to assert total: 0):
- list_returns_total_count_independent_of_pagination — limit=2 with 3
  rows returns 2 items and total=3.
- search_via_trigram_tolerates_typos — `?search=narto` finds Naruto.
- list_sort_title_orders_alphabetically — three out-of-order inserts
  come back A→Z.
- search_reflects_filtered_total — search narrows total to 1.

Frontend:
- lib/api/mangas.ts gains a `MangaSort` type and threads `sort` through
  listMangas's query-string builder.
- Home page renders a "Sort" select (Recent / Title A→Z) that re-runs
  the list query, and shows "Showing N of M" when total is present.

Lockstep version bump to 0.8.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:46:16 +02:00
MechaCat02
e92c581c7b feat: bookmarks (CRUD + per-user listing + frontend toggle)
Backend:
- Migration 0004_bookmarks_unique.sql adds a partial unique index on
  (user_id, manga_id) WHERE chapter_id IS NULL. The 0001 UNIQUE
  constraint over (user_id, manga_id, chapter_id) doesn't block dupes
  when chapter_id is NULL under Postgres's default NULLS DISTINCT, so a
  user could otherwise bookmark the same manga twice at the manga
  level. Chapter-level dupes are still caught by the 0001 constraint.
- repo::bookmark with create / list_for_user / find_owner / delete.
  create catches the 23505 unique violation and surfaces it as
  AppError::Conflict so handlers return a clean 409.
- POST /api/v1/bookmarks { manga_id, chapter_id?, page? } — CurrentUser
  required. Pre-validates the manga exists (404 if not) and, when
  chapter_id is supplied, that the chapter belongs to that manga (also
  404), so FK violations can't bubble up as 500s.
- DELETE /api/v1/bookmarks/{id} — owner-only. 404 if unknown, 403 if it
  exists for another user, 204 on success. Idempotent: deleting an
  already-deleted bookmark is 404, not 500.
- GET /api/v1/me/bookmarks — paged envelope, sorted by created_at DESC,
  scoped to the current user so the URL itself can't be used to peek at
  someone else's bookmarks.

Integration coverage in tests/api_bookmarks.rs (9 cases): create+list
returns only own; duplicate manga-level bookmark → 409; unknown manga
→ 404; unauthenticated POST → 401; user A cannot delete user B's
bookmark (403); unknown delete → 404; double-delete → 404, not 500;
/me/bookmarks requires auth; paged envelope shape on empty list.

Frontend:
- lib/api/bookmarks.ts with createBookmark / deleteBookmark /
  listMyBookmarks. listMyBookmarksOrEmpty wraps the 401 case so pages
  can render anonymously without try/catch boilerplate.
- /manga/[id] overview: pre-loads the user's bookmark list in its load
  function and renders either:
  - "★ Bookmarked" / "☆ Bookmark" toggle with aria-pressed when authed;
    click POSTs or DELETEs and mutates a local working copy of the
    bookmark list (optimistic UI without re-fetching);
  - or a "Sign in to bookmark" link for anonymous users.
- /bookmarks page lists the current user's bookmarks (chapter-level
  bookmarks link into the reader, manga-level back to the overview).
  Anonymous users see a sign-in prompt instead of a 401 page.

E2E in e2e/bookmarks.spec.ts (3 cases): authed toggle round-trip
(bookmark, see in /bookmarks list, unbookmark); anonymous user gets the
sign-in CTA on the overview; anonymous /bookmarks shows the sign-in
prompt. Existing reader.spec.ts updated for the new
bookmark-signin/toggle test IDs.

Lockstep version bump to 0.7.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:40:27 +02:00
MechaCat02
9af070608b feat: streaming files endpoint + reader pages + chapter pages metadata
Backend:
- Migration 0003_pages.sql adds a `pages` table (id, chapter_id,
  page_number, storage_key, content_type) with a unique (chapter_id,
  page_number). New table because chapter pages can have different MIME
  types per page; reconstructing keys from a single template would
  break the moment a chapter mixes png and jpg pages.
- `domain::Page` + `repo::page` (create + list_for_chapter).
- The chapter upload handler now inserts one page row per part as it
  writes the bytes to storage.
- GET /api/v1/mangas/{id}/chapters/{n}/pages returns `{pages: [...]}`
  with the storage_key clients need to construct image URLs. 404 if
  the manga or chapter doesn't exist; reads are public.

Storage trait grows `get_stream(&str) -> StreamingFile` returning a
`Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>` + size. The
local backend implements via `tokio::fs::File` + `tokio_util::io::
ReaderStream` with a 64 KiB chunk size. GET /api/v1/files/*key now
streams via `axum::body::Body::from_stream` instead of buffering — the
test asserts a 200 KiB file emits >1 frame end-to-end through the
router.

Frontend:
- lib/api/client.ts gains `fileUrl(key)` so components don't
  reconstruct the `/api/v1/files/...` path manually.
- lib/api/chapters.ts gains `ChapterPage` type + `getChapterPages` (the
  type is named ChapterPage to avoid colliding with `Page` from
  client.ts, which is the pagination envelope).
- /manga/[id]/+page.svelte: overview with cover, title, author,
  description, chapter list, and a disabled bookmark control (real
  bookmarking lands in feat/bookmarks). Responsive at 640 px.
- /manga/[id]/chapter/[n]/+page.svelte: paginated reader. Current page
  loads eagerly; next page is preloaded in a hidden img so navigation
  feels instant. Keyboard handler maps ArrowRight/j/Space → next,
  ArrowLeft/k → prev, Home/End → first/last; skips when the user is
  typing in an input. Focus ring on the prev/next buttons.
- SSR is disabled on both routes via `export const ssr = false` so the
  client-only fetch flow doesn't need to be replicated server-side; the
  routes are interactive features, not SEO surfaces.
- E2E (e2e/reader.spec.ts): overview shows the title/cover/chapter
  list; reader pages through three pages via ArrowRight, j, k, and
  ArrowLeft, and the preload img holds the page-2 src on initial load.

Lockstep version bump to 0.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:32:08 +02:00
MechaCat02
a92f6f70e2 feat: multipart manga + chapter uploads with magic-byte MIME sniff
POST /api/v1/mangas and POST /api/v1/mangas/{id}/chapters now accept
multipart/form-data, gated by CurrentUser:

- /mangas: required `metadata` part (NewManga JSON) + optional `cover`
  image part.
- /mangas/{id}/chapters: required `metadata` (NewChapter JSON) + one or
  more `page` parts ordered by arrival. Returns 404 if the parent manga
  doesn't exist, 409 on duplicate (manga_id, number).

MIME is sniffed via the `infer` crate (magic bytes), not the
client-supplied filename or Content-Type. Whitelist:
jpeg / png / webp / gif / avif. Anything else → 415
unsupported_media_type. The stored key's extension is derived from the
sniffed type so a "page1.png" that's actually a JPEG lands as `.jpg`.

Size cap is two-layer:
- Request body cap (config.max_request_bytes, default 200 MiB) enforced
  by axum's DefaultBodyLimit before the handler sees the request.
- Per-image-part cap (config.max_file_bytes, default 20 MiB) enforced
  after reading the part, so a single oversized image can't pass even
  if the total request fits.

Storage keys follow the layout documented in CLAUDE.md:
- mangas/{manga_id}/cover.{ext}
- mangas/{manga_id}/chapters/{chapter_id}/pages/{nnnn}.{ext} (1-indexed).

AppError grows PayloadTooLarge/UnsupportedMediaType/ValidationFailed
(413 / 415 / 422). ValidationFailed carries a `details` JSON object the
client can use to highlight bad fields (e.g. {"title":"required"}).
Top-level matching in code() stays exhaustive.

Backend coverage in tests/api_uploads.rs (10 cases):
- create_manga_with_cover_stores_image — file is reachable via
  /api/v1/files/{key} with the right Content-Type.
- create_manga_without_cover_leaves_path_null.
- create_manga_rejects_non_image_cover_with_415 — PDF claimed as png.
- create_manga_rejects_oversized_cover_with_413.
- create_chapter_with_pages_stores_each — extension derived from
  sniffed MIME, files reachable in arrival order.
- create_chapter_rejects_when_no_pages_with_422 — details.page set.
- create_chapter_rejects_renamed_non_image_page → 415.
- create_chapter_returns_409_on_duplicate_number.
- create_chapter_requires_authentication → 401.
- create_chapter_under_unknown_manga_is_404.

Existing tests/api_mangas.rs is migrated to multipart; the create
response is now 201 Created. tests/common::MultipartBuilder builds the
body by hand so the test crate stays free of HTTP-client deps.

Frontend lib/api/mangas.ts: createManga now sends FormData (metadata +
optional cover Blob). Browser fills in the boundary header automatically.
Vitest asserts the FormData structure via FileReader (jsdom doesn't
implement Blob.text()).

E2E tests wait for the post-hydration nav-login link before
interacting with the login form, fixing a flake where pre-hydration
clicks would submit via the browser default and bypass our handler.

Lockstep version bump to 0.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:21:10 +02:00
MechaCat02
2f9912533f feat: list + get chapters under /api/v1/mangas/{id}/chapters
Both endpoints are public reads — anyone browsing can see a manga's
table of contents and chapter metadata. Uploads land in feat/uploads.

- GET /api/v1/mangas/{id}/chapters returns the paged envelope
  ({items, page}) ordered by chapter number ASC. Surfaces 404 if the
  parent manga doesn't exist so an empty result can't be mistaken for
  "no chapters yet" on a real manga.
- GET /api/v1/mangas/{id}/chapters/{number} returns a single chapter,
  404 if either manga or chapter is missing.

repo::chapter exposes list_for_manga, find_by_manga_and_number, and
create. create translates the (manga_id, number) unique violation into
AppError::Conflict so the upload handler can later return a clean 409.

Frontend lib/api/chapters.ts mirrors the shape with listChapters and
getChapter; Vitest asserts the URL shape, paged response handling, and
404 envelope propagation.

Lockstep version bump to 0.4.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:08:20 +02:00
MechaCat02
383cfbed3b feat: argon2id passwords, session cookies, bot bearer tokens
Adds the full auth flow. Reads stay public; writes (currently only POST
/api/v1/mangas) require a CurrentUser. Both browsers and bot scripts hit
the same endpoints — they just present credentials differently.

Migration 0002_auth.sql introduces users.password_hash, a sessions
table, and an api_tokens table. Sessions and api_tokens store only
sha256(raw_token) — the raw value lives in the cookie or the
Authorization header.

New endpoints under /api/v1/auth/:
- POST /register — argon2id hash, creates a session, sets cookie.
- POST /login — verifies, rotates to a fresh session (old ones expire
  naturally so other devices stay signed in).
- POST /logout — deletes the server-side session row + clears the
  cookie via Max-Age=0.
- GET  /me — current user via the new CurrentUser extractor.
- POST /tokens — issue a bot bearer token; raw value returned exactly
  once at creation.
- DELETE /tokens/{id} — owner-only: 404 if unknown, 403 if it exists
  but belongs to another user, 204 on success.

The CurrentUser axum extractor resolves cookie first, then
Authorization: Bearer; failure → AppError::Unauthenticated (401). New
AppError variants Unauthenticated/Forbidden/Conflict carry the matching
envelope codes; the top-level match in `code()` stays exhaustive.

Backend integration coverage in tests/api_auth.rs: register sets a
HttpOnly SameSite=Lax cookie and never leaks password_hash; duplicate
username → 409; weak password → 400; login rotates the cookie; wrong
password / unknown user → 401; /me with vs without cookie; logout
invalidates the cookie; bot-token roundtrip via Bearer; user A cannot
delete user B's token (403); unknown delete → 404.

Frontend:
- lib/api/auth.ts — typed wrappers; me() returns null on 401.
- lib/session.svelte.ts — per-tab user state with a seq counter to
  guard against an in-flight /me clobbering a fresh setUser.
- lib/api/client.ts — request<T> returns undefined for 204.
- routes/login + routes/register — forms with action="javascript:void(0)"
  so the no-JS path is a no-op (avoids the hydration-race where a
  pre-attach click would submit via the browser default).
- routes/+layout.svelte — session-aware nav: spinner → user + Logout,
  or Login / Register.
- e2e/auth-flow.spec.ts — login flips the layout, logout flips back;
  bad credentials surface the API error message.

Config grows AuthConfig (cookie_secure, cookie_domain, session_ttl_days)
and CORS_ALLOWED_ORIGINS. CORS middleware is mounted in app::build and
stays a no-op (same-origin) until origins are listed.

Lockstep version bump to 0.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:04:25 +02:00
MechaCat02
ce9a01793f feat: nest API under /api/v1, structured error envelope, paged lists
Move every handler from /api/* to /api/v1/*. /api/* is now reserved for
future versioning.

Standardise the error response shape across the API as
{"error": {"code": "snake_case", "message": "..."}}. AppError gains a
`code()` whose top-level variants are matched exhaustively without a
wildcard — new variants are a compile error until coded. 500-class
responses always emit the fixed "internal error" string and log the
real cause via tracing only.

Lock in the list pagination envelope as {"items": [...], "page": {
"limit", "offset", "total"}} and apply it to GET /api/v1/mangas. `total`
serialises as null until feat/list-search-polish lands an indexed count.

The frontend client parses the envelope into ApiError.code with an
http_error fallback for non-JSON bodies. listMangas now returns the
paged shape; the root route consumes .items. New client.test.ts covers
envelope parsing and the fallback paths.

Lockstep version bump to 0.2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:41:20 +02:00
MechaCat02
6c1d04aaf4 chore: initial project scaffold
Set up Mangalord with a Rust/axum backend, SvelteKit frontend, Postgres,
and Docker Compose deployment. Establishes the architecture and TDD
patterns the project will extend:

- Hexagonal-ish backend layering (domain / repo / storage / api) with
  a pluggable Storage trait (LocalStorage today, S3 as a future impl).
- Initial migration: users, mangas, chapters, bookmarks.
- Vertical slice for mangas (list, search, create, get) with
  #[sqlx::test] integration coverage and storage unit tests.
- SvelteKit frontend using Svelte 5 runes, typed API client, Vitest
  unit tests and Playwright e2e with route mocking.
- CLAUDE.md documenting layering, TDD/git/SemVer workflow rules, and
  extension points (tags, fulltext search, OCR, S3, auth).
- Project-scoped .claude/settings.json with permission allowlist for
  the toolchain (git, cargo, npm/vite, docker, psql, gh, doc fetches).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:05:16 +02:00