From 2267a83f6ca48bcc41b66a04c69e46da9c316c4e Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 4 Jul 2026 20:57:08 +0200 Subject: [PATCH] feat(home): add a Continue-reading shelf with new-chapter badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface a horizontal "Continue reading" shelf at the top of the homepage, fed by the user's read-progress history. Each card jumps back to the exact page they left off (deep-linking `?page=N` past the first) and flags how many chapters have landed since — the personal new_chapters_count from the read-progress list. The shelf is fetched after the catalogue load so the public browse path stays unauthenticated; guests get an empty list (401 swallowed) and the shelf stays hidden. Add new_chapters_count to the frontend ReadProgressSummary type to match the backend. Component unit tests cover href/deep-link, badge visibility, and the no-chapter fallback; e2e covers signed-in (shelf + badge) and anonymous (hidden). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/continue-reading-shelf.spec.ts | 89 ++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/lib/api/read_progress.ts | 4 + .../components/ContinueReadingShelf.svelte | 168 ++++++++++++++++++ .../ContinueReadingShelf.svelte.test.ts | 68 +++++++ .../lib/components/HistoryList.svelte.test.ts | 1 + frontend/src/routes/+page.svelte | 20 +++ 10 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 frontend/e2e/continue-reading-shelf.spec.ts create mode 100644 frontend/src/lib/components/ContinueReadingShelf.svelte create mode 100644 frontend/src/lib/components/ContinueReadingShelf.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a5fa49a..369de5e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.94.1" +version = "0.96.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5a2f57b..790db91 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.95.0" +version = "0.96.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/continue-reading-shelf.spec.ts b/frontend/e2e/continue-reading-shelf.spec.ts new file mode 100644 index 0000000..38a3511 --- /dev/null +++ b/frontend/e2e/continue-reading-shelf.spec.ts @@ -0,0 +1,89 @@ +import { test, expect, type Page } from './fixtures'; + +// The homepage "Continue reading" shelf: visible with a per-manga new-chapter +// badge when the user has reading history, and absent for anonymous visitors +// (the read-progress fetch 401s → empty → hidden). + +const emptyMangas = { items: [], page: { limit: 50, offset: 0, total: 0 } }; + +async function mockCommon(page: Page) { + await page.route('**/api/v1/auth/config', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/genres*', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) + ); + await page.route('**/api/v1/mangas*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(emptyMangas) + }) + ); +} + +test('shows the continue-reading shelf with a new-chapter badge when signed in', async ({ page }) => { + await mockCommon(page); + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: { id: 'u1', username: 'reader', is_admin: false } }) + }) + ); + await page.route('**/api/v1/me/read-progress*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [ + { + manga_id: 'm1', + manga_title: 'Berserk', + manga_cover_image_path: null, + chapter_id: 'c1', + chapter_number: 3, + page: 4, + updated_at: '2026-01-01T00:00:00Z', + new_chapters_count: 2 + } + ], + page: { limit: 50, offset: 0, total: 1 } + }) + }) + ); + + await page.goto('/'); + + const card = page.getByTestId('continue-card-m1'); + await expect(card).toBeVisible(); + await expect(card).toHaveAttribute('href', '/manga/m1/chapter/c1?page=4'); + await expect(page.getByTestId('continue-new-badge-m1')).toContainText('2'); +}); + +test('hides the continue-reading shelf for anonymous visitors', async ({ page }) => { + await mockCommon(page); + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) + }) + ); + await page.route('**/api/v1/me/read-progress*', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) + }) + ); + + await page.goto('/'); + // The catalogue heading confirms the page rendered before asserting absence. + await expect(page.getByRole('heading', { name: 'Mangas' })).toBeVisible(); + await expect(page.getByTestId('continue-shelf')).toHaveCount(0); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 575d277..c963903 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.95.0", + "version": "0.96.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.95.0", + "version": "0.96.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index a55800d..6fb1002 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.95.0", + "version": "0.96.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/read_progress.ts b/frontend/src/lib/api/read_progress.ts index abe408a..ce4c295 100644 --- a/frontend/src/lib/api/read_progress.ts +++ b/frontend/src/lib/api/read_progress.ts @@ -17,6 +17,10 @@ export type ReadProgressSummary = { chapter_number: number | null; page: number; updated_at: string; + /** Chapters past the reader's last-read chapter — a personal "new + * since you last read" count. `0` when caught up or when the position + * is unknown (manga-level progress / deleted chapter). */ + new_chapters_count: number; }; export type ReadProgressPage = { diff --git a/frontend/src/lib/components/ContinueReadingShelf.svelte b/frontend/src/lib/components/ContinueReadingShelf.svelte new file mode 100644 index 0000000..1b922c8 --- /dev/null +++ b/frontend/src/lib/components/ContinueReadingShelf.svelte @@ -0,0 +1,168 @@ + + +
+

Continue reading

+ +
+ + diff --git a/frontend/src/lib/components/ContinueReadingShelf.svelte.test.ts b/frontend/src/lib/components/ContinueReadingShelf.svelte.test.ts new file mode 100644 index 0000000..a3b6dfd --- /dev/null +++ b/frontend/src/lib/components/ContinueReadingShelf.svelte.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import ContinueReadingShelf from './ContinueReadingShelf.svelte'; +import type { ReadProgressSummary } from '$lib/api/read_progress'; + +afterEach(() => cleanup()); + +type Entry = ReadProgressSummary & { new_chapters_count: number }; + +function entry(over: Partial = {}): Entry { + return { + manga_id: 'm1', + manga_title: 'Berserk', + manga_cover_image_path: null, + chapter_id: 'c1', + chapter_number: 3, + page: 3, + updated_at: '2026-01-01T00:00:00Z', + new_chapters_count: 0, + ...over + }; +} + +describe('ContinueReadingShelf', () => { + it('renders a card per entry linking to the continue position', () => { + render(ContinueReadingShelf, { + props: { + entries: [ + entry({ manga_id: 'm1', chapter_id: 'c1', page: 3 }), + entry({ manga_id: 'm2', chapter_id: 'c2', page: 1, manga_title: 'Vinland' }) + ] + } + }); + + // Page > 1 deep-links to the exact page; page 1 omits the query. + const first = screen.getByTestId('continue-card-m1'); + expect(first.getAttribute('href')).toBe('/manga/m1/chapter/c1?page=3'); + const second = screen.getByTestId('continue-card-m2'); + expect(second.getAttribute('href')).toBe('/manga/m2/chapter/c2'); + + expect(screen.getByText('Berserk')).toBeTruthy(); + expect(screen.getByText('Vinland')).toBeTruthy(); + }); + + it('shows the new-chapter badge only when there are new chapters', () => { + render(ContinueReadingShelf, { + props: { + entries: [ + entry({ manga_id: 'm1', new_chapters_count: 2 }), + entry({ manga_id: 'm2', new_chapters_count: 0 }) + ] + } + }); + + const badge = screen.getByTestId('continue-new-badge-m1'); + expect(badge.textContent).toContain('2'); + expect(screen.queryByTestId('continue-new-badge-m2')).toBeNull(); + }); + + it('links to the manga when the progress has no chapter', () => { + render(ContinueReadingShelf, { + props: { + entries: [entry({ manga_id: 'm3', chapter_id: null, chapter_number: null })] + } + }); + expect(screen.getByTestId('continue-card-m3').getAttribute('href')).toBe('/manga/m3'); + }); +}); diff --git a/frontend/src/lib/components/HistoryList.svelte.test.ts b/frontend/src/lib/components/HistoryList.svelte.test.ts index 7fcad8d..a2f254d 100644 --- a/frontend/src/lib/components/HistoryList.svelte.test.ts +++ b/frontend/src/lib/components/HistoryList.svelte.test.ts @@ -14,6 +14,7 @@ function entry(over: Partial = {}): ReadProgressSummary { chapter_number: 5, page: 1, updated_at: '2026-01-02T00:00:00Z', + new_chapters_count: 0, ...over }; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index b6b8209..6b2e9b3 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -22,7 +22,12 @@ sortLabel as composeSortLabel, sortUrlParams } from '$lib/mangaSort'; + import { + listMyReadProgressOrEmpty, + type ReadProgressSummary + } from '$lib/api/read_progress'; import Chip from '$lib/components/Chip.svelte'; + import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte'; import MangaCard from '$lib/components/MangaCard.svelte'; import Pager from '$lib/components/Pager.svelte'; import SegmentedControl from '$lib/components/SegmentedControl.svelte'; @@ -37,6 +42,7 @@ const PAGE_SIZE = 50; let mangas: MangaCardData[] = $state([]); + let continueEntries = $state([]); let search = $state(''); let sort = $state(DEFAULT_SORT); let order = $state(defaultOrderFor(DEFAULT_SORT)); @@ -301,6 +307,16 @@ } await hydrateFromUrl(); await load(); + // Fetch the "Continue reading" shelf after the catalogue so the + // public browse path stays unauthenticated and unblocked. Returns + // empty for guests (401 swallowed), which hides the shelf. + try { + const progress = await listMyReadProgressOrEmpty(); + continueEntries = progress.items; + } catch { + // Never let a history hiccup break the catalogue — leave the + // shelf hidden. + } }); // Track viewport in a separate $effect so the listener cleans up on @@ -455,6 +471,10 @@ +{#if continueEntries.length > 0} + +{/if} +