diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 31d4548..cec967a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.121.0" +version = "0.122.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e600477..d411d99 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.121.0" +version = "0.122.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/detail-skeleton.spec.ts b/frontend/e2e/detail-skeleton.spec.ts new file mode 100644 index 0000000..9323ac7 --- /dev/null +++ b/frontend/e2e/detail-skeleton.spec.ts @@ -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((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'); +}); diff --git a/frontend/package.json b/frontend/package.json index 48dcdab..ff787a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.121.0", + "version": "0.122.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/MangaDetailSkeleton.svelte b/frontend/src/lib/components/MangaDetailSkeleton.svelte new file mode 100644 index 0000000..5753a94 --- /dev/null +++ b/frontend/src/lib/components/MangaDetailSkeleton.svelte @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/lib/components/MangaDetailSkeleton.svelte.test.ts b/frontend/src/lib/components/MangaDetailSkeleton.svelte.test.ts new file mode 100644 index 0000000..973cae6 --- /dev/null +++ b/frontend/src/lib/components/MangaDetailSkeleton.svelte.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index 9a9e36b..cf55f37 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -1,1459 +1,42 @@ - - Mangalord | {manga.title} - - -
-
- {#if manga.cover_image_path} - - {/if} - - -
- -
- {#if session.user} - - {/if} - -
- -
- {#if manga.cover_image_path} - - {/if} -
-

{manga.title}

- {#if authors.length > 0} -

- by {authors.map((a) => a.name).join(', ')} -

- {/if} - {statusLabel} -
-
-
- -
- {#if manga.cover_image_path} - {manga.title} cover - {/if} -
-
-

{manga.title}

- - {statusLabel} - -
- - {#if authors.length > 0} -
- by - {#each authors as a (a.id)} - - {/each} -
- {/if} - - {#if manga.alt_titles.length > 0} -
- Also known as ({manga.alt_titles.length}) -
    - {#each manga.alt_titles as alt} -
  • {alt}
  • - {/each} -
-
- {/if} - - {#if contentWarnings.length > 0} -
- ⚠ Content warning - {#each contentWarnings as w (w)} - {w} - {/each} -
- {/if} - - {#if genres.length > 0} -
- Genres - {#each genres as g (g.id)} - - {/each} -
- {/if} - -
- Tags - {#each tags as t (t.id)} - removeTag(t) - : undefined} - removeLabel="Remove tag" - /> - {/each} - {#if session.user} -
- 0} - aria-autocomplete="list" - aria-activedescendant={suggestHighlight >= 0 - ? `${suggestListId}-opt-${suggestHighlight}` - : undefined} - class="tag-input" - data-testid="tag-input" - /> - - {#if suggestions.length > 0} -
    - {#each suggestions as s, i (s.id)} -
  • - -
  • - {/each} -
- {/if} -
- {/if} -
- {#if tagError} - - {/if} - - {#if manga.description} -

- {manga.description} -

- {#if descNeedsClamp} - - {/if} - {/if} - - {#if session.user} -
- - - - - - - - {#if session.user.is_admin} - - {/if} -
- {#if resyncMessage} -

- {resyncMessage.text} -

- {/if} - {:else} - - Sign in to bookmark or collect - - {/if} -
-
- - {#if session.user} - (collectionModalOpen = false)} - /> - {/if} - -
-
-
-

Chapters

- {#if newChapterCount > 0} - - {newChapterCount} new since last read - - {/if} -
- {#if contentBytes === null || contentBytes > 0} - - Content: {contentBytes === null ? '—' : formatBytes(contentBytes)} - - {/if} -
- {#if continueChapterId != null && continueChapterNumber != null} - - Continue reading - - {continueLabel} - {#if readProgress && readProgress.page > 1} - — page {readProgress.page} - {/if} - - - {/if} - {#if chapters.length === 0} -

No chapters yet.

+{#await data.bundle} + +{:then bundle} + +{:catch err} +
- - {#if similar.length > 0} -
-

Similar

-
    - {#each similar as m (m.id)} - - {/each} -
-
- {/if} - - (overflowOpen = false)} - testid="detail-overflow-sheet" - > -
- {#if session.user} - - - - - - {#if session.user.is_admin} - - {/if} - {:else} - - Sign in to bookmark or collect - - {/if} -
-
- - {#if ctaTarget} - - {/if} -
+ Back to browse + +{/await} diff --git a/frontend/src/routes/manga/[id]/+page.ts b/frontend/src/routes/manga/[id]/+page.ts index 47c2cf7..3d3317c 100644 --- a/frontend/src/routes/manga/[id]/+page.ts +++ b/frontend/src/routes/manga/[id]/+page.ts @@ -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 = 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 }; }; diff --git a/frontend/src/routes/manga/[id]/DetailView.svelte b/frontend/src/routes/manga/[id]/DetailView.svelte new file mode 100644 index 0000000..c82b3f7 --- /dev/null +++ b/frontend/src/routes/manga/[id]/DetailView.svelte @@ -0,0 +1,1460 @@ + + + + Mangalord | {manga.title} + + +
+
+ {#if manga.cover_image_path} + + {/if} + + +
+ +
+ {#if session.user} + + {/if} + +
+ +
+ {#if manga.cover_image_path} + + {/if} +
+

{manga.title}

+ {#if authors.length > 0} +

+ by {authors.map((a) => a.name).join(', ')} +

+ {/if} + {statusLabel} +
+
+
+ +
+ {#if manga.cover_image_path} + {manga.title} cover + {/if} +
+
+

{manga.title}

+ + {statusLabel} + +
+ + {#if authors.length > 0} +
+ by + {#each authors as a (a.id)} + + {/each} +
+ {/if} + + {#if manga.alt_titles.length > 0} +
+ Also known as ({manga.alt_titles.length}) +
    + {#each manga.alt_titles as alt} +
  • {alt}
  • + {/each} +
+
+ {/if} + + {#if contentWarnings.length > 0} +
+ ⚠ Content warning + {#each contentWarnings as w (w)} + {w} + {/each} +
+ {/if} + + {#if genres.length > 0} +
+ Genres + {#each genres as g (g.id)} + + {/each} +
+ {/if} + +
+ Tags + {#each tags as t (t.id)} + removeTag(t) + : undefined} + removeLabel="Remove tag" + /> + {/each} + {#if session.user} +
+ 0} + aria-autocomplete="list" + aria-activedescendant={suggestHighlight >= 0 + ? `${suggestListId}-opt-${suggestHighlight}` + : undefined} + class="tag-input" + data-testid="tag-input" + /> + + {#if suggestions.length > 0} +
    + {#each suggestions as s, i (s.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ {/if} +
+ {#if tagError} + + {/if} + + {#if manga.description} +

+ {manga.description} +

+ {#if descNeedsClamp} + + {/if} + {/if} + + {#if session.user} +
+ + + + + + + + {#if session.user.is_admin} + + {/if} +
+ {#if resyncMessage} +

+ {resyncMessage.text} +

+ {/if} + {:else} + + Sign in to bookmark or collect + + {/if} +
+
+ + {#if session.user} + (collectionModalOpen = false)} + /> + {/if} + +
+
+
+

Chapters

+ {#if newChapterCount > 0} + + {newChapterCount} new since last read + + {/if} +
+ {#if contentBytes === null || contentBytes > 0} + + Content: {contentBytes === null ? '—' : formatBytes(contentBytes)} + + {/if} +
+ {#if continueChapterId != null && continueChapterNumber != null} + + Continue reading + + {continueLabel} + {#if readProgress && readProgress.page > 1} + — page {readProgress.page} + {/if} + + + {/if} + {#if chapters.length === 0} +

No chapters yet.

+ {:else} +
    + {#each chapters as c (c.id)} + {@const read = isChapterRead(c.number, lastReadNumber)} +
  1. + {#if read} +
  2. + {/each} +
+ {/if} +
+ + {#if similar.length > 0} +
+

Similar

+
    + {#each similar as m (m.id)} + + {/each} +
+
+ {/if} + + (overflowOpen = false)} + testid="detail-overflow-sheet" + > +
+ {#if session.user} + + + + + + {#if session.user.is_admin} + + {/if} + {:else} + + Sign in to bookmark or collect + + {/if} +
+
+ + {#if ctaTarget} + + {/if} +
+ + diff --git a/frontend/src/routes/manga/[id]/types.ts b/frontend/src/routes/manga/[id]/types.ts new file mode 100644 index 0000000..2a21371 --- /dev/null +++ b/frontend/src/routes/manga/[id]/types.ts @@ -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[]; +};