Compare commits

...

19 Commits

Author SHA1 Message Date
MechaCat02
51ea254dde fix: surface streamed load failures inline
All checks were successful
deploy / test-backend (push) Successful in 30m44s
deploy / test-frontend (push) Successful in 10m46s
deploy / build-and-push (push) Successful in 10m59s
deploy / deploy (push) Successful in 13s
The streamed loaders on bookmarks, collections, library, search, and the
collection detail page swallowed only ApiError into their in-band `error`
field and re-threw anything else. A raw network failure (a non-ApiError
TypeError from fetch) therefore escaped the `{#await}` to SvelteKit's
generic error page. On the collection detail page a content-load failure
showed a misleading "this collection is empty", and on search a failed
results query showed a false "no matches" because the streamed `error`
was never rendered.

Fold non-ApiError failures into the same in-band `error` so every
transient blip renders as an inline message, add a `contentError` branch
to the collection detail grid, and render the search results `error` in
both result views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:11:37 +02:00
MechaCat02
fee68dd9ac feat: loading skeleton on the manga detail page
The detail loader now streams its six-call bundle instead of blocking
navigation on it. The page component is a thin {#await} wrapper: it shows a
MangaDetailSkeleton (cover + meta + chapter rows) while the bundle loads,
renders the extracted DetailView once resolved, and shows an inline
not-found on a 404 (previously the framework error page). Extracting
DetailView also makes it remount per navigation, so its tag/bookmark/reaction
state re-seeds cleanly on manga-to-manga moves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:30:49 +02:00
MechaCat02
3c8e264f48 feat: loading skeleton on the library page
The library loader streams its one-shot bundle (also the auth gate), so the
active sub-tab shows a skeleton while it loads — a grid skeleton for the
Collections tab, a row skeleton for Bookmarks/History/Page-tags — instead of
only the global nav bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:21:20 +02:00
MechaCat02
16cdec051a feat: loading skeletons on the bookmarks and collections pages
Both loaders now stream the whole result (the single fetch is also the auth
gate) so the page shows a skeleton while it loads instead of the global nav
bar only: a ListRowSkeleton on bookmarks and a MangaGridSkeleton on the
collections grid, resolving to the sign-in / error / empty / list branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:18:43 +02:00
MechaCat02
5b51bcf056 feat: results skeleton on the search page
The search loader now awaits only the chip cloud (its auth gate) and streams
the view-specific results, so switching tag / view / sort / text shows a
SearchResultsSkeleton in place of the frozen previous list until the query
resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:14:43 +02:00
MechaCat02
f1e66141f4 fix: add tags optimistically on the detail page
submitTag now shows the chip immediately with the typed name and reconciles
it with the server's (normalized) ref on success, rolling back on error —
matching the already-optimistic tag removal instead of blocking on the
round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:05:23 +02:00
MechaCat02
cee1e73f98 feat: grid skeletons on the author and collection detail pages
Both loaders now stream their manga grid instead of blocking on it: the cheap
header renders immediately (and still handles 404/401 in the loader) while a
MangaGridSkeleton stands in until the grid resolves, then swaps in without a
layout shift. Collection detail seeds its optimistic-removal state from the
stream via a guarded effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:01:53 +02:00
MechaCat02
ec73c6e001 feat: reserve home shelf space while it loads (fixes CLS)
The Continue-reading / Recommended shelves were fetched after the catalogue
and rendered only once populated, so they popped in late and shoved the grid
down on every authenticated home visit. Now the shelf fetch fires
concurrently with the catalogue and a reserved ShelfSkeleton holds the space
for logged-in users until it resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:53:03 +02:00
MechaCat02
bd6ae86a85 feat: preload the reader from the detail CTA
The Continue / Read-first-chapter CTA is the highest-intent link to the
heaviest route in the app. An effect now programmatically preloadData()s the
target chapter as soon as the detail resolves, so the reader's load and first
page images are warm before the tap. Complements the reader's own
next-chapter warming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:47:53 +02:00
MechaCat02
3622dcc02f fix: reserve detail cover box and async-decode list/shelf covers
- The manga detail desktop overview cover had no reserved aspect-ratio, so
  the meta column reflowed when it decoded. Give .cover aspect-ratio: 2/3 +
  object-fit: cover (mirrors MangaCard).
- Add decoding="async" to the detail hero/overview covers and the list/shelf
  covers (bookmarks, history, continue-reading, collections, tagged rows) so
  decode stays off the main thread — previously only MangaCard had it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:43:21 +02:00
MechaCat02
9cb2f152d3 fix: bookmark toggle is optimistic and no longer fails silently
The detail-page bookmark toggle awaited the round-trip before updating and
had no catch — a failed create/delete threw unhandled and the user saw
nothing. It now flips optimistically (placeholder reconciled with the server
row on success), rolls back on error, and surfaces a toast, matching the
sibling like/dislike buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:40:33 +02:00
MechaCat02
7c3c9cf699 feat: app-wide toast notifications
Adds a client-side toast store (info/success/error, auto-dismiss with a
sticky option) and a Toaster component mounted in the root layout, wired to
the reserved --z-toast token. Gives mutations a consistent way to surface
success/failure instead of inline-only text or silent catches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:36:34 +02:00
MechaCat02
0d9505ce9f fix: address skeleton/nav-progress review findings
Correctness:
- MangaGridSkeleton: covers rendered 0px tall because height="0" is a
  definite height that defeats aspect-ratio. Skeleton gains an `aspectRatio`
  prop (height stays auto) so the 2/3 cover box is reserved and the grid no
  longer shifts when real covers load.
- Move `.manga-card` layout into global tokens.css (it was scoped to
  MangaCard, so the skeleton's cards inherited none of it). Both consumers
  now share one definition.
- NavProgress: rebuilt as an idle/loading/done state machine. It now
  completes to full and fades on settle (was snapping away instantly),
  restarts the trickle when one navigation supersedes another (was parking
  frozen near 90%), animates transform/opacity only with will-change gated to
  the active phase, and hides itself in reader fullscreen.
- Skeleton: text-variant height resolved in one place (no inline style
  shadowing a stylesheet rule); shimmer gradient/animation gated behind
  prefers-reduced-motion: no-preference so reduced motion shows a flat block.

A11y:
- Home loading region announces via visually-hidden text instead of an
  aria-label a live region won't reliably read.

Cleanup:
- Drop the fragile :first-child selector and index-keying ceremony in
  MangaGridSkeleton; simplify Skeleton's style construction.

Tests: adds regression guards for the 0px cover collapse, the aspect-ratio
box, text-height defaulting, and the nav-progress complete/restart phases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:02:27 +02:00
MechaCat02
7bdbe3ce5b fix: define missing --font-md and --success-soft-bg tokens
Both were referenced by components (RecommendationShelf, the manga detail
page) but never declared, so var() fell through to inherited/fallback
values. Adds --font-md (1rem, on the xs/sm/md/lg scale) and
--success-soft-bg in both the light and dark theme blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:44:43 +02:00
MechaCat02
3364ea52c9 feat: async decode for manga cover images
Adds decoding="async" to the MangaCard cover so image decode happens off the
main thread, keeping grid scroll/paint smooth as covers stream in. The 2/3
aspect-ratio already reserves space, so no layout shift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:41:28 +02:00
MechaCat02
def97d3087 feat: skeleton loading state on the home catalog
Replaces the plain "Loading…" text with a content-shaped MangaGridSkeleton
so the catalog grid's layout is reserved while it loads and swaps in without
a shift. Keeps the data-testid="loading" wrapper (now role="status") for
accessibility and selector stability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:40:28 +02:00
MechaCat02
09f12c8959 feat: global navigation progress bar
Most data routes run their load with ssr = false, so SvelteKit holds the
previous page on screen while the next one's data resolves — with no
feedback. NavProgress, mounted once in the root layout and driven by the
$navigating store, shows a thin top bar that trickles while a navigation is
pending and fades out on completion. Degrades to a static visible bar under
prefers-reduced-motion. New --z-nav-progress token sits above the fixed
header but below modals/toasts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:38:14 +02:00
MechaCat02
34c1122e4e feat: add MangaGridSkeleton loading placeholder
Composes Skeleton into a cover-grid placeholder that reuses the shared
.manga-grid / .manga-card layout, so it matches the real catalog grid at
every breakpoint and content swaps in without a layout shift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:31:58 +02:00
MechaCat02
200ab1c0a0 feat: add Skeleton loading-placeholder primitive
A reusable, token-driven shimmer block with configurable width/height,
radius, and variant. Decorative (aria-hidden) — loading state is announced
by the container. Freezes to a solid muted block under the global
prefers-reduced-motion override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:28:00 +02:00
62 changed files with 3986 additions and 1730 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.109.1"
version = "0.122.1"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.109.1"
version = "0.122.1"
edition = "2021"
default-run = "mangalord"

View File

@@ -0,0 +1,66 @@
import { test, expect, type Page } from './fixtures';
// The author page streams its manga grid: the cheap author header renders
// immediately while the (potentially large) grid shows a skeleton, then swaps
// in without a layout shift.
const authorId = 'a1111111-1111-1111-1111-111111111111';
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/authors/${authorId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: authorId, name: 'Kentaro Miura', manga_count: 1 })
})
);
}
test('shows a grid skeleton while the author mangas stream, then the grid', async ({ page }) => {
await mockCommon(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(`**/api/v1/authors/${authorId}/mangas*`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
id: 'm1', title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z'
}
],
page: { limit: 50, offset: 0, total: 1 }
})
});
});
await page.goto(`/authors/${authorId}`);
// Header renders immediately (not blocked on the grid).
await expect(page.getByTestId('author-name')).toHaveText('Kentaro Miura');
// Grid skeleton stands in while the stream is pending.
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
await expect(page.getByTestId('author-manga-list')).toHaveCount(0);
release();
await expect(page.getByTestId('author-manga-list')).toContainText('Berserk');
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
});

View File

@@ -0,0 +1,57 @@
import { test, expect, type Page } from './fixtures';
// The bookmarks page streams its list (the single fetch is also the auth
// gate), so a row skeleton shows while it loads, then resolves to the list.
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, body: '' }));
}
test('shows a row skeleton while bookmarks stream, then the list', async ({ page }) => {
await mockCommon(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v1/me/bookmarks*', async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
id: 'bk1', user_id: 'u1', manga_id: 'm1', chapter_id: null, page: null,
created_at: '2026-01-01T00:00:00Z', manga_title: 'Berserk', manga_cover_image_path: null
}
],
page: { limit: 50, offset: 0, total: 1 }
})
});
});
await page.goto('/bookmarks');
await expect(page.getByTestId('list-row-skeleton')).toBeVisible();
release();
await expect(page.getByText('Berserk')).toBeVisible();
await expect(page.getByTestId('list-row-skeleton')).toHaveCount(0);
});

View File

@@ -0,0 +1,67 @@
import { test, expect, type Page } from './fixtures';
// The collection detail page streams its manga/page grids: the header renders
// immediately while a grid skeleton stands in, then the real grid swaps in.
const collectionId = 'd1111111-1111-1111-1111-111111111111';
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route(`**/api/v1/collections/${collectionId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: collectionId, name: 'Favourites', description: null, manga_count: 1 })
})
);
await page.route(`**/api/v1/collections/${collectionId}/pages*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
);
}
test('streams a grid skeleton on the collection detail then the manga grid', async ({ page }) => {
await mockCommon(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(`**/api/v1/collections/${collectionId}/mangas*`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{ id: 'm1', title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }
],
page: { limit: 200, offset: 0, total: 1 }
})
});
});
await page.goto(`/collections/${collectionId}`);
await expect(page.getByRole('heading', { name: 'Favourites' })).toBeVisible();
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
await expect(page.getByTestId('collection-manga-list')).toHaveCount(0);
release();
await expect(page.getByTestId('collection-manga-list')).toContainText('Berserk');
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
});

View File

@@ -0,0 +1,115 @@
import { test, expect, type Page } from './fixtures';
// The detail-page bookmark toggle should feel instant (optimistic) and never
// fail silently: a rejected create/delete must roll the button back and
// surface a toast.
const mangaId = 'b1111111-1111-1111-1111-111111111111';
async function mockDetail(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
})
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
})
})
);
}
test('bookmark toggles optimistically before the request resolves', async ({ page }) => {
await mockDetail(page);
// Hold the create request open so we can observe the pre-response state.
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v1/bookmarks', async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'bk1', user_id: 'u1', manga_id: mangaId, chapter_id: null, page: null,
created_at: '2026-01-01T00:00:00Z'
})
});
});
await page.goto(`/manga/${mangaId}`);
const btn = page.getByTestId('bookmark-toggle');
await expect(btn).toHaveAttribute('aria-pressed', 'false');
await btn.click();
// Optimistic: reflects bookmarked state while the POST is still pending.
await expect(btn).toHaveAttribute('aria-pressed', 'true');
release();
// Stays bookmarked once the server confirms.
await expect(btn).toHaveAttribute('aria-pressed', 'true');
});
test('a failed bookmark rolls back and shows an error toast', async ({ page }) => {
await mockDetail(page);
await page.route('**/api/v1/bookmarks', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'internal', message: 'boom' } })
})
);
await page.goto(`/manga/${mangaId}`);
const btn = page.getByTestId('bookmark-toggle');
await btn.click();
// Rolls back to un-bookmarked, and the failure is surfaced (not silent).
await expect(btn).toHaveAttribute('aria-pressed', 'false');
await expect(page.getByTestId('toast')).toBeVisible();
});

View File

@@ -0,0 +1,85 @@
import { test, expect, type Page } from './fixtures';
// The detail page's Continue/Read CTA is the highest-intent link to the
// heaviest route in the app. It should be programmatically preloaded so the
// reader's data (and first page images) are warm before the tap — proven here
// by the reader-only chapter-pages endpoint being requested on the detail page
// without any click.
const mangaId = 'c1111111-1111-1111-1111-111111111111';
const chId = 'cc111111-1111-1111-1111-111111111111';
async function mockDetailAndReader(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
// Chapter list (detail + reader both use this).
const chapters = [
{ id: chId, manga_id: mangaId, number: 1, title: null, page_count: 3, created_at: '2026-01-01T00:00:00Z' }
];
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: chapters, page: { limit: 200, offset: 0, total: 1 } }) })
);
// Single-chapter fetch (reader only).
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chId}`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(chapters[0]) })
);
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
})
})
);
}
test('programmatically preloads the reader from the detail CTA', async ({ page }) => {
await mockDetailAndReader(page);
// The chapter-pages endpoint is fetched only by the reader's load.
let pagesRequested: () => void = () => {};
const pagesHit = new Promise<void>((resolve) => {
pagesRequested = resolve;
});
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chId}/pages`, (route) => {
pagesRequested();
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ pages: [{ id: 'p1', number: 1, image_path: 'x', width: null, height: null }] })
});
});
await page.goto(`/manga/${mangaId}`);
// Detail rendered (so ctaTarget is resolved to the first chapter).
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
// Preload fires the reader load (hence the pages endpoint) without a click.
await expect(pagesHit).resolves.toBeUndefined();
});

View File

@@ -0,0 +1,77 @@
import { test, expect, type Page } from './fixtures';
// The manga detail page streams its data bundle: a MangaDetailSkeleton shows
// while the six calls load, then DetailView swaps in. A missing manga (404)
// resolves to an inline not-found instead of the happy-path markup.
const mangaId = 'f1111111-1111-1111-1111-111111111111';
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
}
test('shows a detail skeleton while the bundle streams, then the page', async ({ page }) => {
await mockCommon(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(`**/api/v1/mangas/${mangaId}`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
})
});
});
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('manga-detail-skeleton')).toBeVisible();
await expect(page.getByTestId('manga-title')).toHaveCount(0);
release();
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
await expect(page.getByTestId('manga-detail-skeleton')).toHaveCount(0);
});
test('renders an inline not-found for a missing manga (404)', async ({ page }) => {
await mockCommon(page);
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'no' } }) })
);
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('detail-error')).toContainText('Manga not found');
});

View File

@@ -0,0 +1,88 @@
import { test, expect, type Page } from './fixtures';
// Adding a tag on the detail page should feel instant: the chip appears
// optimistically while the request is in flight, and is reconciled with the
// server's (normalized) tag on success.
const mangaId = 'e1111111-1111-1111-1111-111111111111';
async function mockDetail(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
// Autocomplete suggestions endpoint — keep it empty/fast.
await page.route('**/api/v1/tags*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
);
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
})
})
);
}
test('adds a tag optimistically before the request resolves', async ({ page }) => {
await mockDetail(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(`**/api/v1/mangas/${mangaId}/tags`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'tag1', name: 'action', added_by: 'u1' })
});
});
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
await page.getByTestId('tag-input').fill('action');
await page.getByTestId('tag-input').press('Enter');
// Optimistic: the chip shows while the POST is still gated.
await expect(page.getByTestId('manga-tags')).toContainText('action');
release();
// Still present after the server confirms (reconciled to the real tag).
await expect(page.getByTestId('manga-tags')).toContainText('action');
await expect(page.getByTestId('tag-chip-tag1')).toBeVisible();
});

View File

@@ -63,6 +63,51 @@ test('home page renders the Mangalord heading and search input', async ({ page }
await expect(page.getByTestId('empty')).toContainText('No mangas yet');
});
test('shows a skeleton grid while the catalog is loading, then the real grid', async ({ page }) => {
await mockAnonymous(page);
// Hold the catalog fetch open so the loading state is observable.
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v1/mangas*', async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
id: 'm1',
title: 'Berserk',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [],
genres: []
}
],
page: { limit: 50, offset: 0, total: 1 }
})
});
});
await page.goto('/');
// Skeleton stands in for the grid while the fetch is pending.
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
await expect(page.getByTestId('manga-list')).toHaveCount(0);
// Once data lands, the real grid replaces the skeleton.
release();
await expect(page.getByTestId('manga-list')).toContainText('Berserk');
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
});
// Anti-drift guard: the desktop sort <select> and the mobile sort sheet must
// both enumerate exactly SORT_FIELD_LABELS, in the same order. The desktop
// select used to hard-code its <option>s, so a label rename in mangaSort.ts

View File

@@ -0,0 +1,148 @@
import { test, expect, type Page } from './fixtures';
// The global nav progress bar (rendered once in the root layout) is the only
// feedback most data routes have while a client-side navigation is pending:
// they run `load` with `ssr = false`, so SvelteKit holds the previous page on
// screen with no indication anything is happening. This drives a real
// home -> detail navigation, holds the detail's `getManga` open, and asserts
// the bar activates during the pending load and clears once it settles.
const mangaId = 'm1111111-1111-1111-1111-111111111111';
const listItem = {
id: mangaId,
title: 'Berserk',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [],
genres: []
};
const mangaDetail = {
id: mangaId,
title: 'Berserk',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [],
genres: [],
tags: [],
content_warnings: [],
chapter_storage_bytes: 0
};
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
})
);
await page.route('**/api/v1/genres*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
// Catalog list (Pattern B). Registered before the detail routes so the
// more-specific handlers below win for the /mangas/:id URLs.
await page.route('**/api/v1/mangas*', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [listItem], page: { limit: 50, offset: 0, total: 1 } })
})
);
// Detail load's non-manga calls.
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'not_found', message: 'no' } })
})
);
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'not_found', message: 'no' } })
})
);
}
test('shows the nav progress bar during a pending navigation and clears it after', async ({
page
}) => {
await mockCommon(page);
// Hold the detail's getManga open so the navigation stays pending long
// enough to observe the bar. Registered last => wins for the exact
// /mangas/:id URL over the catalog glob.
let releaseDetail: () => void = () => {};
const gate = new Promise<void>((resolve) => {
releaseDetail = resolve;
});
await page.route(`**/api/v1/mangas/${mangaId}`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaDetail)
});
});
await page.goto('/');
await expect(page.getByTestId('manga-list')).toBeVisible();
const bar = page.getByTestId('nav-progress');
// Idle on a settled page.
await expect(bar).toHaveAttribute('data-phase', 'idle');
// Client-side navigate into the (gated) detail page.
await page.locator(`a[href="/manga/${mangaId}"]`).first().click();
// Bar enters the loading phase while the detail load is in flight.
await expect(bar).toHaveAttribute('data-phase', 'loading');
// Let the load resolve; the detail renders and the bar settles back to
// idle (via a brief done phase).
releaseDetail();
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
await expect(bar).toHaveAttribute('data-phase', 'idle');
});

View File

@@ -0,0 +1,74 @@
import { test, expect, type Page } from './fixtures';
// The search page streams its results: the chip cloud / controls stay put
// while a re-query shows a results skeleton, which then swaps for the list.
const mangaId = 'a9999999-9999-9999-9999-999999999999';
const chapterId = 'c9999999-9999-9999-9999-999999999999';
const pageId = 'p11111111-1111-1111-1111-111111111111';
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 'u1', username: 'tester', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/me/page-tags/distinct*', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [{ tag: 'funny', count: 38 }] })
})
);
await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, body: '' }));
}
test('shows a results skeleton while the tagged pages stream, then the list', async ({ page }) => {
await mockCommon(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v1/me/page-tags?**', async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
tag: 'funny', page_id: pageId, chapter_id: chapterId, manga_id: mangaId,
page_number: 5, chapter_number: 1, chapter_title: null, manga_title: 'Berserk',
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png`,
tagged_at: '2026-01-01T00:00:00Z'
}
],
page: { limit: 100, offset: 0, total: 1 }
})
});
});
await page.goto('/search?tag=funny');
// The chip cloud / active tag renders immediately (distinct resolved).
await expect(page.getByTestId('search-active-tag')).toBeVisible();
// Results skeleton while the tagged-pages query is pending.
await expect(page.getByTestId('search-results-skeleton')).toBeVisible();
await expect(page.getByTestId('search-pages-list')).toHaveCount(0);
release();
await expect(page.getByTestId('search-pages-list')).toContainText('Berserk');
await expect(page.getByTestId('search-results-skeleton')).toHaveCount(0);
});

View File

@@ -0,0 +1,96 @@
import { test, expect, type Page } from './fixtures';
// Streamed loaders (bookmarks / collections / library / search) capture a
// failed fetch into an in-band `error` field so the page renders an inline
// message instead of either the framework error page (network failure —
// a non-ApiError) or a misleading empty state (an HTTP error). These specs
// drive each distinct loader shape into that error path.
const collectionId = 'd2222222-2222-2222-2222-222222222222';
async function authed(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, body: '' }));
}
test('bookmarks: a network failure renders inline, not the framework error page', async ({ page }) => {
await authed(page);
// A raw connection failure surfaces as a non-ApiError (TypeError) from
// fetch — the case that previously escaped to the framework boundary.
await page.route('**/api/v1/me/bookmarks*', (r) => r.abort());
await page.goto('/bookmarks');
await expect(page.getByTestId('bookmarks-error')).toBeVisible();
});
test('library: a network failure on one of the aggregated fetches renders inline', async ({ page }) => {
await authed(page);
await page.route('**/api/v1/me/collections*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
);
await page.route('**/api/v1/me/read-progress*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 100, offset: 0, total: 0 } }) })
);
await page.route('**/api/v1/me/page-tags/distinct*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route('**/api/v1/me/page-tags?**', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 100, offset: 0, total: 0 } }) })
);
await page.route('**/api/v1/me/bookmarks*', (r) => r.abort());
await page.goto('/library');
await expect(page.getByTestId('library-error')).toBeVisible();
});
test('search: a failed results query shows an error, not a false "no matches"', async ({ page }) => {
await authed(page);
await page.route('**/api/v1/me/page-tags/distinct*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ tag: 'funny', count: 3 }] }) })
);
// The streamed tagged-pages query fails with a server error.
await page.route('**/api/v1/me/page-tags?**', (r) =>
r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { code: 'internal_error', message: 'boom' } }) })
);
await page.goto('/search?tag=funny');
// The chip cloud / active tag renders (distinct resolved) ...
await expect(page.getByTestId('search-active-tag')).toBeVisible();
// ... and the failed results render as an error, not "No pages tagged".
await expect(page.getByTestId('search-results-error')).toBeVisible();
await expect(page.getByTestId('search-pages-empty')).toHaveCount(0);
});
test('collection detail: a failed content load shows an inline error, not an empty collection', async ({ page }) => {
await authed(page);
await page.route(`**/api/v1/collections/${collectionId}`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: collectionId, name: 'Favourites', description: null, manga_count: 1 }) })
);
await page.route(`**/api/v1/collections/${collectionId}/pages*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/collections/${collectionId}/mangas*`, (r) =>
r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { code: 'internal_error', message: 'boom' } }) })
);
await page.goto(`/collections/${collectionId}`);
await expect(page.getByRole('heading', { name: 'Favourites' })).toBeVisible();
await expect(page.getByTestId('collection-content-error')).toBeVisible();
await expect(page.getByTestId('collection-empty')).toHaveCount(0);
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.109.1",
"version": "0.122.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -23,6 +23,7 @@
alt=""
class="cover"
loading="lazy"
decoding="async"
/>
{:else}
<div class="cover cover-placeholder">

View File

@@ -26,6 +26,7 @@
alt=""
class="collage-cover"
loading="lazy"
decoding="async"
/>
{/each}
{/if}

View File

@@ -51,6 +51,7 @@
alt=""
class="cover"
loading="lazy"
decoding="async"
/>
{:else}
<span class="cover cover-placeholder">

View File

@@ -89,6 +89,7 @@
alt=""
class="cover"
loading="lazy"
decoding="async"
/>
{:else}
<div class="cover cover-placeholder">

View File

@@ -29,6 +29,16 @@ describe('HistoryList', () => {
expect(cont.getAttribute('href')).toBe('/manga/m1/chapter/c9');
});
it('renders covers with lazy loading and async decode', () => {
render(HistoryList, {
props: { entries: [entry({ manga_cover_image_path: 'mangas/m1/cover.jpg' })] }
});
const img = document.querySelector('img.cover') as HTMLImageElement;
expect(img).not.toBeNull();
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.getAttribute('decoding')).toBe('async');
});
it('appends the page number to the continue line only past page 1', () => {
// The page suffix is a separate text node from "Continue Chapter N",
// so assert against the link's normalized text rather than getByText.

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import Skeleton from './Skeleton.svelte';
/**
* Placeholder for a cover + two-line row list (bookmarks, history, tagged
* pages). Reserves the list height so rows swap in without a shift.
*/
let {
count = 6,
coverWidth = '64px',
testid = 'list-row-skeleton'
}: { count?: number; coverWidth?: string; testid?: string } = $props();
</script>
<ul class="list" data-testid={testid} aria-hidden="true">
{#each Array(count) as _}
<li class="row" style="grid-template-columns: {coverWidth} 1fr;">
<Skeleton width={coverWidth} aspectRatio="2 / 3" radius="sm" />
<div class="meta">
<Skeleton variant="text" width="55%" radius="sm" />
<Skeleton variant="text" width="35%" radius="sm" />
</div>
</li>
{/each}
</ul>
<style>
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.row {
display: grid;
gap: var(--space-3);
align-items: center;
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
</style>

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import ListRowSkeleton from './ListRowSkeleton.svelte';
afterEach(() => cleanup());
describe('ListRowSkeleton', () => {
it('renders the requested number of rows', () => {
render(ListRowSkeleton, { props: { count: 5, testid: 'sk' } });
expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(5);
});
it('defaults to 6 rows and is decorative', () => {
render(ListRowSkeleton, { props: { testid: 'sk' } });
const el = screen.getByTestId('sk');
expect(el.querySelectorAll('.row').length).toBe(6);
expect(el.getAttribute('aria-hidden')).toBe('true');
});
it('applies the cover width to the row template', () => {
render(ListRowSkeleton, { props: { coverWidth: '80px', testid: 'sk' } });
const row = screen.getByTestId('sk').querySelector('.row') as HTMLElement;
expect(row.style.gridTemplateColumns).toBe('80px 1fr');
});
});

View File

@@ -52,6 +52,7 @@
alt=""
class="cover"
loading="lazy"
decoding="async"
/>
{:else}
<div class="cover cover-placeholder">
@@ -89,17 +90,8 @@
</li>
<style>
.manga-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
list-style: none;
/* Grid items default to min-width: auto which equals the
intrinsic content size — long author / title strings then
push the cell past `1fr`. Forcing 0 lets the column control
the width and the children ellipsize inside it. */
min-width: 0;
}
/* `.manga-card` layout lives in tokens.css so the catalog cards and the
loading skeleton share one definition. */
.cover-link {
display: block;

View File

@@ -49,6 +49,16 @@ describe('MangaCard badges & overlays', () => {
expect(screen.queryByTestId('cover-progress')).toBeNull();
});
it('renders the cover with lazy loading and async decode to keep grid paint smooth', () => {
render(MangaCard, {
props: { manga: { ...baseManga, cover_image_path: 'mangas/m1/cover.jpg' } }
});
const img = document.querySelector('img.cover') as HTMLImageElement;
expect(img).not.toBeNull();
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.getAttribute('decoding')).toBe('async');
});
it('renders the progress overlay clamped to 0..1 and reflects the value as a width style', () => {
const { rerender } = render(MangaCard, {
props: { manga: baseManga, progress: 0.4 }

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import Skeleton from './Skeleton.svelte';
/**
* Placeholder for the manga detail page while its data streams in. Mirrors
* the desktop overview (cover + meta column) and the chapter list so the
* page keeps its shape and swaps in without a big layout shift.
*/
let { chapters = 6 }: { chapters?: number } = $props();
</script>
<div data-testid="manga-detail-skeleton" aria-hidden="true">
<div class="overview">
<div class="cover">
<Skeleton aspectRatio="2 / 3" radius="md" />
</div>
<div class="meta">
<Skeleton variant="text" width="70%" height="1.6rem" radius="sm" />
<Skeleton variant="text" width="40%" radius="sm" />
<div class="chips">
{#each Array(4) as _}
<Skeleton width="72px" height="1.6rem" radius="pill" />
{/each}
</div>
<Skeleton variant="text" width="100%" radius="sm" />
<Skeleton variant="text" width="92%" radius="sm" />
<Skeleton variant="text" width="60%" radius="sm" />
</div>
</div>
<Skeleton variant="text" width="8rem" height="1.3rem" radius="sm" />
<ol class="chapter-list">
{#each Array(chapters) as _}
<li><Skeleton variant="text" width="100%" height="1.1rem" radius="sm" /></li>
{/each}
</ol>
</div>
<style>
.overview {
display: grid;
grid-template-columns: minmax(0, 200px) 1fr;
gap: var(--space-4);
align-items: start;
margin-bottom: var(--space-6);
}
/* Match the real overview: stack on phones, but keep the cover from
ballooning to full width. */
@media (max-width: 640px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
.cover {
max-width: 160px;
}
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chapter-list {
list-style: none;
padding: 0;
margin: var(--space-3) 0 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.chapter-list li {
padding: var(--space-2) 0;
border-top: 1px solid var(--border);
}
</style>

View File

@@ -0,0 +1,26 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import MangaDetailSkeleton from './MangaDetailSkeleton.svelte';
afterEach(() => cleanup());
describe('MangaDetailSkeleton', () => {
it('renders the overview + a default of 6 chapter-row placeholders', () => {
render(MangaDetailSkeleton);
const root = screen.getByTestId('manga-detail-skeleton');
expect(root.querySelector('.overview')).not.toBeNull();
expect(root.querySelectorAll('.chapter-list li').length).toBe(6);
});
it('honours the chapters prop', () => {
render(MangaDetailSkeleton, { props: { chapters: 3 } });
expect(
screen.getByTestId('manga-detail-skeleton').querySelectorAll('.chapter-list li').length
).toBe(3);
});
it('is decorative (aria-hidden)', () => {
render(MangaDetailSkeleton);
expect(screen.getByTestId('manga-detail-skeleton').getAttribute('aria-hidden')).toBe('true');
});
});

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import Skeleton from './Skeleton.svelte';
/**
* Placeholder for a manga cover grid while it loads. Reuses the global
* `.manga-grid` and `.manga-card` layout (both single-sourced in
* tokens.css) so it lines up with the real grid at every breakpoint and
* content swaps in without a layout shift. The cover reserves its 2/3 box
* via `aspect-ratio` (height stays auto), matching MangaCard's cover.
*/
let { count = 12 }: { count?: number } = $props();
</script>
<ul class="manga-grid" data-testid="manga-grid-skeleton" aria-hidden="true">
{#each Array(count) as _}
<li class="manga-card">
<Skeleton aspectRatio="2 / 3" radius="md" />
<Skeleton variant="text" width="80%" radius="sm" />
</li>
{/each}
</ul>

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import MangaGridSkeleton from './MangaGridSkeleton.svelte';
afterEach(() => cleanup());
describe('MangaGridSkeleton', () => {
it('renders 12 card skeletons by default', () => {
render(MangaGridSkeleton);
const root = screen.getByTestId('manga-grid-skeleton');
expect(root.querySelectorAll('.manga-card').length).toBe(12);
});
it('honours the count prop', () => {
render(MangaGridSkeleton, { props: { count: 4 } });
const root = screen.getByTestId('manga-grid-skeleton');
expect(root.querySelectorAll('.manga-card').length).toBe(4);
});
it('reuses the shared .manga-grid layout class', () => {
render(MangaGridSkeleton);
expect(screen.getByTestId('manga-grid-skeleton').classList.contains('manga-grid')).toBe(
true
);
});
it('is decorative so the container announces loading', () => {
render(MangaGridSkeleton);
expect(screen.getByTestId('manga-grid-skeleton').getAttribute('aria-hidden')).toBe('true');
});
it('reserves each cover as a 2/3 aspect box, not a collapsed zero-height one', () => {
render(MangaGridSkeleton, { props: { count: 1 } });
const card = screen
.getByTestId('manga-grid-skeleton')
.querySelector('.manga-card') as HTMLElement;
const cover = card.firstElementChild as HTMLElement;
expect(cover.style.aspectRatio).toBe('2 / 3');
// A definite height:0 would make aspect-ratio a no-op and collapse
// the cover — the exact layout-shift regression this guards.
expect(cover.style.height).toBe('');
});
});

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { navigating } from '$app/stores';
/**
* Thin top-of-viewport progress bar shown during client-side navigation.
* Most data routes run their `load` with `ssr = false`, so SvelteKit holds
* the previous page on screen while the next one's data resolves — with no
* feedback. This bar is that feedback: it trickles toward the right edge
* while a navigation is pending, snaps to full and fades out once it
* settles.
*
* Decorative (`aria-hidden`). Animates `transform`/`opacity` only (both
* compositor-friendly), and drops `will-change` when idle. Under
* prefers-reduced-motion the trickle keyframe is neutralized by the global
* override, leaving the `.loading` base transform as a static visible bar.
*/
// idle: hidden. loading: trickling. done: snap to full, then fade to idle.
let phase = $state<'idle' | 'loading' | 'done'>('idle');
// Bumped whenever a *new* navigation begins. Keying the element on it
// remounts the bar so the trickle animation restarts even when one
// navigation supersedes another (the store stays truthy the whole time,
// just swapping Navigation objects).
let run = $state(0);
// Plain locals (not $state) so reading them in the effect doesn't make the
// effect depend on its own writes. `runCount` mirrors `run` so we can bump
// it without reactively reading the `run` state.
let runCount = 0;
let wasNavigating = false;
let doneTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
const isNavigating = !!$navigating;
if (isNavigating) {
clearTimeout(doneTimer);
run = ++runCount;
phase = 'loading';
} else if (wasNavigating) {
phase = 'done';
doneTimer = setTimeout(() => {
phase = 'idle';
}, 260);
}
wasNavigating = isNavigating;
return () => clearTimeout(doneTimer);
});
</script>
{#key run}
<div
class="nav-progress"
class:loading={phase === 'loading'}
class:done={phase === 'done'}
data-testid="nav-progress"
data-phase={phase}
aria-hidden="true"
></div>
{/key}
<style>
.nav-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: var(--primary);
z-index: var(--z-nav-progress);
transform: scaleX(0);
transform-origin: left;
opacity: 0;
pointer-events: none;
}
.nav-progress.loading {
opacity: 1;
/* Base (non-animated) target — this is what reduced-motion users see,
and the keyframe's end state, so disabling the animation still
leaves a clearly visible bar. */
transform: scaleX(0.9);
animation: nav-progress-trickle 8s cubic-bezier(0.1, 0.7, 0.3, 1) forwards;
will-change: transform;
}
.nav-progress.done {
opacity: 0;
transform: scaleX(1);
/* Complete to full quickly, then fade out. */
transition:
transform 120ms ease-out,
opacity 200ms ease-out 120ms;
}
/* The reader hides all its own chrome in fullscreen; the progress bar
would be the one visible element left, so hide it too. */
:global(html[data-reader-fullscreen='true']) .nav-progress {
display: none;
}
@keyframes nav-progress-trickle {
0% {
transform: scaleX(0);
}
50% {
transform: scaleX(0.65);
}
100% {
transform: scaleX(0.9);
}
}
</style>

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { flushSync } from 'svelte';
import { render, screen, cleanup } from '@testing-library/svelte';
// A controllable stand-in for SvelteKit's `navigating` store: `null` when
// idle, a Navigation-like object while a client-side navigation is pending.
// Built via vi.hoisted (a minimal writable, so no import is needed before
// the hoisted vi.mock factory runs).
const { navigating } = vi.hoisted(() => {
let value: unknown = null;
const subs = new Set<(v: unknown) => void>();
return {
navigating: {
subscribe(fn: (v: unknown) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
set(v: unknown) {
value = v;
subs.forEach((fn) => fn(value));
}
}
};
});
vi.mock('$app/stores', () => ({ navigating }));
import NavProgress from './NavProgress.svelte';
const nav = (path: string) => ({ from: null, to: { url: new URL(`http://x${path}`) } });
afterEach(() => {
cleanup();
navigating.set(null);
});
describe('NavProgress', () => {
it('renders a decorative bar carrying the nav-progress testid', () => {
render(NavProgress);
const bar = screen.getByTestId('nav-progress');
expect(bar).toBeTruthy();
expect(bar.getAttribute('aria-hidden')).toBe('true');
});
it('is idle when not navigating', () => {
render(NavProgress);
const bar = screen.getByTestId('nav-progress');
expect(bar.dataset.phase).toBe('idle');
expect(bar.classList.contains('loading')).toBe(false);
});
it('enters the loading phase while a navigation is pending', () => {
render(NavProgress);
navigating.set(nav('/manga/1'));
flushSync();
const bar = screen.getByTestId('nav-progress');
expect(bar.dataset.phase).toBe('loading');
expect(bar.classList.contains('loading')).toBe(true);
});
it('snaps to done then returns to idle once navigation settles', () => {
vi.useFakeTimers();
try {
render(NavProgress);
navigating.set(nav('/manga/1'));
flushSync();
expect(screen.getByTestId('nav-progress').dataset.phase).toBe('loading');
navigating.set(null);
flushSync();
// Completes to full and fades, rather than snapping away instantly.
expect(screen.getByTestId('nav-progress').dataset.phase).toBe('done');
vi.advanceTimersByTime(300);
flushSync();
expect(screen.getByTestId('nav-progress').dataset.phase).toBe('idle');
} finally {
vi.useRealTimers();
}
});
it('restarts the bar when a new navigation supersedes a pending one', () => {
render(NavProgress);
navigating.set(nav('/manga/1'));
flushSync();
const first = screen.getByTestId('nav-progress');
// A second navigation begins before the first settles — the store
// stays truthy the whole time, just swapping objects.
navigating.set(nav('/manga/2'));
flushSync();
const second = screen.getByTestId('nav-progress');
expect(second.dataset.phase).toBe('loading');
// {#key run} remounts the element so the trickle animation replays
// rather than staying parked at its previous width.
expect(second).not.toBe(first);
});
});

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Skeleton from './Skeleton.svelte';
/**
* Placeholder for a tagged-page / chapter / manga results list while a
* search re-queries. Mirrors the TaggedPageRow shape (56px 2/3 cover +
* two text lines) so the list keeps its height as results swap in.
*/
let { count = 6, testid = 'search-results-skeleton' }: { count?: number; testid?: string } =
$props();
</script>
<ul class="list" data-testid={testid} aria-hidden="true">
{#each Array(count) as _}
<li class="row">
<Skeleton width="56px" aspectRatio="2 / 3" radius="sm" />
<div class="meta">
<Skeleton variant="text" width="60%" radius="sm" />
<Skeleton variant="text" width="40%" radius="sm" />
</div>
</li>
{/each}
</ul>
<style>
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-3);
align-items: center;
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
</style>

View File

@@ -0,0 +1,22 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import SearchResultsSkeleton from './SearchResultsSkeleton.svelte';
afterEach(() => cleanup());
describe('SearchResultsSkeleton', () => {
it('renders the requested number of row placeholders', () => {
render(SearchResultsSkeleton, { props: { count: 3, testid: 'sk' } });
expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(3);
});
it('defaults to 6 rows', () => {
render(SearchResultsSkeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(6);
});
it('is decorative (aria-hidden)', () => {
render(SearchResultsSkeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
});
});

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import Skeleton from './Skeleton.svelte';
/**
* Placeholder for a horizontal home shelf (Continue reading / Recommended)
* while it loads. Reserves the shelf's vertical space so the catalog below
* doesn't get shoved down when the real shelf pops in after its late fetch.
* Dimensions mirror ContinueReadingShelf's 108×162 cards.
*/
let { count = 6, testid = 'shelf-skeleton' }: { count?: number; testid?: string } = $props();
</script>
<section class="shelf" data-testid={testid} aria-hidden="true">
<Skeleton variant="text" width="10rem" height="1.1rem" radius="sm" />
<ul class="track">
{#each Array(count) as _}
<li class="card">
<Skeleton width="108px" height="162px" radius="sm" />
<Skeleton variant="text" width="80%" radius="sm" />
</li>
{/each}
</ul>
</section>
<style>
.shelf {
margin: 0 0 var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.track {
list-style: none;
display: flex;
gap: var(--space-3);
padding: 0 0 var(--space-2);
margin: 0;
overflow: hidden;
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
width: 108px;
flex: 0 0 auto;
}
</style>

View File

@@ -0,0 +1,22 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import ShelfSkeleton from './ShelfSkeleton.svelte';
afterEach(() => cleanup());
describe('ShelfSkeleton', () => {
it('renders the requested number of card placeholders', () => {
render(ShelfSkeleton, { props: { count: 4, testid: 'sk' } });
expect(screen.getByTestId('sk').querySelectorAll('.card').length).toBe(4);
});
it('defaults to 6 cards', () => {
render(ShelfSkeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').querySelectorAll('.card').length).toBe(6);
});
it('is decorative (aria-hidden)', () => {
render(ShelfSkeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
});
});

View File

@@ -0,0 +1,93 @@
<script lang="ts">
/**
* Decorative loading placeholder. Renders a muted block with a subtle
* shimmer that sweeps across it. Purely visual — `aria-hidden` keeps it
* out of the accessibility tree; the loading state should be announced by
* the container (e.g. `role="status"`).
*
* Under prefers-reduced-motion the shimmer gradient/animation is dropped
* entirely, leaving a flat `--surface-elevated` block.
*/
let {
width = '100%',
height,
aspectRatio,
radius = 'md',
variant = 'block',
testid
}: {
width?: string;
height?: string;
/**
* `aspect-ratio` for the box (e.g. `'2 / 3'`). Lets a fluid-width
* placeholder reserve its shape without a hard-coded height — the
* height stays `auto` so the ratio actually applies.
*/
aspectRatio?: string;
/** Corner radius, mapped to the design-token scale. */
radius?: 'sm' | 'md' | 'lg' | 'pill';
/** `circle` forces a pill radius (for avatars / dots). */
variant?: 'block' | 'text' | 'circle';
testid?: string;
} = $props();
const radiusClass = $derived(variant === 'circle' ? 'radius-pill' : `radius-${radius}`);
// A text line gets a sensible default height so callers don't have to; an
// explicit `height` still wins. Resolving it here (rather than in CSS)
// keeps a single source of truth for the box height and avoids an inline
// style silently shadowing a stylesheet rule.
const resolvedHeight = $derived(height ?? (variant === 'text' ? '0.9em' : undefined));
const style = $derived(
`width: ${width};` +
(resolvedHeight ? ` height: ${resolvedHeight};` : '') +
(aspectRatio ? ` aspect-ratio: ${aspectRatio};` : '')
);
</script>
<span class="skeleton {radiusClass}" data-testid={testid} aria-hidden="true" {style}></span>
<style>
.skeleton {
display: block;
/* Flat base — this is the whole appearance under reduced motion. */
background-color: var(--surface-elevated);
}
@media (prefers-reduced-motion: no-preference) {
.skeleton {
background-image: linear-gradient(
90deg,
var(--surface) 0%,
var(--surface-elevated) 50%,
var(--surface) 100%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: skeleton-shimmer 1.2s ease-in-out infinite;
}
}
.radius-sm {
border-radius: var(--radius-sm);
}
.radius-md {
border-radius: var(--radius-md);
}
.radius-lg {
border-radius: var(--radius-lg);
}
.radius-pill {
border-radius: var(--radius-pill);
}
@keyframes skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import Skeleton from './Skeleton.svelte';
afterEach(() => cleanup());
describe('Skeleton primitive', () => {
it('renders an element carrying the given testid', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk')).toBeTruthy();
});
it('is decorative (aria-hidden) so screen readers ignore it', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
});
it('applies width and height as inline styles', () => {
render(Skeleton, { props: { testid: 'sk', width: '120px', height: '2rem' } });
const el = screen.getByTestId('sk') as HTMLElement;
expect(el.style.width).toBe('120px');
expect(el.style.height).toBe('2rem');
});
it('defaults width to 100% when omitted', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect((screen.getByTestId('sk') as HTMLElement).style.width).toBe('100%');
});
it('maps the radius prop to a radius class', () => {
render(Skeleton, { props: { testid: 'sk', radius: 'lg' } });
expect(screen.getByTestId('sk').classList.contains('radius-lg')).toBe(true);
});
it('defaults to the md radius', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').classList.contains('radius-md')).toBe(true);
});
it('renders a pill radius for the circle variant', () => {
render(Skeleton, { props: { testid: 'sk', variant: 'circle' } });
expect(screen.getByTestId('sk').classList.contains('radius-pill')).toBe(true);
});
it('carries the skeleton base class for the shimmer', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').classList.contains('skeleton')).toBe(true);
});
it('emits aspect-ratio inline (and no fixed height) so a fluid box keeps its shape', () => {
render(Skeleton, { props: { testid: 'sk', aspectRatio: '2 / 3' } });
const el = screen.getByTestId('sk') as HTMLElement;
expect(el.style.aspectRatio).toBe('2 / 3');
// A definite height would defeat aspect-ratio and collapse the box.
expect(el.style.height).toBe('');
});
it('gives a text-variant line a default height without a caller-supplied one', () => {
render(Skeleton, { props: { testid: 'sk', variant: 'text' } });
expect((screen.getByTestId('sk') as HTMLElement).style.height).toBe('0.9em');
});
it('lets an explicit height override the text-variant default', () => {
render(Skeleton, { props: { testid: 'sk', variant: 'text', height: '1rem' } });
expect((screen.getByTestId('sk') as HTMLElement).style.height).toBe('1rem');
});
});

View File

@@ -27,7 +27,7 @@
<li class="row" data-testid={testid}>
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
{#if primaryCover}
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" decoding="async" />
{:else}
<div class="cover cover-placeholder"></div>
{/if}
@@ -46,7 +46,7 @@
{#if item.sample_storage_keys.length > 1}
<div class="samples">
{#each item.sample_storage_keys.slice(1) as key (key)}
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" />
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" decoding="async" />
{/each}
</div>
{/if}

View File

@@ -36,7 +36,7 @@
<li class="row" data-testid={testid}>
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
{#if primaryCover}
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" decoding="async" />
{:else}
<div class="cover cover-placeholder"></div>
{/if}
@@ -50,7 +50,7 @@
{#if sampleStrip.length > 0}
<div class="samples">
{#each sampleStrip as key (key)}
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" />
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" decoding="async" />
{/each}
</div>
{/if}

View File

@@ -35,6 +35,7 @@
alt=""
class="cover"
loading="lazy"
decoding="async"
/>
</a>
<div class="meta">

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { toast } from '$lib/toast.svelte';
import X from '@lucide/svelte/icons/x';
// Errors get an assertive `alert` role so screen readers interrupt;
// success/info use the polite `status` role.
const roleFor = (kind: string) => (kind === 'error' ? 'alert' : 'status');
</script>
<div class="toaster" data-testid="toaster">
{#each toast.toasts as t (t.id)}
<div class="toast {t.kind}" role={roleFor(t.kind)} data-testid="toast" data-kind={t.kind}>
<span class="message">{t.message}</span>
<button
type="button"
class="dismiss"
aria-label="Dismiss notification"
onclick={() => toast.dismiss(t.id)}
>
<X size={16} aria-hidden="true" />
</button>
</div>
{/each}
</div>
<style>
.toaster {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--safe-bottom) + var(--space-4));
z-index: var(--z-toast);
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4);
/* Let clicks fall through the gaps; individual toasts opt back in. */
pointer-events: none;
}
/* Sit above the mobile bottom nav so a toast never hides behind it. */
@media (max-width: 640px) {
.toaster {
bottom: calc(var(--app-bottom-nav-h) + var(--safe-bottom) + var(--space-3));
}
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
max-width: 30rem;
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4);
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: var(--radius-md);
background: var(--surface-elevated);
color: var(--text);
box-shadow: var(--shadow-md);
font-size: var(--font-sm);
}
.toast.error {
border-color: var(--danger);
background: var(--danger-soft-bg);
}
.toast.success {
border-color: var(--success);
background: var(--success-soft-bg);
}
.toast.info {
border-color: var(--primary);
}
.message {
flex: 1;
min-width: 0;
}
.dismiss {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.dismiss:hover {
background: rgb(0 0 0 / 0.06);
color: var(--text);
}
</style>

View File

@@ -0,0 +1,38 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import Toaster from './Toaster.svelte';
import { toast } from '$lib/toast.svelte';
afterEach(() => {
cleanup();
toast.clear();
});
describe('Toaster', () => {
it('renders queued toasts with their message', async () => {
toast.info('Saved');
render(Toaster);
expect(await screen.findByText('Saved')).toBeTruthy();
});
it('gives errors an assertive alert role and others a status role', () => {
toast.error('Boom');
toast.success('Yay');
render(Toaster);
const toasts = screen.getAllByTestId('toast');
const error = toasts.find((t) => t.dataset.kind === 'error')!;
const success = toasts.find((t) => t.dataset.kind === 'success')!;
expect(error.getAttribute('role')).toBe('alert');
expect(success.getAttribute('role')).toBe('status');
});
it('removes a toast when its dismiss button is clicked', async () => {
toast.error('Dismiss me');
render(Toaster);
const btn = screen.getByLabelText('Dismiss notification');
btn.click();
await Promise.resolve();
expect(screen.queryByText('Dismiss me')).toBeNull();
expect(toast.toasts).toHaveLength(0);
});
});

View File

@@ -16,6 +16,7 @@
--danger: #b00020;
--danger-soft-bg: #fff5f5;
--success: #0a7d2c;
--success-soft-bg: #eaf7ee;
--warning-soft-bg: #fff5d6;
--warning-border: #d6a800;
--focus-ring: #2563eb;
@@ -28,6 +29,9 @@
--font-xs: 0.75rem;
--font-sm: 0.875rem;
/* Alias of --font-base at the body size; named on the xs/sm/md/lg/xl
scale that some components reach for. */
--font-md: 1rem;
--font-base: 1rem;
--font-lg: 1.125rem;
--font-xl: 1.5rem;
@@ -95,6 +99,9 @@
--z-dropdown: 10;
--z-sticky: 50;
/* Above the fixed header/app-bar (--z-sticky) so the nav progress bar
is never occluded, but below modals and toasts. */
--z-nav-progress: 60;
--z-modal: 100;
--z-toast: 1000;
}
@@ -114,6 +121,7 @@
--danger: #f87171;
--danger-soft-bg: #3a1620;
--success: #4ade80;
--success-soft-bg: #12301c;
--warning-soft-bg: #3a2e10;
--warning-border: #a37800;
--focus-ring: #60a5fa;
@@ -341,6 +349,20 @@ img {
gap: var(--space-4);
}
/* A single cover cell — the catalog/author/collection cards and the
loading skeleton share this so their layout can't drift. */
.manga-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
list-style: none;
/* Grid items default to min-width: auto which equals the intrinsic
content size — long author / title strings then push the cell past
`1fr`. Forcing 0 lets the column control the width and the children
ellipsize inside it. */
min-width: 0;
}
@media (max-width: 640px) {
.manga-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// Guards against components referencing design tokens that were never
// declared. `var(--x)` with no declaration silently falls through to the
// inherited/initial value, so a typo'd or missing token degrades quietly
// rather than failing loudly. These two were referenced by components
// (RecommendationShelf, the manga detail page) but absent from tokens.css.
const TOKENS = readFileSync(resolve(process.cwd(), 'src/lib/styles/tokens.css'), 'utf8');
// Every design token must be declared at least once (in :root and/or a theme
// override). We only assert declaration, not value.
function declares(name: string): boolean {
return new RegExp(`${name}\\s*:`).test(TOKENS);
}
describe('design tokens', () => {
it('declares --font-md (referenced by RecommendationShelf)', () => {
expect(declares('--font-md')).toBe(true);
});
it('declares --success-soft-bg (referenced by the manga detail page)', () => {
expect(declares('--success-soft-bg')).toBe(true);
});
it('declares --success-soft-bg in the dark theme override too', () => {
const darkBlock = TOKENS.slice(TOKENS.indexOf("[data-theme='dark']"));
expect(/--success-soft-bg\s*:/.test(darkBlock)).toBe(true);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { toast } from './toast.svelte';
afterEach(() => {
toast.clear();
vi.useRealTimers();
});
describe('toast store', () => {
it('shows a toast with the given message and kind, returning its id', () => {
const id = toast.show('hello', 'info');
expect(toast.toasts).toHaveLength(1);
expect(toast.toasts[0]).toMatchObject({ id, message: 'hello', kind: 'info' });
});
it('stacks multiple toasts in insertion order with distinct ids', () => {
const a = toast.info('first');
const b = toast.error('second');
expect(toast.toasts.map((t) => t.message)).toEqual(['first', 'second']);
expect(a).not.toBe(b);
});
it('exposes kind helpers', () => {
toast.error('boom');
toast.success('yay');
expect(toast.toasts.map((t) => t.kind)).toEqual(['error', 'success']);
});
it('dismisses a toast by id', () => {
const id = toast.info('bye');
toast.dismiss(id);
expect(toast.toasts).toHaveLength(0);
});
it('auto-dismisses after the timeout', () => {
vi.useFakeTimers();
toast.show('temp', 'info', 3000);
expect(toast.toasts).toHaveLength(1);
vi.advanceTimersByTime(3000);
expect(toast.toasts).toHaveLength(0);
});
it('does not auto-dismiss when the timeout is 0 (sticky)', () => {
vi.useFakeTimers();
toast.show('sticky', 'error', 0);
vi.advanceTimersByTime(60_000);
expect(toast.toasts).toHaveLength(1);
});
it('clear() removes everything and cancels pending timers', () => {
vi.useFakeTimers();
toast.info('a');
toast.info('b');
toast.clear();
expect(toast.toasts).toHaveLength(0);
// A previously-scheduled auto-dismiss must not fire against a new toast.
const id = toast.info('c', 0);
vi.advanceTimersByTime(60_000);
expect(toast.toasts).toEqual([{ id, message: 'c', kind: 'info' }]);
});
});

View File

@@ -0,0 +1,64 @@
// App-wide transient notifications (toasts).
//
// A single client-side store; the <Toaster /> in the root layout renders it.
// Mutated only from the browser (event handlers / catch blocks), so the
// module-level singleton can't leak across SSR requests — SSR renders an
// empty list and the client takes over after hydration.
export type ToastKind = 'info' | 'success' | 'error';
export type Toast = { id: number; kind: ToastKind; message: string };
const DEFAULT_TIMEOUT_MS = 5000;
// Errors linger longer — the user may need to read what failed.
const ERROR_TIMEOUT_MS = 8000;
class ToastStore {
toasts = $state<Toast[]>([]);
private nextId = 1;
private timers = new Map<number, ReturnType<typeof setTimeout>>();
/**
* Queue a toast. `timeoutMs = 0` makes it sticky (dismiss only via the
* close button / `dismiss`). Returns the id so callers can dismiss early.
*/
show(message: string, kind: ToastKind = 'info', timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
const id = this.nextId++;
this.toasts = [...this.toasts, { id, kind, message }];
if (timeoutMs > 0) {
this.timers.set(
id,
setTimeout(() => this.dismiss(id), timeoutMs)
);
}
return id;
}
error(message: string, timeoutMs: number = ERROR_TIMEOUT_MS): number {
return this.show(message, 'error', timeoutMs);
}
success(message: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
return this.show(message, 'success', timeoutMs);
}
info(message: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
return this.show(message, 'info', timeoutMs);
}
dismiss(id: number): void {
const timer = this.timers.get(id);
if (timer) {
clearTimeout(timer);
this.timers.delete(id);
}
this.toasts = this.toasts.filter((t) => t.id !== id);
}
clear(): void {
this.timers.forEach((t) => clearTimeout(t));
this.timers.clear();
this.toasts = [];
}
}
export const toast = new ToastStore();

View File

@@ -9,6 +9,8 @@
import { theme } from '$lib/theme.svelte';
import AppBar from '$lib/components/AppBar.svelte';
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
import NavProgress from '$lib/components/NavProgress.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import Upload from '@lucide/svelte/icons/upload';
import UserCircle from '@lucide/svelte/icons/user-circle';
@@ -198,6 +200,8 @@
<title>{layoutTitle}</title>
</svelte:head>
<NavProgress />
{#if showMobileChrome}
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
<AppBar title="Mangalord" testid="mobile-app-bar" />
@@ -268,6 +272,8 @@
</div>
{/if}
<Toaster />
<style>
header {
padding: var(--space-3) var(--space-4);

View File

@@ -31,7 +31,10 @@
import Chip from '$lib/components/Chip.svelte';
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
import RecommendationShelf from '$lib/components/RecommendationShelf.svelte';
import ShelfSkeleton from '$lib/components/ShelfSkeleton.svelte';
import { session } from '$lib/session.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
import Pager from '$lib/components/Pager.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import Sheet from '$lib/components/Sheet.svelte';
@@ -47,6 +50,10 @@
let mangas: MangaCardData[] = $state([]);
let continueEntries = $state<ReadProgressSummary[]>([]);
let recommendations = $state<MangaCardData[]>([]);
// True while the personal shelves are being fetched. Drives a reserved
// ShelfSkeleton (for logged-in users) so the catalog below isn't shoved
// down when the shelves resolve.
let shelvesLoading = $state(false);
let search = $state('');
let sort = $state<MangaSort>(DEFAULT_SORT);
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
@@ -310,26 +317,31 @@
// Filter UI still loads with an empty genre list rather than blocking.
}
await hydrateFromUrl();
// Fetch the personal shelves concurrently with the catalogue (rather
// than after it) so their reserved skeleton and the grid resolve
// together instead of the shelves popping in late and shoving the grid
// down. The `/me/*` calls are isolated: a failure (or 401 for guests)
// hides its own shelf via the *OrEmpty wrappers without touching the
// catalogue or the other shelf.
shelvesLoading = true;
const shelves = Promise.allSettled([listMyReadProgressOrEmpty(), listMyRecommendations()])
.then(([progressResult, recsResult]) => {
if (progressResult.status === 'fulfilled') {
// Drop finished series (read to the end, nothing new) — a
// "Continue reading" shelf is for what's still in progress.
continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
}
if (recsResult.status === 'fulfilled') {
// Personal "Recommended for you" feed (empty for guests / no
// taste signals yet → shelf hidden).
recommendations = recsResult.value;
}
})
.finally(() => {
shelvesLoading = false;
});
await load();
// Fetch the personal shelves after the catalogue so the public browse
// path stays unauthenticated and unblocked. Both are independent
// `/me/*` calls, so fire them concurrently rather than in series.
// Each is isolated: a failure (or 401 for guests) hides its own shelf
// without touching the catalogue or the other shelf.
const [progressResult, recsResult] = await Promise.allSettled([
listMyReadProgressOrEmpty(),
listMyRecommendations()
]);
if (progressResult.status === 'fulfilled') {
// Drop finished series (read to the end, nothing new) — a
// "Continue reading" shelf is for what's still in progress.
continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
}
if (recsResult.status === 'fulfilled') {
// Personal "Recommended for you" feed (empty for guests / no
// taste signals yet → shelf hidden).
recommendations = recsResult.value;
}
await shelves;
});
// Track viewport in a separate $effect so the listener cleans up on
@@ -484,12 +496,18 @@
</a>
</div>
{#if continueEntries.length > 0}
<ContinueReadingShelf entries={continueEntries} />
{/if}
{#if shelvesLoading && session.user}
<!-- Reserve the shelf area for logged-in users so the grid below doesn't
jump when the real shelves resolve. -->
<ShelfSkeleton testid="shelf-skeleton" />
{:else}
{#if continueEntries.length > 0}
<ContinueReadingShelf entries={continueEntries} />
{/if}
{#if recommendations.length > 0}
<RecommendationShelf mangas={recommendations} />
{#if recommendations.length > 0}
<RecommendationShelf mangas={recommendations} />
{/if}
{/if}
<form
@@ -659,7 +677,10 @@
</Sheet>
{#if loading}
<p class="status" data-testid="loading">Loading…</p>
<div data-testid="loading" role="status">
<span class="visually-hidden">Loading manga…</span>
<MangaGridSkeleton />
</div>
{:else if error}
<p class="error" data-testid="error" role="alert">{error}</p>
{:else if mangas.length === 0}
@@ -972,6 +993,18 @@
color: var(--text-muted);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.error {
color: var(--danger);
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import MangaCard from '$lib/components/MangaCard.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
import Pager from '$lib/components/Pager.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { goto } from '$app/navigation';
@@ -7,15 +8,8 @@
let { data } = $props();
const author = $derived(data.author);
const mangas = $derived(data.mangas);
const total = $derived(data.total);
const currentPage = $derived(data.currentPage);
const pageSize = $derived(data.pageSize);
const totalPages = $derived(
total != null && total > 0 ? Math.ceil(total / pageSize) : 1
);
const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1);
const rangeEnd = $derived((currentPage - 1) * pageSize + mangas.length);
function goToPage(p: number) {
if (p === currentPage) return;
@@ -45,28 +39,36 @@
</p>
</header>
{#if mangas.length === 0}
<p class="status" data-testid="author-no-mangas">
No mangas attributed to this author.
</p>
{:else}
{#if total != null}
<p class="meta" data-testid="author-shown-of-total">
Showing {rangeStart}{rangeEnd} of {total}
{#await data.mangas}
<MangaGridSkeleton />
{:then result}
{@const mangas = result.items}
{@const total = result.total}
{@const totalPages = total != null && total > 0 ? Math.ceil(total / pageSize) : 1}
{@const rangeStart = mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1}
{@const rangeEnd = (currentPage - 1) * pageSize + mangas.length}
{#if mangas.length === 0}
<p class="status" data-testid="author-no-mangas">
No mangas attributed to this author.
</p>
{:else}
{#if total != null}
<p class="meta" data-testid="author-shown-of-total">
Showing {rangeStart}{rangeEnd} of {total}
</p>
{/if}
<ul class="manga-grid" data-testid="author-manga-list">
{#each mangas as m (m.id)}
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
{/each}
</ul>
<Pager page={currentPage} {totalPages} onChange={goToPage} testid="author-pager" />
{/if}
<ul class="manga-grid" data-testid="author-manga-list">
{#each mangas as m (m.id)}
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
{/each}
</ul>
<Pager
page={currentPage}
{totalPages}
onChange={goToPage}
testid="author-pager"
/>
{/if}
{:catch}
<p class="status" role="alert" data-testid="author-error">
Could not load this author's mangas.
</p>
{/await}
<style>
.back {

View File

@@ -11,28 +11,27 @@ export const load: PageLoad = async ({ params, url }) => {
const pageParam = Number(url.searchParams.get('page') ?? '1');
const currentPage =
Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
let author;
try {
const [author, mangas] = await Promise.all([
getAuthor(params.id),
listAuthorMangas(params.id, {
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
})
]);
return {
author,
mangas: mangas.items,
total: mangas.page.total,
currentPage,
pageSize: PAGE_SIZE
};
// Awaited so a 404 surfaces as a real SvelteKit error (the framework
// not-found page) rather than the happy-path markup with undefined
// data. The author header is cheap, so blocking on it is fine.
author = await getAuthor(params.id);
} catch (e) {
// 404 surfaces as a real SvelteKit error so the framework shell
// renders the standard not-found page instead of the route's
// happy-path markup with undefined data.
if (e instanceof ApiError && e.status === 404) {
error(404, 'Author not found');
}
throw e;
}
return {
author,
// Streamed (not awaited) so the grid shows a MangaGridSkeleton while it
// loads instead of the whole page blocking on it.
mangas: listAuthorMangas(params.id, {
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
}).then((m) => ({ items: m.items, total: m.page.total })),
currentPage,
pageSize: PAGE_SIZE
};
};

View File

@@ -1,27 +1,29 @@
<script lang="ts">
import BookmarkList from '$lib/components/BookmarkList.svelte';
import ListRowSkeleton from '$lib/components/ListRowSkeleton.svelte';
let { data } = $props();
const authenticated = $derived(data.authenticated);
const bookmarks = $derived(data.bookmarks);
const error = $derived(data.error);
</script>
<h1>Bookmarks</h1>
{#if error}
<p class="error" role="alert" data-testid="bookmarks-error">
Couldn't load bookmarks: {error}
</p>
{:else if !authenticated}
<p class="hint" data-testid="bookmarks-signin">
<a href="/login">Sign in</a> to see your bookmarks.
</p>
{:else if bookmarks.length === 0}
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
{:else}
<BookmarkList {bookmarks} />
{/if}
{#await data.result}
<ListRowSkeleton />
{:then r}
{#if r.error}
<p class="error" role="alert" data-testid="bookmarks-error">
Couldn't load bookmarks: {r.error}
</p>
{:else if !r.authenticated}
<p class="hint" data-testid="bookmarks-signin">
<a href="/login">Sign in</a> to see your bookmarks.
</p>
{:else if r.bookmarks.length === 0}
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
{:else}
<BookmarkList bookmarks={r.bookmarks} />
{/if}
{/await}
<style>
.error {

View File

@@ -5,21 +5,26 @@ import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async () => {
try {
const page = await listMyBookmarks();
return { bookmarks: page.items, authenticated: true, error: null };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { bookmarks: [], authenticated: false, error: null };
}
// Anything else (502 upstream_unavailable from a backend
// restart, 500 internal_error) is rendered inline rather than
// re-thrown — SvelteKit's generic error.html is not the right
// UX for a transient API blip and the user is already
// authenticated as far as we know.
if (e instanceof ApiError) {
return { bookmarks: [], authenticated: true, error: e.message };
}
throw e;
}
// Streamed (the load itself doesn't await) so the list shows a skeleton
// while the single fetch — which is also the auth gate — is in flight.
return {
result: (async () => {
try {
const page = await listMyBookmarks();
return { bookmarks: page.items, authenticated: true, error: null as string | null };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { bookmarks: [], authenticated: false, error: null as string | null };
}
// Anything else — an HTTP error (502 upstream_unavailable from
// a backend restart, 500 internal_error) or a raw network
// failure (a non-ApiError TypeError) — renders inline rather
// than re-thrown: SvelteKit's generic error.html is not the
// right UX for a transient API blip and the user is already
// authenticated as far as we know.
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
return { bookmarks: [], authenticated: true, error: message };
}
})()
};
};

View File

@@ -1,26 +1,30 @@
<script lang="ts">
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
let { data } = $props();
const collections = $derived(data.collections);
</script>
<h1>Collections</h1>
{#if !data.authenticated}
<p class="status">
<a href="/login">Sign in</a> to see and manage your collections.
</p>
{:else if data.error}
<p class="error" role="alert">{data.error}</p>
{:else if collections.length === 0}
<p class="status" data-testid="collections-empty">
You don't have any collections yet. Open any manga and use
<strong>Add to collection</strong> to start one.
</p>
{:else}
<CollectionsGrid {collections} />
{/if}
{#await data.result}
<MangaGridSkeleton count={6} />
{:then r}
{#if !r.authenticated}
<p class="status">
<a href="/login">Sign in</a> to see and manage your collections.
</p>
{:else if r.error}
<p class="error" role="alert">{r.error}</p>
{:else if r.collections.length === 0}
<p class="status" data-testid="collections-empty">
You don't have any collections yet. Open any manga and use
<strong>Add to collection</strong> to start one.
</p>
{:else}
<CollectionsGrid collections={r.collections} />
{/if}
{/await}
<style>
.status {

View File

@@ -5,16 +5,23 @@ import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async () => {
try {
const page = await listMyCollections({ limit: 200 });
return { collections: page.items, authenticated: true, error: null };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { collections: [], authenticated: false, error: null };
}
if (e instanceof ApiError) {
return { collections: [], authenticated: true, error: e.message };
}
throw e;
}
// Streamed so the grid shows a skeleton while the single fetch (also the
// auth gate) is in flight.
return {
result: (async () => {
try {
const page = await listMyCollections({ limit: 200 });
return { collections: page.items, authenticated: true, error: null as string | null };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { collections: [], authenticated: false, error: null as string | null };
}
// An HTTP error or a raw network failure (a non-ApiError
// TypeError) renders inline rather than escaping to the
// framework error page for a transient API blip.
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
return { collections: [], authenticated: true, error: message };
}
})()
};
};

View File

@@ -12,6 +12,7 @@
import type { Manga } from '$lib/api/client';
import { fileUrl } from '$lib/api/client';
import MangaCard from '$lib/components/MangaCard.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
@@ -22,10 +23,39 @@
let { data } = $props();
// svelte-ignore state_referenced_locally
let collection = $state({ ...data.collection });
// svelte-ignore state_referenced_locally
let mangas = $state<Manga[]>([...data.mangas]);
// svelte-ignore state_referenced_locally
let pages = $state<CollectionPageItem[]>([...data.pages]);
// The manga/page lists are streamed and held as local mutable state so
// removals can be optimistic. They're seeded once the stream resolves; a
// skeleton shows until then.
let mangas = $state<Manga[]>([]);
let pages = $state<CollectionPageItem[]>([]);
let contentLoading = $state(true);
// Set when the streamed content fails to load, so we surface an inline
// error instead of a misleading "this collection is empty".
let contentError = $state<string | null>(null);
// Seed (and re-seed on navigation) from the streamed content. A monotonic
// guard drops a stale resolution that lands after a newer navigation.
let contentSeq = 0;
$effect(() => {
const content = data.content;
const seq = ++contentSeq;
contentLoading = true;
contentError = null;
content
.then((r) => {
if (seq !== contentSeq) return;
mangas = r.mangas;
pages = r.pages;
contentLoading = false;
})
.catch((e) => {
if (seq !== contentSeq) return;
mangas = [];
pages = [];
contentError = e instanceof Error ? e.message : 'Something went wrong.';
contentLoading = false;
});
});
let editing = $state(false);
let editName = $state('');
@@ -178,13 +208,19 @@
{/if}
</header>
{#if mangas.length === 0 && pages.length === 0}
{#if contentLoading}
<MangaGridSkeleton count={6} />
{:else if contentError}
<p class="error" role="alert" data-testid="collection-content-error">
Couldn't load this collection's contents: {contentError}
</p>
{:else if mangas.length === 0 && pages.length === 0}
<p class="status" data-testid="collection-empty">
This collection is empty.
</p>
{/if}
{#if mangas.length > 0}
{#if !contentLoading && mangas.length > 0}
<section aria-labelledby="mangas-heading">
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
<ul class="manga-grid" data-testid="collection-manga-list">
@@ -207,7 +243,7 @@
</section>
{/if}
{#if pages.length > 0}
{#if !contentLoading && pages.length > 0}
<section aria-labelledby="pages-heading">
<h2 id="pages-heading" class="section-heading">Pages</h2>
<ul class="page-grid" data-testid="collection-page-list">

View File

@@ -10,18 +10,11 @@ import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params, url }) => {
let collection;
try {
const [collection, mangas, pages] = await Promise.all([
getCollection(params.id),
listCollectionMangas(params.id, { limit: 200 }),
listCollectionPages(params.id, { limit: 200 })
]);
return {
collection,
mangas: mangas.items,
total: mangas.page.total,
pages: pages.items
};
// Awaited so the header renders immediately and 401/404 are handled by
// the framework (redirect / not-found) rather than the happy path.
collection = await getCollection(params.id);
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 401) {
@@ -34,4 +27,13 @@ export const load: PageLoad = async ({ params, url }) => {
}
throw e;
}
return {
collection,
// Streamed so the manga/page grids show a skeleton while they load
// instead of the whole page blocking on them.
content: Promise.all([
listCollectionMangas(params.id, { limit: 200 }),
listCollectionPages(params.id, { limit: 200 })
]).then(([mangas, pages]) => ({ mangas: mangas.items, pages: pages.items }))
};
};

View File

@@ -7,6 +7,8 @@
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
import PageTagsList from '$lib/components/PageTagsList.svelte';
import HistoryList from '$lib/components/HistoryList.svelte';
import ListRowSkeleton from '$lib/components/ListRowSkeleton.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
let { data } = $props();
@@ -66,37 +68,42 @@
/>
</div>
{#if !data.authenticated}
<p class="hint" data-testid="library-signin">
<a href="/login?next=/library">Sign in</a> to see your library.
</p>
{:else if data.error}
<p class="error" role="alert" data-testid="library-error">
Couldn't load library: {data.error}
</p>
{:else if activeTab === 'bookmarks'}
{#if data.bookmarks.length === 0}
<p class="hint" data-testid="library-bookmarks-empty">No bookmarks yet.</p>
{#await data.result}
{#if activeTab === 'collections'}
<MangaGridSkeleton count={6} />
{:else}
<BookmarkList bookmarks={data.bookmarks} testid="library-bookmark-list" />
<ListRowSkeleton />
{/if}
{:else if activeTab === 'collections'}
{#if data.collections.length === 0}
<p class="hint" data-testid="library-collections-empty">
You don't have any collections yet. Open any manga and use
<strong>Add to collection</strong> to start one.
{:then r}
{#if !r.authenticated}
<p class="hint" data-testid="library-signin">
<a href="/login?next=/library">Sign in</a> to see your library.
</p>
{:else if r.error}
<p class="error" role="alert" data-testid="library-error">
Couldn't load library: {r.error}
</p>
{:else if activeTab === 'bookmarks'}
{#if r.bookmarks.length === 0}
<p class="hint" data-testid="library-bookmarks-empty">No bookmarks yet.</p>
{:else}
<BookmarkList bookmarks={r.bookmarks} testid="library-bookmark-list" />
{/if}
{:else if activeTab === 'collections'}
{#if r.collections.length === 0}
<p class="hint" data-testid="library-collections-empty">
You don't have any collections yet. Open any manga and use
<strong>Add to collection</strong> to start one.
</p>
{:else}
<CollectionsGrid collections={r.collections} />
{/if}
{:else if activeTab === 'page-tags'}
<PageTagsList initialItems={r.pageTags} initialDistinct={r.distinctPageTags} />
{:else}
<CollectionsGrid collections={data.collections} />
<HistoryList entries={r.history} onClear={clearOne} testid="library-history" />
{/if}
{:else if activeTab === 'page-tags'}
<PageTagsList
initialItems={data.pageTags}
initialDistinct={data.distinctPageTags}
/>
{:else}
<HistoryList entries={data.history} onClear={clearOne} testid="library-history" />
{/if}
{/await}
<style>
.library-heading {

View File

@@ -26,30 +26,37 @@ export const load: PageLoad = async () => {
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
error: null as string | null
};
try {
const [bookmarks, collections, history, pageTags, distinctPageTags] =
await Promise.all([
listMyBookmarks(),
listMyCollections({ limit: 200 }),
listMyReadProgress({ limit: 100 }),
listMyPageTags({ limit: 100 }),
listMyDistinctPageTags(undefined, 100)
]);
return {
...empty,
bookmarks: bookmarks.items,
collections: collections.items,
history: history.items,
pageTags: pageTags.items,
distinctPageTags
};
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { ...empty, authenticated: false };
}
if (e instanceof ApiError) {
return { ...empty, error: e.message };
}
throw e;
}
// Streamed so the active sub-tab shows a skeleton while the one-shot fetch
// (also the auth gate) is in flight.
return {
result: (async () => {
try {
const [bookmarks, collections, history, pageTags, distinctPageTags] =
await Promise.all([
listMyBookmarks(),
listMyCollections({ limit: 200 }),
listMyReadProgress({ limit: 100 }),
listMyPageTags({ limit: 100 }),
listMyDistinctPageTags(undefined, 100)
]);
return {
...empty,
bookmarks: bookmarks.items,
collections: collections.items,
history: history.items,
pageTags: pageTags.items,
distinctPageTags
};
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { ...empty, authenticated: false };
}
// An HTTP error or a raw network failure (a non-ApiError
// TypeError) renders inline rather than escaping to the
// framework error page for a transient API blip.
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
return { ...empty, error: message };
}
})()
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,30 +4,34 @@ import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import { getMyReactionForManga } from '$lib/api/reactions';
import type { PageLoad } from './$types';
import type { DetailData } from './types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const [manga, chapters, bookmarks, readProgress, reaction, similar] = await Promise.all([
export const load: PageLoad = ({ params }) => {
// Streamed (the load doesn't await) so the page renders a
// MangaDetailSkeleton while the bundle loads instead of blocking
// navigation on all six calls. A getManga 404 / any hard failure rejects
// the bundle and is handled by the page's {:catch}.
const bundle: Promise<DetailData> = Promise.all([
getManga(params.id),
listChapters(params.id),
listMyBookmarksOrEmpty(),
// Null when guest or never-read — page handles both cases.
// Null when guest or never-read — the page handles both cases.
getMyReadProgressForManga(params.id),
// Null when guest or not reacted — seeds the like/dislike toggle.
// Non-critical: any failure degrades to an unset toggle, never a
// broken page.
getMyReactionForManga(params.id).catch(() => null),
// Recommendations are non-critical: a failure here must not break
// the detail page, so fall back to an empty list.
// Recommendations are non-critical: fall back to an empty list.
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
]);
return {
]).then(([manga, chapters, bookmarks, readProgress, reaction, similar]) => ({
manga,
chapters: chapters.items,
bookmarks: bookmarks.items,
readProgress,
reaction,
similar
};
}));
return { bundle };
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
import type { MangaCard, MangaDetail } from '$lib/api/mangas';
import type { Chapter } from '$lib/api/chapters';
import type { Bookmark } from '$lib/api/bookmarks';
import type { ReadProgressForManga } from '$lib/api/read_progress';
import type { Reaction } from '$lib/api/reactions';
/**
* The manga detail bundle. Streamed from the loader (see +page.ts) and passed
* to DetailView once resolved, so the page can render a skeleton while it
* loads. Shape matches the loader's previous flat return.
*/
export type DetailData = {
manga: MangaDetail;
chapters: Chapter[];
bookmarks: Bookmark[];
readProgress: ReadProgressForManga | null;
reaction: Reaction | null;
similar: MangaCard[];
};

View File

@@ -5,6 +5,7 @@
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte';
import SearchResultsSkeleton from '$lib/components/SearchResultsSkeleton.svelte';
import { CONTENT_WARNINGS, type ContentWarning } from '$lib/api/page_tags';
import X from '@lucide/svelte/icons/x';
@@ -176,21 +177,29 @@
</section>
{#if data.contentSearch}
{#if data.results.length === 0}
<p class="hint" data-testid="search-content-empty">
No pages match this search.
</p>
{:else}
<ul class="list" data-testid="search-content-list">
{#each data.results as p (p.page_id)}
<TaggedPageRow
item={{ ...p, tag: '', tagged_at: '' }}
showTagPill={false}
testid={`search-result-${p.page_id}`}
/>
{/each}
</ul>
{/if}
{#await data.results}
<SearchResultsSkeleton />
{:then r}
{#if r.error}
<p class="error" role="alert" data-testid="search-results-error">
Couldn't load results: {r.error}
</p>
{:else if r.results.length === 0}
<p class="hint" data-testid="search-content-empty">
No pages match this search.
</p>
{:else}
<ul class="list" data-testid="search-content-list">
{#each r.results as p (p.page_id)}
<TaggedPageRow
item={{ ...p, tag: '', tagged_at: '' }}
showTagPill={false}
testid={`search-result-${p.page_id}`}
/>
{/each}
</ul>
{/if}
{/await}
{:else}
<!-- Tag filter. When a tag is selected it's shown as a chip with
an x to clear; when none is, the input doubles as the entry
@@ -280,53 +289,61 @@
</div>
{/if}
{#if data.view === 'pages'}
{#if data.pages.length === 0}
<p class="hint" data-testid="search-pages-empty">
No pages tagged with "{data.tag}".
{#await data.results}
<SearchResultsSkeleton />
{:then r}
{#if r.error}
<p class="error" role="alert" data-testid="search-results-error">
Couldn't load results: {r.error}
</p>
{:else if data.view === 'pages'}
{#if r.pages.length === 0}
<p class="hint" data-testid="search-pages-empty">
No pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-pages-list">
{#each r.pages as p (p.page_id)}
<TaggedPageRow
item={p}
showTagPill={false}
testid={`search-page-row-${p.page_id}`}
/>
{/each}
</ul>
{/if}
{:else if data.view === 'chapters'}
{#if r.chapters.length === 0}
<p class="hint" data-testid="search-chapters-empty">
No chapters contain pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-chapters-list">
{#each r.chapters as c (c.chapter_id)}
<TaggedChapterRow
item={c}
testid={`search-chapter-row-${c.chapter_id}`}
/>
{/each}
</ul>
{/if}
{:else}
<ul class="list" data-testid="search-pages-list">
{#each data.pages as p (p.page_id)}
<TaggedPageRow
item={p}
showTagPill={false}
testid={`search-page-row-${p.page_id}`}
/>
{/each}
</ul>
{#if r.mangas.length === 0}
<p class="hint" data-testid="search-mangas-empty">
No mangas contain pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-mangas-list">
{#each r.mangas as m (m.manga_id)}
<TaggedMangaRow
item={m}
testid={`search-manga-row-${m.manga_id}`}
/>
{/each}
</ul>
{/if}
{/if}
{:else if data.view === 'chapters'}
{#if data.chapters.length === 0}
<p class="hint" data-testid="search-chapters-empty">
No chapters contain pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-chapters-list">
{#each data.chapters as c (c.chapter_id)}
<TaggedChapterRow
item={c}
testid={`search-chapter-row-${c.chapter_id}`}
/>
{/each}
</ul>
{/if}
{:else}
{#if data.mangas.length === 0}
<p class="hint" data-testid="search-mangas-empty">
No mangas contain pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-mangas-list">
{#each data.mangas as m (m.manga_id)}
<TaggedMangaRow
item={m}
testid={`search-manga-row-${m.manga_id}`}
/>
{/each}
</ul>
{/if}
{/if}
{/await}
{/if}
{/if}
{/if}

View File

@@ -60,7 +60,7 @@ export const load: PageLoad = async ({ url }) => {
// backend; cw_exclude alone can't drive a search.
const contentSearch = text !== '' || cwInclude.length > 0;
const empty = {
const base = {
authenticated: true,
tag,
view,
@@ -70,6 +70,10 @@ export const load: PageLoad = async ({ url }) => {
cwExclude,
contentSearch,
distinct: [] as PageTagSummary[],
error: null as string | null
};
const emptyResults = {
pages: [] as TaggedPageItem[],
chapters: [] as TaggedChapterAggregate[],
mangas: [] as TaggedMangaAggregate[],
@@ -78,55 +82,54 @@ export const load: PageLoad = async ({ url }) => {
error: null as string | null
};
// The chip cloud + auth gate come from `distinct`, so it's awaited. A 401
// here means "not signed in"; any other error is surfaced inline. The
// view-specific results are streamed (see below).
let distinct: PageTagSummary[];
try {
const distinct = await listMyDistinctPageTags(undefined, 100);
// Content search takes precedence over tag browsing.
if (contentSearch) {
const r = await searchPages({
text: text || undefined,
cwInclude,
cwExclude,
limit: 100
});
return { ...empty, distinct, results: r.items, total: r.page.total ?? 0 };
}
// No tag selected → just the chip cloud.
if (!tag) return { ...empty, distinct };
if (view === 'chapters') {
const r = await listTaggedChapters({ tag, order, limit: 100 });
return {
...empty,
distinct,
chapters: r.items,
total: r.page.total ?? 0
};
}
if (view === 'mangas') {
const r = await listTaggedMangas({ tag, order, limit: 100 });
return {
...empty,
distinct,
mangas: r.items,
total: r.page.total ?? 0
};
}
const r = await listMyPageTags({ tag, limit: 100 });
return {
...empty,
distinct,
pages: r.items,
total: r.page.total ?? 0
};
distinct = await listMyDistinctPageTags(undefined, 100);
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { ...empty, authenticated: false };
return { ...base, authenticated: false, results: Promise.resolve(emptyResults) };
}
if (e instanceof ApiError) {
return { ...empty, error: e.message };
}
throw e;
// Any other HTTP error or a raw network failure (a non-ApiError
// TypeError) is surfaced inline rather than escaping to the framework
// error page.
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
return { ...base, error: message, results: Promise.resolve(emptyResults) };
}
// Streamed so the results list shows a skeleton while it (re-)queries on
// every tag / view / sort / text change instead of freezing the old list.
const results = (async () => {
try {
// Content search takes precedence over tag browsing.
if (contentSearch) {
const r = await searchPages({
text: text || undefined,
cwInclude,
cwExclude,
limit: 100
});
return { ...emptyResults, results: r.items, total: r.page.total ?? 0 };
}
// No tag selected → just the chip cloud, no results.
if (!tag) return emptyResults;
if (view === 'chapters') {
const r = await listTaggedChapters({ tag, order, limit: 100 });
return { ...emptyResults, chapters: r.items, total: r.page.total ?? 0 };
}
if (view === 'mangas') {
const r = await listTaggedMangas({ tag, order, limit: 100 });
return { ...emptyResults, mangas: r.items, total: r.page.total ?? 0 };
}
const r = await listMyPageTags({ tag, limit: 100 });
return { ...emptyResults, pages: r.items, total: r.page.total ?? 0 };
} catch (e) {
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
return { ...emptyResults, error: message };
}
})();
return { ...base, distinct, results };
};