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>
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>