Commit Graph

100 Commits

Author SHA1 Message Date
MechaCat02
f5692ea109 feat(ui): show a tooltip with the full text on truncated titles
Add an `overflowTooltip` Svelte action that sets a native `title` only when
an element is actually clipped (ellipsis or line-clamp), re-checking on
resize. Native title is keyboard/touch reachable and zero-dep, unlike a
hover-only popover. Applied to the clipped titles on MangaCard, BookmarkList,
HistoryList, and the Continue-reading shelf.

Unit tests cover the overflow decision and the set/clear/update behaviour;
an e2e verifies a real-browser truncated card title gets the tooltip while a
short one does not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:08:28 +02:00
MechaCat02
ad1689b818 feat(detail): link genre and tag chips to the filtered catalogue
Genre chips now deep-link to /?genres=<id> and user-tag chips to
/?tags=<id>, reusing the homepage catalogue's existing filter params —
turning the detail page's metadata into one-tap discovery. Tag chips keep
their remove affordance (the Chip's href branch preventDefaults the remove
button). e2e asserts both chip hrefs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:04:38 +02:00
MechaCat02
01d18e7ba2 feat(detail): mark read chapters and count new ones on the manga page
On the manga detail page, dim chapters at or before the reader's last-read
chapter (with an accent check + screen-reader "Read." label) and show a
"N new since last read" badge counting chapters that have landed since.
All client-side from the already-loaded chapter list and per-manga read
progress — no extra requests. The count reflects the loaded (paginated)
chapter list. Logic lives in a pure, unit-tested chapterProgress helper;
e2e covers the mid-series and never-opened cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:01:58 +02:00
MechaCat02
2267a83f6c feat(home): add a Continue-reading shelf with new-chapter badges
Surface a horizontal "Continue reading" shelf at the top of the homepage,
fed by the user's read-progress history. Each card jumps back to the exact
page they left off (deep-linking `?page=N` past the first) and flags how
many chapters have landed since — the personal new_chapters_count from the
read-progress list. The shelf is fetched after the catalogue load so the
public browse path stays unauthenticated; guests get an empty list (401
swallowed) and the shelf stays hidden.

Add new_chapters_count to the frontend ReadProgressSummary type to match
the backend. Component unit tests cover href/deep-link, badge visibility,
and the no-chapter fallback; e2e covers signed-in (shelf + badge) and
anonymous (hidden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:57:17 +02:00
MechaCat02
cf8971faae fix(reader): render pages at a consistent width instead of thin stripes
Reader page sizing was height-driven — `max-height: 90vh` in single mode
and unbounded natural width in continuous mode. A long vertical page hit
the height cap first and, preserving aspect ratio, collapsed into a thin
vertical stripe; narrow and wide scans rendered at different widths.

Switch both modes to width-driven sizing via a capped `--reader-page-width`
reading column (min(100%, 700px)): every page renders at the same width
regardless of intrinsic dimensions, and tall pages extend downward and
scroll instead of shrinking. Top-align the single-mode grid and make the
prev/next chevrons sticky so they stay reachable on a long page.

Add a Playwright spec asserting equal rendered width across differently-
proportioned pages and that a tall page exceeds the viewport height. The
page-context-menu fixture served a 1x1 image that now renders as a 700x700
square taller than the viewport, making Playwright scroll it into view
before right-clicking — a synthetic scroll that trips the menu's by-design
close-on-scroll. Give that fixture a realistic landscape aspect so the page
fits the viewport, matching real user behaviour (no scroll on right-click).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:45:59 +02:00
MechaCat02
8acc0e6cc2 feat(frontend): use the Mangalord monogram as the browser tab icon
Some checks failed
deploy / test-frontend (push) Has been cancelled
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-backend (push) Has been cancelled
The favicon link pointed at a non-existent favicon.ico. Ship the
monogram SVGs from static/ and wire two theme-aware <link rel="icon">
tags so the tab icon follows the browser color scheme: the dark-M
"light" monogram on light chrome, the white-M "dark" monogram on dark.

Bump 0.93.10 -> 0.94.0 (feat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:34:45 +02:00
MechaCat02
141cd52f7e fix(frontend): percent-encode fileUrl key segments
fileUrl interpolated the raw storage key into the path, so a key segment
containing a reserved character (`?`, `#`, `%`, space) could be
reinterpreted as a query/fragment delimiter. Encode each `/`-separated
segment with encodeURIComponent, keeping slashes as literal path
separators. Keys are backend-generated today, so this is defence-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:18:34 +02:00
MechaCat02
0865659ab3 fix(auth): keep session on a wrong-current-password 401
The change-password endpoint returns 401 for a wrong *current* password,
the same status the global on401 hook uses to detect an expired session.
The hook fired first and cleared session.user, so the account form's
"still signed in?" guard always saw null and bounced the user to /login
instead of surfacing the error inline.

Add a per-call `suppressOn401` option to `request`; changePassword opts
in, so a 401 there stays inline and the session is left intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:11:07 +02:00
MechaCat02
cbbc626768 docs: correct ?text= OCR-search drift (no longer 501)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:19:38 +02:00
MechaCat02
3ba30f4ba9 fix(login): close ?next= control-char open-redirect + cover register
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:18:01 +02:00
MechaCat02
4306d1c96a fix(frontend): strip hop-by-hop headers from proxied responses too
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:17:32 +02:00
MechaCat02
83c2899373 feat(analysis): add ocrs OCR backend and OCR-driven page text search
All checks were successful
deploy / test-backend (push) Successful in 28m40s
deploy / test-frontend (push) Successful in 10m36s
deploy / build-and-push (push) Successful in 11m8s
deploy / deploy (push) Successful in 12s
Adds an in-process ocrs OCR backend as the active analysis engine
(vision left dormant), enables OCR text search on the page-tag
aggregation endpoints, and reshapes the admin analysis/settings UI to
present the OCR-only surface. Bumps version 0.89.0 -> 0.90.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:52:43 +02:00
MechaCat02
4fb98e4a1e refactor(history): reuse IconButton; drop redundant emptyText overrides
All checks were successful
deploy / test-backend (push) Successful in 30m16s
deploy / test-frontend (push) Successful in 10m36s
deploy / build-and-push (push) Successful in 10m24s
deploy / deploy (push) Successful in 13s
The HistoryList clear button hand-rolled the same `.icon-btn danger`
styles that were extracted into the shared IconButton component one
commit earlier — the exact duplication that refactor targeted. Swap it
for <IconButton variant="danger">; data-testid/aria pass through the
component's {...rest} spread so behaviour is unchanged.

Both callers also passed an emptyText equal to the prop's default; drop
the overrides so the default lives in one place.

No behaviour change, so no version bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:51:05 +02:00
MechaCat02
5130c9933e feat(profile): add a desktop Page-tags section
Page tags were a first-class section of the mobile Library tab but had no
home in the desktop /profile tabs, so desktop users couldn't browse their
tagged pages without going through /search. Add a /profile/page-tags route
that reuses PageTagsList (the same component the mobile Library renders) and
a "Page tags" tab between Collections and History.

The loader mirrors the library wrapper's page-tags fetch; 401 → sign-in
prompt, matching the other profile sub-routes.

e2e: the desktop Page-tags tab navigates to /profile/page-tags and renders
the list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
e9331747d0 feat(ui): present page-action editors as sheets on mobile
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>
2026-06-25 21:27:58 +02:00
MechaCat02
af6a07bd1f feat(nav): give admins a mobile entry point to the admin area
The admin area was reachable only from the desktop header's is_admin link;
on mobile there was no tab or row, so admins had to type the URL and no tab
highlighted. Add a conditional Admin row to the mobile Account hub and
`/admin` to the Account tab's match prefixes, so the tab stays highlighted
while an admin is in the admin area.

e2e: an admin session shows an Admin row linking to /admin; a non-admin
never sees it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
5e8323d7fa feat(reader): surface brightness on desktop, not just the mobile sheet
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>
2026-06-25 21:27:58 +02:00
MechaCat02
2715275065 feat(history): share one HistoryList across desktop and mobile
The desktop /profile/history page and the mobile Library "History" tab
each hand-rendered reading history with separate markup, which had drifted:
the Library tab was a read-only 2-column list with no date and no
removed-chapter / whole-manga fallbacks, while /profile/history was a
3-column list with a per-row clear button. Extract a shared HistoryList
component so the two can't diverge again.

- New HistoryList.svelte owns row rendering, the optimistic-removal UX
  (instant remove, rollback + inline error on failure), and the empty
  state. Clear is opt-in via an `onClear` prop so a caller can render
  read-only, but both surfaces now pass it.
- The mobile Library History tab gains the clear action, the "Read {date}"
  line, and the (chapter removed) / whole-manga fallbacks it was missing.
- Continue label is built in script (was inline) so the " — page N" suffix
  keeps its spaces — Svelte trimmed them at the {#if} edge, rendering
  "Chapter 3— page 7". Both surfaces now read "Continue Chapter N".

Net -204 lines across the two pages. Uploads parity on the mobile tab is
left as a follow-up (it needs the library loader to fetch uploads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
198a1d46b0 refactor(ui): fold the 36px header/search buttons into IconButton
Completes the IconButton extraction by adding `size` and `radius` props and
converting the two remaining outliers that were left out of the first pass:
the desktop header logout button (+layout) and the home search submit button
(+page), both 36px with the medium radius.

IconButton now drives every icon button in the app (except profile/history's,
which the shared-HistoryList change removes separately). Defaults stay 32px /
small radius, so the four already-converted sites are untouched. Rendered
size, radius, and variant match the old markup — no behaviour change, no
version bump.

This also sets up applying the --tap-min touch floor in one place once the
touch-target change lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
3a36796768 refactor(ui): extract shared IconButton from duplicated .icon-btn copies
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>
2026-06-25 21:27:58 +02:00
MechaCat02
bd7c3fc28c refactor(catalog): derive desktop sort options from SORT_FIELD_LABELS
The desktop sort <select> hard-coded its four <option>s while the mobile
sort sheet rendered them from SORT_FIELD_LABELS. The two happened to match,
but a label rename in mangaSort.ts would have silently diverged the desktop
control. Render the desktop options from the same map so they can't.

No behaviour change (the rendered options are identical), so no version
bump. Adds an e2e guard asserting the desktop select's option values and
labels equal SORT_FIELD_LABELS in order — verified red against the old
hard-coded markup (with a label renamed) and green after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:27 +02:00
MechaCat02
4456deb146 fix(collections): make per-card remove buttons usable on touch
The remove buttons on a collection's cards were revealed only on
hover / focus-within (opacity 0 → 1), so on touch they were invisible and
undiscoverable. Show them persistently at the 640px breakpoint and enlarge
the hit area from 24px to 32px (kept below the full 44px floor because the
button floats over a 4-up cover thumbnail).

Test pins the CSS contract (jsdom can't evaluate @media): the mobile block
makes `.remove` opaque.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:27 +02:00
MechaCat02
94591dfda4 fix(manga): show force-resync result on mobile, not just desktop
`.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>
2026-06-25 21:25:27 +02:00
MechaCat02
194adf1339 fix(ui): meet 44px touch-target floor on shared mobile primitives
The desktop/mobile consistency review found primary mobile controls
rendering below the ~44px comfortable touch floor. Address the genuinely
shared primitives in one pass:

- Add a --tap-min: 44px token (documented anchor for the floor).
- tokens.css: text inputs, selects, and submit buttons grow to --tap-min
  under the 640px breakpoint (fixes the 36px auth/login form controls).
  Scoped to form controls + type=submit so dense icon buttons aren't
  inflated.
- SegmentedControl: .seg options reach the floor on mobile (it doubles as
  the catalog sort toggle and the Library tab switcher).
- Chip: expand .chip-remove's tappable area to --tap-min via a centered
  pseudo-element so the 16px glyph stays compact and tag rows don't grow.

Pure-CSS responsive change — jsdom can't evaluate @media or layout, so the
test pins the stylesheet contract (each shared primitive bumps to --tap-min
inside the mobile breakpoint), guarding against the bump being dropped.

The per-route .icon-btn (copy-pasted across seven files) is left for a
separate refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:27 +02:00
MechaCat02
dee53fa212 feat(mangas): per-field sort options + direction toggle on the catalog (0.88.0)
Sort the manga catalog by created / updated / title / author with an
independent asc/desc direction control. An omitted `order` defaults per field
(dates newest-first, text A→Z), matching the UI, so a bare `?sort=<field>`
means the same thing in the browser and over the API; `sort=recent` remains a
back-compat alias for `created`.

- Backend: SortField/SortOrder parsed with validation (structured 422 on bad
  input), per-field default_order, NULLS LAST only on the nullable author key,
  and migration 0033 indexing mangas(updated_at DESC, id) to back the default
  sort and its id tie-break.
- Frontend: catalog sort field + direction (SegmentedControl) on desktop and a
  mobile bottom sheet; pure helpers in $lib/mangaSort; keyboard-accessible
  direction control; visible Direction labels and a sheet "Done" button.
- Tests: backend integration coverage (defaults, alias, invalid input,
  ascending-id tie-break), frontend unit + e2e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:15:51 +02:00
MechaCat02
579e6ade0f fix(cancellable): suppress non-Abort errors from superseded predecessors (0.87.24)
CancellableLoader.run() previously swallowed AbortError but propagated
every other rejection. A predecessor whose fetch survived its abort
(timing race, or fn that ignores signal) and then 5xx'd would bubble
the error into the caller's catch, clearing the successor's spinner
and writing its `error` field — exactly the flicker the loader was
supposed to prevent.

Strengthen the contract: a call that no longer owns this.current
returns null regardless of how it died. Symmetric with the existing
late-resolve suppression at the success path. The loadCoverage catch
in admin/analysis/+page.svelte now only fires when this call genuinely
failed AND still owns the loader — so clearing the spinner there is
sound.

Three new unit tests pin all three contract corners: a superseded
predecessor's non-Abort rejection resolves to null; a standalone call
with no successor still throws normally; the AbortError swallow on a
still-owning call (synthetic AbortError from fn with no successor or
cancel()) remains protected, since the new supersede check would
otherwise have eaten every existing AbortError-path test and left
that branch as silently-removable dead code.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 07:11:01 +02:00
MechaCat02
4e14ed09ef test(frontend): extract CancellableLoader; pin debounce-race invariants in unit tests (0.87.23)
0.87.16 followup. Extract the four debounce-race invariants
(predecessor abort, late-result suppression, AbortError swallow,
ownership-aware finally) from `loadCoverage` into a reusable
`CancellableLoader` helper at $lib/util/cancellable, with 9 unit
tests covering each. Page rewrite uses the helper.

Also corrects: hooks.server.test.ts (added Node-fetch rejection
shape for already-aborted entry; removed redundant tick); admin.test.ts
(AbortError-propagation now uses pre-aborted signal + mockRejectedValueOnce
mirroring real Node fetch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 21:03:12 +02:00
MechaCat02
54cbbfc440 test(frontend): pin abort-chain + signal pass-through contracts (0.87.16)
Three 0.87.9 follow-ups:

1. hooks.server.ts: two new tests covering the
   already-aborted-at-entry and mid-flight client-disconnect branches
   of the abort chain. Mutation-confirmed.

2. lib/api/admin.ts: two new tests pinning the
   getAnalysisMangaCoverage(init?.signal) pass-through and the
   AbortError-rejection propagation the dashboard's debounce-race
   handler relies on. Mutation-confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 19:22:48 +02:00
MechaCat02
651fb27522 fix(analysis): emit Cancelled event + assert lock-release after cancel (0.87.13)
Two 0.87.4 follow-ups:

1. Cron tick: existing shutdown-on-cancel test now also asserts the
   advisory lock is re-acquirable after cancel-during-tick. Without
   this, a refactor moving `pg_advisory_unlock` under the cancel arm
   would ship green and break multi-replica safety.

2. Analysis worker: cancel-mid-dispatch left the dashboard banner stuck
   on the cancelled page until a later page overwrote it. Add a
   `Cancelled` AnalysisEvent variant, publish on the cancel-release
   branch, wire the analysis and admin overview dashboards to clear
   on it. Per-page chip rolls back from `analyzing` to `queued`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 19:00:38 +02:00
MechaCat02
747bdeda46 fix(frontend-admin): SSE/AbortController hygiene + signal-chain through proxy (0.87.9)
Three frontend findings (medium):

1. **`hooks.server.ts` didn't chain client-disconnect into the upstream
   fetch.** Closing an admin tab left the backend's `broadcast::Receiver`
   + spawned tokio task running until next backend restart. Listen for
   `event.request.signal.abort` and forward to the timeout controller.

2. **`admin/analysis/+page.svelte` missing visibility/pagehide/pageshow
   handlers.** Mobile Safari throttles SSE in background tabs; bfcache
   leaves a dead connection on back-nav. Apply the close/open-stream
   pattern from `admin/crawler/+page.svelte`.

3. **`loadCoverage` had no AbortController.** Debounced search
   keystrokes raced. Per-loader controller plumbed through
   `getAnalysisMangaCoverage`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:52:33 +02:00
MechaCat02
9f7dfe4d4e fix(admin): keyboard-reachable audit payload toggle; drop dead step ternary
All checks were successful
deploy / test-backend (push) Successful in 27m23s
deploy / test-frontend (push) Successful in 10m20s
deploy / build-and-push (push) Successful in 10m42s
deploy / deploy (push) Successful in 13s
Review follow-ups: the audit row's expand was mouse-only (onclick on a bare
<tr>). Move the toggle to a real <button> in the actions cell with
aria-expanded + aria-label so keyboard/AT users can open a row's payload; the
row onclick stays as a mouse convenience (stopPropagation avoids a double
toggle). Also simplify a no-op `step={'%' ? '1' : '1'}` to step="1" on the
threshold inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:55:48 +02:00
MechaCat02
c6a6d1690d feat(admin): time-series trend charts on the metrics tabs (0.87.0)
Turn the dormant timing data in crawl_metrics / page_analysis into "is it
healthy over time" views. New bucketed series queries (GROUP BY date_trunc,
hour|day via a closed Bucket enum so the unit can't be attacker-controlled)
behind GET /v1/admin/{crawler,analysis}/metrics/series, with a shared
SeriesParams/resolve_bucket helper (bad bucket → 400) and migration 0030
indexing page_analysis(analyzed_at) for the analysis scan.

Frontend: a dependency-free SVG TrendChart (line+area, null buckets render as
gaps, empty-state, role=img) embedded above the per-op tables in Crawler and
Analysis → Metrics, driven by each panel's existing window selector with
AbortController-cancelled fetches. A buildSeries() util fills the continuous
bucket axis (throughput 0 for empty buckets, success/duration null) — unit
tested alongside the chart and the series API client.

Closes the Phase-1 observability set (audit log · health checks · trends).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:48:56 +02:00
MechaCat02
314fc8738b feat(admin): operational health checks page with editable thresholds
New /admin/health rolls up signals already collected — disk/memory sensors,
dead-job + missing-cover backlogs, 24h crawl/analysis failure rates, metadata
cron freshness, and the live crawler session/browser flags — into a flat
checks list (ok/warn/critical) with an overall status and remediation links.
The overview gains a compact Health summary banner linking in.

GET /v1/admin/health evaluates the checks (DB sub-queries run concurrently via
try_join; all hit existing indexes). PUT /v1/admin/health/thresholds persists
the thresholds to app_settings (merged over compiled defaults, forward-compat
via serde(default)), validates ranges (400 on bad input), and audit-logs the
change in one transaction. New repo::crawler::last_metadata_tick_at getter and
a lightweight system::memory_percent_used (no CPU settle delay).

Pure status helpers (over_limit for gauges, exceeds for backlog counts so a
0-limit doesn't false-alarm at 0) are unit-tested; endpoint tests cover shape,
gating, persistence+audit, and validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:38:18 +02:00
MechaCat02
31013cc893 feat(admin): audit-log viewer
Surface the admin_audit table (written by every mutating admin action but
previously unreadable) as a new /admin/audit tab. New repo::admin_audit::list
(LEFT JOIN users for the actor's username, filter by action/target_kind/actor/
since, at DESC via admin_audit_at_idx) behind GET /v1/admin/audit, mirroring the
crawler history endpoint's paged/clamped idiom.

Frontend: a self-contained AuditTable (target-kind + window + action filters,
expandable JSON payload, AbortController-cancelled fetches, loading/error/empty
states) and shared fmtAgo() in format.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:26:09 +02:00
MechaCat02
dd300a150c feat(crawler): reconcile pass to enqueue mangas missing from the DB (0.85.0)
The interleaved metadata pass misses mangas to list drift (a title slips a
pagination slot during the slow detail walk). Add a reconcile pass: a cheap,
full, list-only walk (refs only, no detail visit, no early stop) that
set-diffs the walked keys against manga_sources and enqueues the strictly-
missing ones as SyncManga jobs. A set-diff is immune to drift — a manga only
has to appear somewhere in the list.

- Build the previously-dead SyncManga worker by extending RealChapterDispatcher
  to run the shared pipeline::process_manga_ref (fetch → upsert → cover →
  chapters), refactored out of run_metadata_pass so both paths stay in lockstep.
  The crawl worker now leases both sync_chapter_content and sync_manga
  (jobs::lease_kinds); both serialize on the single exclusive browser.
- SyncManga payload carries url + title so the worker can rebuild the ref and
  the dead-jobs/history UI can label a missing manga that has no manga row yet.
- "Missing" = strict NOT EXISTS (dropped rows count as present). Re-enqueue is
  skipped when a pending/running/dead SyncManga job already exists, so a gone
  manga (detail 404 → retries → dead) is left dead and not retried.
- New POST /v1/admin/crawler/reconcile (fire-and-forget, shares
  manual_pass_lock) + "Reconcile missing" admin button + Reconciling status
  phase streamed over SSE.
- dead-jobs/history queries surface payload title/url/key; tables fall back to
  them for sync_manga rows; both searches match the payload title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:15:11 +02:00
35664bccc7 perf(admin): fix O(mangas x jobs) overview query that pinned Postgres (0.85.1)
All checks were successful
deploy / test-backend (push) Successful in 26m28s
deploy / test-frontend (push) Successful in 10m18s
deploy / build-and-push (push) Successful in 11m21s
deploy / deploy (push) Successful in 13s
The admin overview dashboard polls /admin/overview every 30s. Its
`manga_stats` aggregate evaluated MANGA_SYNC_STATE_CASE over the WHOLE
library (14.5k mangas) with no LIMIT, and that CASE runs a correlated
EXISTS over crawler_jobs. With no index on the `sync_chapter_list`
manga_id path, Postgres seqscanned all in-flight jobs *per manga* —
O(mangas x jobs). The disabled-analysis backlog (9.5k pending
`analyze_page` rows that can never match the sync-kind filter) inflated
every scan, so each call took 6-7 min. The 30s poll stacked ~10 of them
concurrently → load 11, Postgres at ~100%.

EXPLAIN ANALYZE on the live DB: the rewrite drops the query from 6-7 min
to ~1.5s (per-manga crawler_jobs seqscan → single index-backed pass).

Fixes:
- Rewrite `manga_stats` to collect the in-flight manga-id set in ONE pass
  over the sync jobs (index-backed), then classify via a hash semi-join —
  O(mangas + jobs), immune to the analyze_page backlog size.
- Add partial index crawler_jobs_sync_chapter_list_manga_idx (migration
  0029) covering the sync_chapter_list manga_id path; mirrors the existing
  sync_manga index (0020). Also speeds the per-row Mangas tab listing.
- statement_timeout backstop (5s) on the aggregate so a pathological plan
  can never pin a backend for minutes again.
- Single-flight + 10s TTL cache on the /admin/overview handler so
  concurrent pollers coalesce instead of stacking.
- Slow the dashboard poll 30s -> 60s (server-cached now anyway).

The in_progress rule in the rewrite is kept in lockstep with the first
arm of MANGA_SYNC_STATE_CASE (documented in both places).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:05:40 +02:00
cde4aca98b feat(admin): overview dashboard sections + system temperature/load sensors (0.85.0) (#11)
All checks were successful
deploy / test-backend (push) Successful in 25m9s
deploy / test-frontend (push) Successful in 10m13s
deploy / build-and-push (push) Successful in 10m25s
deploy / deploy (push) Successful in 12s
2026-06-16 18:46:28 +00:00
d51ab2a049 feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
Some checks failed
deploy / test-backend (push) Failing after 19m59s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped
2026-06-16 12:21:13 +00:00
MechaCat02
790549636f feat(storage): admin storage-usage stats + per-manga/chapter sizes
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
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>
2026-06-15 20:18:41 +02:00
MechaCat02
2b7a11b480 feat(admin): runtime-editable crawler & analysis config in the dashboard
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.

- New `app_settings` table (migration 0026) holds one JSONB row per group;
  env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
  DB-overlay conversion and field-level validation; env-only/secret fields
  (browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
  cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
  env-managed view + prompt defaults; PUT validates (422 + per-field
  details), persists + audits in one tx, then gracefully respawns the
  affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
  gate behind shared runtime cells so reloads take effect with no restart;
  a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
  (defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
  reset-to-default, env-managed read-only panel, save-&-apply confirm, and
  per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:32:20 +02:00
MechaCat02
8b5bd99446 feat(analysis): live SSE updates in the admin coverage dashboard
The Analysis section now reflects worker progress in real time:

- Opens an EventSource on the live stream while mounted; a "Live" pill
  reflects connection state and reconnects on drop (probes after repeated
  failures so a lost session logs out).
- Applies events incrementally: enqueued marks in-scope loaded pages
  queued; started flips a chip to a pulsing "analyzing"; completed flips
  it green and live-bumps the manga + chapter coverage badges (guarded so
  no double count); failed flips it red. A compact activity ticker shows
  the last few events.
- Server numbers stay authoritative: re-loading the overview or a page
  grid clears the matching live overrides.

API client: analysisStatusStreamUrl() + AnalysisEvent type.

Tests: vitest for the stream URL; Playwright asserts SSE frames flow into
the ticker (existing coverage/drill/queue specs get a default quiet
stream). svelte-check + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:12:56 +02:00
MechaCat02
6bb72b8775 feat(analysis): admin coverage overview + per-page result inspector UI
Reworks /admin/analysis from a blind enqueue form into a coverage browser:

- Loads a paginated overview of mangas with a CoverageBadge
  (Full/Partial/None, analyzed/total) per row; debounced search filters it.
- Drill manga → chapters (coverage badge each) → page grid (chips colored
  done/queued/failed/none) with a legend.
- Click a page chip → Modal showing the full result: status, model, time,
  NSFW + content-warning chips, scene description, tags, and OCR lines
  (labeled by kind). Unanalyzed/failed pages get a "Queue this page"
  action (force re-analyze).
- Keeps "Queue all" + per-manga/chapter enqueue and the include-analyzed
  toggle; enqueue refreshes the open chapter's grid so queued pages show.

API client: getAnalysisMangaCoverage / ChapterCoverage / ChapterPages /
PageDetail + types. New CoverageBadge component.

Tests: vitest for the four clients; Playwright for overview+badges+queue-all,
drill+inspect, and queue-from-detail. svelte-check + build clean; 266 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:39:54 +02:00
MechaCat02
7e675b72cc feat(analysis): admin analysis section uses manga search + chapter picker
Replaces the raw manga-id / chapter-id UUID inputs with a real picker:

- Search a manga (debounced, reuses listAdminMangas) → result rows show
  title + chapter count; "Queue manga" enqueues the whole manga.
- Expand a result to load its chapters (listAdminChapters) and "Queue
  chapter" enqueues a single chapter.
- Keeps the "Queue all (whole library)" action and a single global
  "include already-analyzed" toggle applied to every action; per-action
  busy state + a result message naming what was queued.

Tests: rewrote the Playwright spec to drive the search → manga/chapter
flow (whole library, search+queue manga, expand+queue chapter with
include-analyzed). svelte-check + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:05:09 +02:00
MechaCat02
8b7ea2e1b2 feat(analysis): admin UI — reader context-menu action + dashboard section
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>
2026-06-13 19:57:03 +02:00
MechaCat02
d36f24e9af feat(search): frontend content search (text + warnings) + manga warning banner
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>
2026-06-13 19:12:34 +02:00
MechaCat02
33f684887d feat(mangas): add tag-similarity "Similar" section on manga detail
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>
2026-06-13 15:52:37 +02:00
MechaCat02
6c901e64c9 feat(search): tag-based page search surface + per-page tags & collections
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>
2026-06-13 15:51:38 +02:00
MechaCat02
9910a0a995 fix(frontend): reader back button pops history instead of pushing (0.60.2)
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>
2026-06-08 20:59:10 +02:00
MechaCat02
00577071fd fix(frontend): mobile layout polish + overflow handling (0.60.1)
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>
2026-06-08 20:58:21 +02:00
MechaCat02
1e3fd27308 feat(frontend): mobile account hub + library wrapper (0.60.0)
Phase 5 (final) of the mobile redesign: /profile/account becomes an
inset-grouped iOS-style hub on mobile and the new /library route hosts
a SegmentedControl over Bookmarks / Collections / History. Profile-
layout horizontal tabs hide below 640px. Desktop layout is unchanged.

- New /library/+page.ts loads bookmarks, collections, and read-history
  in parallel so segmented-control tabs swap instantly without a new
  round trip. 401 → unauthenticated path with sign-in prompt.
- New /library/+page.svelte renders the SegmentedControl with ?tab=
  as the source of truth. Bookmarks reuses the existing BookmarkList,
  Collections reuses CollectionsGrid, History inlines a slim cover +
  title + "Continue Ch. N" row list. `goto(..., { replaceState: true })`
  drives the URL — plain `replaceState` from $app/navigation doesn't
  reliably re-trigger $page-derived state.
- BottomNav's Library tab now points at /library (Phase 1 placeholder
  was /bookmarks). The Phase 1 mobile-chrome spec is updated to expect
  the new target and mocks the additional library data endpoints.
- /profile/+layout.svelte hides the horizontal tabs below 640px — the
  bottom-nav Library/Account tabs plus the account hub carry mobile
  cross-section navigation.
- /profile/account/+page.svelte gains a mobile hub: a centered avatar
  + username + "Member since" header, a row group with Profile /
  Preferences / Change password rows (the last opening a bottom
  Sheet hosting the existing password form snippet), and a separate
  group with a red "Log out" row that reuses the layout's logout
  flow (logout API → session.setUser(null) → preferences.clearForLogout
  → goto('/login')). Desktop keeps the inline card with the form.
- matchMedia gates the hub vs. desktop card so the password form
  testids never duplicate on the page — the snippet is rendered in
  exactly one mount at a time.
- 9 Playwright tests cover the Library nav handoff, segmented-control
  sub-tab swap with URL, sign-in CTAs on both routes, account hub
  composition, password sheet open flow, logout flow (POST /auth/
  logout + redirect to /login), profile tabs hiding on mobile, and
  the desktop regression where the inline card is the only password
  surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:35:07 +02:00