The Add-tags and Add-to-collection editors opened as centered Modals on
every form factor, so a mobile user went action-Sheet → centered Modal — an
inconsistent hop, and AddTagsSheet's docstring promised a sheet it never
got. Introduce AdaptiveDialog, which renders a bottom Sheet under 640px and
a centered Modal above it behind one shared contract (open / title / onClose
/ children / testid). Route both editors through it:
- AddToCollectionModal now wraps AdaptiveDialog (both its call sites — manga
detail and the reader — adapt automatically).
- The reader hosts AddTagsSheet in an AdaptiveDialog instead of a Modal, so
its "slots into a Sheet (mobile) or Modal (desktop)" contract is now true.
Modal/Sheet already share an API, so the switch is transparent: the dialog
keeps the same role, title, testid, and close affordance. Desktop is
unchanged.
AdaptiveDialog has its own unit test (Sheet on mobile, Modal on desktop);
an e2e proves the mobile long-press → Add-tag flow now opens a Sheet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The brightness slider lived only in the mobile reader settings sheet, so
desktop users couldn't dim — and could be left stuck dimmed by a value a
phone had persisted (brightness is shared via localStorage and the
--reader-dim effect is form-factor agnostic). Add an inline brightness
slider to the desktop reader nav, marked `desktop-control` alongside the
existing mode toggle and gap select. It binds the same `brightness` state,
so a value set on either form factor stays in sync.
Also mocks `/me/read-progress` in the reader-mode e2e setup — without it the
GET/PUT fell through to the (absent) backend and stalled the reader load,
which is why those specs couldn't run headless. The spec is now hermetic and
covers the new desktop control (it dims/undims, and a phone-persisted value
is recoverable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Five files hand-rolled a near-identical 32px `.icon-btn` (same size, hover,
and primary/danger variants). Extract a single IconButton.svelte component
so the treatment lives in one place. Converts the four sites with the
standard 32px form: collections detail, manga edit, upload, and the
chapter-pages editor.
The component takes a `variant` (plain/primary/danger) and spreads any
button attributes (onclick, disabled, aria-label, title, data-testid)
straight through; `type="button"` defaults but a caller can override. The
rendered button keeps the same class, styles, and DOM position, so layout
and behaviour are unchanged — no version bump.
Three icon-button sites are intentionally left out:
- The header (+layout) and home search button are 36px / different radius —
size outliers that need a size/radius prop before folding in.
- profile/history's copy is being removed in the shared-HistoryList change;
touching it here would just conflict.
A component (not a global class) avoids colliding with those remaining
local `.icon-btn` definitions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`.resync-msg` (the admin Force-resync result) was display:none under 640px,
so mobile admins — who trigger resync from the overflow sheet — got no
success or error feedback. The element is already a sibling of `.action-row`
(not a child), so it survives the row being hidden; the only thing hiding it
was an explicit mobile rule. Drop that rule.
e2e: a mobile admin triggers Force resync from the overflow sheet and now
sees the "Metadata updated" result (written test-first against the hidden
element, then unhidden).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persist blob sizes (covers + chapter pages) and surface upload storage
usage to admins in two places:
- Admin System tab: total/covers/chapters totals, ratio of disk, average
image sizes, and top-5 largest mangas/chapters leaderboards, behind a
new GET /api/v1/admin/storage endpoint (separate from /admin/system so
the cheap DB aggregates aren't coupled to its 250ms CPU sample).
- Manga detail page: total chapter-content size and per-chapter size,
with an em-dash for uncrawled or not-yet-measured chapters.
Sizes are captured at write time (crawler put_stream return, uploaded
page/cover byte length) into nullable pages.size_bytes /
mangas.cover_size_bytes columns; NULL means "not yet measured", distinct
from a real 0. A one-shot, idempotent admin "Backfill sizes" action
(POST /api/v1/admin/storage/backfill) stats pre-existing blobs in
keyset-paginated, capped batches and reports more_remaining so a large
legacy library can be drained over multiple runs.
Aggregates and leaderboards treat NULL honestly (only fully-measured
entities are ranked); a dashboard banner flags unmeasured rows so partial
figures aren't read as complete. persist_pages now prunes stale page rows
on a shrinking re-crawl so totals and page_count stay consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surfaces the scoped re-enqueue and per-page force-analyze in the UI:
- api/admin: reenqueueAnalysis({onlyUnanalyzed, mangaId, chapterId}) and
analyzePage(pageId).
- Reader PageContextMenu gains an admin-only "Queue for analysis" item
(canAnalyze/onAnalyzePage/analyzeState props) wired to the force-analyze
endpoint with inline busy/done/error feedback; reset per page.
- New /admin/analysis dashboard section + nav tab: scope selector
(whole library / one manga / one chapter), id input, and an
"include already-analyzed" toggle (default off), with busy/notice/error.
Tests: vitest for the two clients; Playwright for the context-menu action
(admin vs non-admin) and the admin section (scope + include toggle +
validation). svelte-check + build clean; 262 vitest, 10 new e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the analysis pipeline in the UI:
- api clients: searchPages() + PageSearchItem/ContentWarning types;
listMangas cwInclude/cwExclude; MangaDetail.content_warnings.
- /search gains a content-search mode: free-text over OCR+scene and
tri-state content-warning toggles (include/exclude), driven by URL
params (text, cw_include, cw_exclude) and the new /me/page-search
endpoint. Tag browsing is preserved when no content filter is active.
- manga detail renders a deduped content-warning banner.
Tests: vitest param serialization (searchPages CSV/text/cw, listMangas
cw); Playwright text-search + cw-toggle flows (route-mocked). svelte-check
+ build clean; full vitest (258) + search e2e (7) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GET /v1/mangas/:id/similar returning the top 5 mangas ranked by
Jaccard tag overlap (shared / union), excluding self, as MangaCard items
in a plain { items } object. Surface them in a hidden-when-empty
"Similar" section on the manga detail page, reusing MangaCard.
Backend: new repo::manga::list_similar with a manga_tags self-join; a
shared cards_from_rows helper (also used by list_cards) that loads
authors+genres concurrently; single-sourced column list via MANGA_COLS +
manga_cols(alias) to replace the fragile SELECT_COLS string-splitting.
Frontend: getSimilarMangas client fn (guards a malformed body), loader
fetch with non-critical .catch fallback, and the catalog grid hoisted to
a shared global .manga-grid (lib/styles/tokens.css), de-duplicating the
per-route copies.
Bump version 0.62.0 -> 0.63.0 (backend + frontend in lockstep).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the /search surface (Pages / Chapters / Mangas tabs) backed by
per-user page tags and per-page collections: schema (migration 0023),
backend endpoints for page tags/collections and tagged-page aggregations
(with the OCR text-search param reserved at 501), plus the frontend API
clients, library Page-tags tab, collection page sections, page context
menu / AddTagsSheet, and reader long-press wiring. Includes the
continuous-reader navigation fixes (?page=N handling, chapter-reset
timing, back-button pops history) and tag-normalization hardening
accumulated on the branch.
Bump version 0.60.2 -> 0.62.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reader's "back to manga" link was a naked `<a href="/manga/{id}">`
that pushed a new history entry on every tap, so browser-back kept
ping-ponging between detail and reader instead of walking out to
home:
home → detail → reader → reader-back (push detail) → detail-back
(pop reader) → reader-back (push detail) → … loop forever
The fix intercepts left-click and calls `window.history.back()`
directly when there's same-tab history to pop. Middle-click /
cmd-click / right-click pass through so "open in new tab" still
works, and a deep-link / fresh-tab visit where `history.length ===
1` falls through to the href so the button still leads somewhere
useful.
An earlier attempt gated the back on
`document.referrer.startsWith(origin)` — but SvelteKit's SPA
`pushState` doesn't update `document.referrer`, so that check was
always false in practice and the default href fired anyway. The
e2e regression added here uses real link clicks (not
`window.location.href`) so it actually exercises the SPA-nav path
the bug lives on.
Verified: home → detail → reader, then reader-back keeps
`history.length` at 4 (popped, not pushed), and a second tap on the
detail back button walks out to /.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual cleanup pass after wiring up real data — the placeholder
content used during the five mobile phases hid a few sharp edges
that became obvious as soon as user-supplied tags / author handles
/ descriptions hit the screen.
- Mobile gutter unified at 16px (was 12px) — matches iOS / Material
standard and gives chips, description text, and chapter rows
visible breathing room from the screen edge. Hero negative margin
in /manga/[id] tracks the change so the bleed still hits viewport.
- Catalog grid switched from 2 columns to 4 with
`repeat(4, minmax(0, 1fr))` (per user preference). The `minmax(0,
...)` is load-bearing — without it the implicit `minmax(auto, 1fr)`
lets a long single-word card title push the column past the
viewport edge. MangaCard's `.author` and `.genres` lines hide
below 640px so every card in the 4-col layout has identical height
(cover + 2-line title clamp).
- Same minmax(0, 1fr) trick applied to the detail page's
`.overview` grid track, plus defensive `min-width: 0` /
`max-width: 100%` on `.meta` and `.chip-row` so a long
unbreakable tag or romanized URL can't drag the metadata column
past the article gutter.
- BottomNav swapped from `grid-auto-columns` (= `minmax(auto, 1fr)`)
to an explicit `repeat(4, minmax(0, 1fr))`, plus `min-width: 0` on
`.tab` and an ellipsizing `.label` so a long tab label stays
inside the bar. The Browse placeholder is replaced with Upload —
a real action, no longer a no-op duplicate of Home.
- Hero appbar gets 16px all-around inset (was 8px) so the back / ⋯
circle buttons aren't kissing the screen edge or the hero top.
Hero content gets 20px horizontal padding so the cover thumb +
title sit visibly off the hero scrim. Description gains
`overflow-wrap: anywhere` for long URLs / romanized titles.
- Chip now allows `overflow-wrap: anywhere` on its label and uses
`max-width: 100%` + `min-width: 0` so a long token wraps to its
own line within the chip-row and ellipsizes inside the chip.
- Mobile catalog search-row gains `flex-basis: calc(100% - 36px -
--space-2)` so the search input takes its own line — the Filter
and Sort chips wrap to a second row instead of squeezing the
input down to a few characters.
- Global safety net: `html, body { overflow-x: hidden }` below
640px so any future bug that lets an element overflow the
viewport doesn't expose touch-pan that shifts the whole layout.
Fixed descendants (bottom nav, CTA bar) are unaffected.
- audit.mjs — small dev helper that walks all 12 user-facing
routes at 390px and reports horizontal overflow, internal
scrollers, and edge-bleeding controls. Used to drive these fixes
and useful for future mobile changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the mobile redesign: the reader gets a mobile-native
interaction model — invisible tap zones for prev / next / toggle, a
chapter-jump bottom sheet, a reader settings sheet (mode / gap /
brightness), a fixed bottom page scrubber, a brightness overlay
driven by a CSS variable, and an idle-timer auto-hide for the chrome
after 3s of inactivity. Desktop keyboard shortcuts and chevrons are
preserved above 640px.
- New TapZone primitive: invisible 3-column grid that splits the
viewport into prev / toggle / next thirds (Mihon convention). Wired
to the existing `prev()` / `next()` / new `toggleChrome()` so
chapter-edge behavior, page preload, and read-progress tracking
all keep working. Has its own vitest coverage.
- matchMedia gates every mobile addition so the same DOM never
carries two copies of the chapter selector or two sets of nav
controls — the desktop <select>, mode toggle, and gap field stay
inline above 640px; below 640px they swap to a chapter-jump
button and a settings ⋯ button hosting the Sheets.
- Chapter jump Sheet lists every chapter with the current one
highlighted; tapping a row navigates and dismisses.
- Reader settings Sheet uses the SegmentedControl primitive for mode
and (continuous-only) page gap. Brightness lives next to them as a
range slider 0.3..1; its value publishes `--reader-dim` as a CSS
variable that drives the always-rendered fixed dim overlay
(pointer-events: none so taps fall through). Brightness is
client-side only in localStorage — the Preferences table doesn't
carry it and Phase 4 deferred a backend migration.
- Idle auto-hide: 3s timer scoped to mobile + single mode, reuses
the existing focus-mode CSS for the actual slide-off. The timer
resets on every index/mode/viewport/fullscreen change via a tracked
$effect.
- Bottom page scrubber: a styled <input type="range"> at the
viewport foot, single + multi-page only, sliding off in focus mode
alongside the chrome. Honors env(safe-area-inset-bottom).
- Reader joins the layout's `data-mobile-full-bleed` attribute so the
hero/top reader-nav can sit at viewport top under main's cleared
padding-top on mobile.
- Playwright spec covers tap left/right/center, the 3s auto-hide, the
chapter-jump sheet, the settings sheet swapping to continuous, the
brightness slider driving `--reader-dim`, and a desktop regression
for the unchanged inline controls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of the mobile redesign: the manga detail page gets a mobile-
native chrome — blurred-cover hero with a transparent app bar overlay,
secondary actions in an overflow Sheet, a 3-line description clamp
with Read more, and a sticky bottom CTA whose wording reflects read
progress. Desktop layout is untouched.
- /manga/[id] joins the layout's HIDE_MOBILE_CHROME route set so the
global AppBar and BottomNav step aside for the page-specific hero
chrome. A new `html[data-mobile-full-bleed]` opt-in zeroes main's
mobile top padding without touching horizontal/bottom gutters.
- The hero block (mobile-only via CSS) renders a blurred cover
backdrop, a transparent app bar (back / bookmark / overflow ⋯), and
a sharp cover thumbnail beside the title, authors, and status. The
bookmark icon swaps between Bookmark and BookmarkCheck based on the
current bookmark state — reusing the existing toggleBookmark flow.
- Secondary actions (Add to collection / Edit / Upload chapter /
Force resync) move into an overflow Sheet opened from the ⋯
button. The desktop action-row stays.
- Description gets a `.clamped` modifier (-webkit-line-clamp: 3) and
a Read more toggle on mobile, gated by a >200-char heuristic so
short blurbs don't render a dangling button. Desktop shows the
full description as before.
- Sticky bottom CTA bar reads "Continue {chapterLabel}" when
data.readProgress has a chapter_id, "Read first chapter" when
the manga has chapters but no progress, and hides otherwise. The
bar honors env(safe-area-inset-bottom) so it sits above the iOS
home indicator.
- New Playwright spec covers the CTA wording state machine
(no-progress → Read first chapter, has-progress → Continue),
Read more expand/collapse, overflow sheet contents, and a desktop
regression for the unchanged action-row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The user-facing chapter list ordered by (number ASC, created_at ASC),
which broke the source site's order in two ways: non-numeric entries
("notice. : Officials") parsed to number=0 and clustered at the top,
even though the site placed them mid-list, and variants sharing a
number ("Ch.14 : PH" / "Ch.14 : Official") were torn apart by the
created_at tiebreak.
Capture each chapter's position in the source DOM as `source_index`
(0 = first = newest on this site) on every crawler sync, including the
UPDATE branch so a new chapter prepended on the source shifts every
existing row down by one on the next tick. The list query reverses
this with `ORDER BY source_index DESC NULLS LAST, number ASC,
created_at ASC` so the oldest chapter appears first, variants stay
adjacent in the order the site shows them, and non-numeric entries
land where the site placed them. User-uploaded chapters and pre-
migration rows keep their NULL source_index and fall through to the
prior number/created_at tiebreak via NULLS LAST.
The reader's client-side `[...chapters].sort((a,b) => a.number - b.number)`
is dropped; prev/next now walks the server-ordered array positionally
so it traverses variants and non-numeric entries in display order.
Existing data populates on the next cron tick or via admin force-resync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chapter list on the manga detail page, the reader's chapter-select
dropdown, the continuous-mode chapter bar, the browser tab title, and
the profile upload-history entries all prepended "Chapter {number}:"
in front of the crawled site title. Source titles already include
"Ch.N" themselves and the manga page renders chapters inside an <ol>,
so the prefix duplicated information the user could already see.
A small chapterLabel(c) helper in $lib/api/chapters returns the site
title as-is, falling back to "Chapter {number}" only when the
crawler captured an empty title (link/option stays non-empty). The
five render sites now call it. The previous-/next-chapter nav
buttons still read "Previous chapter (Ch. N)" / "Next chapter (Ch. N)"
since those are wayfinding labels, not title display.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a chapter `<select>` to the reader's top nav listing every chapter
of the current manga, defaulting to the open chapter; picking another
entry navigates straight to it without going back to the manga detail
page. Options use the "Ch. N — Title" form to match the existing
chapter tile and prev/next buttons in the reader bar.
Covered by a new Playwright spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a per-tick cover-backfill pass to the crawler daemon so mangas whose
cover download failed on first attempt get retried — the metadata pass's
early-stop optimisation otherwise prevents the walk from revisiting them.
Adds admin-only POST /admin/mangas/:id/resync and POST /admin/chapters/:id/resync
that refetch metadata + cover (or chapter content with force_refetch) from the
crawler source synchronously and return the refreshed row. Surfaced in the
UI as "Force resync" buttons on the manga detail and reader pages,
admin-only via session.user.is_admin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle of small UI/UX fixes plus a build hygiene tweak.
* List pagination — Home (`/`) and `/authors/[id]` silently capped at
the backend default of 50 with no UI to advance. New reusable
`Pager.svelte` (Prev/Next + numbered with ellipsis), URL-synced
`?page=N`, and filter/search/sort reset to page 1 so users aren't
stranded on an out-of-range page. Count label now shows a range
("Showing 51–100 of 237").
* Stale page title — Pages without a `<svelte:head><title>` left the
document title at whatever the last manga / author / collection page
set it to. Move static-route titles into a route-id → title map in
the root layout and invert every dynamic title to brand-first
(`Mangalord | {X}`) for consistency.
* Admin filter bar — `/admin/mangas` search input had `flex: 1` and
ballooned across the row, shoving the sync-state select + Search
button to the far right. Cap at 24rem, vertical-align the row, and
promote the previously aria-only "Sync state" label to visible text.
* Build hygiene — `backend/target` had grown to 68 GiB. Cleaned and
added `[profile.dev] debug = "line-tables-only"` (and `[profile.test]`
too) to cut future dev builds by ~50–70% while keeping line numbers
in backtraces.
Also: configure vitest to resolve Svelte's browser entry so
`@testing-library/svelte` can mount components in jsdom — needed for
the new `Pager.svelte.test.ts`.
Bump 0.48.0 -> 0.49.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five fixes bundled into one release:
- preserve user-attached tags across crawler upserts
(repo::crawler::sync_tags now scopes to added_by IS NULL; orphaned
attachments from deleted users are reaped as crawler-owned)
- gate manga PATCH and cover endpoints on uploaded_by (require_can_edit
in api::mangas; non-NULL uploaded_by must match the caller)
- equalise login response time across user-existence branches
(run argon2 against a OnceLock-cached dummy hash on the no-user
branch so timing doesn't leak username existence)
- crawler download defences (SSRF allowlist of host literals
including IPv4-mapped IPv6 ranges, 32 MiB streamed size cap,
reject non-whitelisted image types, three-way chapter-probe
classifier replaces the binary #avatar_menu check)
- tighten validation and clean up dead unload path
(attach_tag + create_token enforce 64-char caps; LocalStorage
rejects NUL bytes explicitly; reader flushFinalProgress drops
the always-405 sendBeacon path)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PUT /mangas/:id/cover (multipart) and DELETE /mangas/:id/cover so
covers can be replaced or cleared after creation, and wires a dedicated
/manga/[id]/edit SvelteKit route that combines the existing PATCH with
the new cover endpoints. Cover PUT cleans up the old blob when the
extension changes, swallowing StorageError::NotFound so a manually-gone
file doesn't surface as a 404 to the client. Edit link on the manga
detail page is gated on session.user, matching the auth posture of the
underlying handlers.
Also pins the local-dev port story via loadEnv() in vite.config.ts so
VITE_PORT / BACKEND_URL from a (gitignored) .env keep the dev URL
stable across runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real-world sources publish multiple chapters at the same number:
different scanlators ("Ch.52 from bloomingdale" + "Ch.52 from mina"),
translator notices and farewells, alt-translations. The (manga_id,
number) UNIQUE constraint from 0001 silently collapsed all of those
into a single row via the upsert path in repo::crawler. Migration 0013
drops the constraint; sync_manga_chapters now plain-INSERTs each
SourceChapterRef so every parsed chapter survives as its own row.
Identity moves from the (manga_id, number) tuple to the chapter UUID:
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id` (replaces :number)
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id/pages`
- `repo::chapter::find_by_id_in_manga` (replaces find_by_manga_and_number)
- Frontend reader route renamed to `/manga/[id]/chapter/[chapter_id]`
- Chapter links throughout (manga page list, continue-reading CTA,
reader prev/next, history rows, bookmark cards) use chapter.id
- API clients getChapter/getChapterPages take a chapter id string
read_progress + bookmarks already FK chapter_id; they only enrich with
chapter_number for display, which is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
$(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>
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>
- `/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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>