feat(home): add a Continue-reading shelf with new-chapter badges

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-04 20:57:08 +02:00
parent f318c3bf51
commit 2267a83f6c
10 changed files with 355 additions and 5 deletions

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -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);
});

View File

@@ -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",

View File

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

View File

@@ -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 = {

View File

@@ -0,0 +1,168 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import type { ReadProgressSummary } from '$lib/api/read_progress';
import BookImage from '@lucide/svelte/icons/book-image';
// Horizontal "Continue reading" shelf for the homepage. Fed the user's
// read-progress history (newest first); each card jumps straight back to
// where they left off and flags how many chapters have landed since.
// Rendered only when there are entries (the homepage owns that gate), so
// this component has no empty state.
let {
entries,
testid = 'continue-shelf'
}: {
entries: ReadProgressSummary[];
testid?: string;
} = $props();
// Deep-link to the exact page when past the first; page 1 is the reader's
// default so the query is omitted to keep the URL clean. No chapter (a
// manga-level progress row) falls back to the manga page.
function continueHref(p: ReadProgressSummary): string {
if (p.chapter_id == null) return `/manga/${p.manga_id}`;
const base = `/manga/${p.manga_id}/chapter/${p.chapter_id}`;
return p.page > 1 ? `${base}?page=${p.page}` : base;
}
function targetLabel(p: ReadProgressSummary): string {
if (p.chapter_number == null) return `Page ${p.page}`;
const label = chapterLabel({ number: p.chapter_number, title: null });
return p.page > 1 ? `${label} · p ${p.page}` : label;
}
</script>
<section class="shelf" aria-label="Continue reading" data-testid="{testid}">
<h2 class="shelf-title">Continue reading</h2>
<ul class="track" data-testid="{testid}-list">
{#each entries as p (p.manga_id)}
<li>
<a
href={continueHref(p)}
class="card"
data-testid="continue-card-{p.manga_id}"
>
<span class="cover-wrap">
{#if p.manga_cover_image_path}
<img
src={fileUrl(p.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<span class="cover cover-placeholder">
<BookImage size={28} aria-hidden="true" />
</span>
{/if}
{#if p.new_chapters_count > 0}
<span
class="new-badge"
data-testid="continue-new-badge-{p.manga_id}"
aria-label="{p.new_chapters_count} new chapter{p.new_chapters_count === 1 ? '' : 's'} since you last read"
>
{p.new_chapters_count > 99 ? '99+' : p.new_chapters_count} new
</span>
{/if}
</span>
<span class="title" data-testid="continue-title-{p.manga_id}">{p.manga_title}</span>
<span class="target">{targetLabel(p)}</span>
</a>
</li>
{/each}
</ul>
</section>
<style>
.shelf {
margin: 0 0 var(--space-5);
}
.shelf-title {
font-size: var(--font-md);
font-weight: var(--weight-semibold);
margin: 0 0 var(--space-2);
}
.track {
list-style: none;
display: flex;
gap: var(--space-3);
padding: 0 0 var(--space-2);
margin: 0;
overflow-x: auto;
scroll-snap-type: x proximity;
/* Momentum scrolling on iOS; keep the scrollbar unobtrusive. */
-webkit-overflow-scrolling: touch;
}
.card {
display: flex;
flex-direction: column;
width: 108px;
scroll-snap-align: start;
color: var(--text);
text-decoration: none;
}
.cover-wrap {
position: relative;
display: block;
line-height: 0;
}
.cover {
width: 108px;
height: 162px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.new-badge {
position: absolute;
top: var(--space-1);
right: var(--space-1);
padding: 0 6px;
height: 20px;
display: inline-flex;
align-items: center;
background: var(--primary);
color: var(--primary-contrast);
font-size: var(--font-xs);
font-weight: var(--weight-semibold);
line-height: 1;
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
pointer-events: none;
}
.title {
margin-top: var(--space-1);
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card:hover .title {
color: var(--primary);
}
.target {
font-size: var(--font-xs);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -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> = {}): 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');
});
});

View File

@@ -14,6 +14,7 @@ function entry(over: Partial<ReadProgressSummary> = {}): ReadProgressSummary {
chapter_number: 5,
page: 1,
updated_at: '2026-01-02T00:00:00Z',
new_chapters_count: 0,
...over
};
}

View File

@@ -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<ReadProgressSummary[]>([]);
let search = $state('');
let sort = $state<MangaSort>(DEFAULT_SORT);
let order = $state<SortOrder>(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 @@
</a>
</div>
{#if continueEntries.length > 0}
<ContinueReadingShelf entries={continueEntries} />
{/if}
<form
onsubmit={onSubmit}
action="javascript:void(0)"