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 @@
+
+
+
+
+
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}
+