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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.94.1"
|
version = "0.96.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.95.0"
|
version = "0.96.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
89
frontend/e2e/continue-reading-shelf.spec.ts
Normal file
89
frontend/e2e/continue-reading-shelf.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.95.0",
|
"version": "0.96.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.95.0",
|
"version": "0.96.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.16.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@playwright/test": "^1.48.0",
|
"@playwright/test": "^1.48.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.95.0",
|
"version": "0.96.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export type ReadProgressSummary = {
|
|||||||
chapter_number: number | null;
|
chapter_number: number | null;
|
||||||
page: number;
|
page: number;
|
||||||
updated_at: string;
|
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 = {
|
export type ReadProgressPage = {
|
||||||
|
|||||||
168
frontend/src/lib/components/ContinueReadingShelf.svelte
Normal file
168
frontend/src/lib/components/ContinueReadingShelf.svelte
Normal 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>
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ function entry(over: Partial<ReadProgressSummary> = {}): ReadProgressSummary {
|
|||||||
chapter_number: 5,
|
chapter_number: 5,
|
||||||
page: 1,
|
page: 1,
|
||||||
updated_at: '2026-01-02T00:00:00Z',
|
updated_at: '2026-01-02T00:00:00Z',
|
||||||
|
new_chapters_count: 0,
|
||||||
...over
|
...over
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,12 @@
|
|||||||
sortLabel as composeSortLabel,
|
sortLabel as composeSortLabel,
|
||||||
sortUrlParams
|
sortUrlParams
|
||||||
} from '$lib/mangaSort';
|
} from '$lib/mangaSort';
|
||||||
|
import {
|
||||||
|
listMyReadProgressOrEmpty,
|
||||||
|
type ReadProgressSummary
|
||||||
|
} from '$lib/api/read_progress';
|
||||||
import Chip from '$lib/components/Chip.svelte';
|
import Chip from '$lib/components/Chip.svelte';
|
||||||
|
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
import Pager from '$lib/components/Pager.svelte';
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||||
@@ -37,6 +42,7 @@
|
|||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
let mangas: MangaCardData[] = $state([]);
|
let mangas: MangaCardData[] = $state([]);
|
||||||
|
let continueEntries = $state<ReadProgressSummary[]>([]);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let sort = $state<MangaSort>(DEFAULT_SORT);
|
let sort = $state<MangaSort>(DEFAULT_SORT);
|
||||||
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
|
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
|
||||||
@@ -301,6 +307,16 @@
|
|||||||
}
|
}
|
||||||
await hydrateFromUrl();
|
await hydrateFromUrl();
|
||||||
await load();
|
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
|
// Track viewport in a separate $effect so the listener cleans up on
|
||||||
@@ -455,6 +471,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if continueEntries.length > 0}
|
||||||
|
<ContinueReadingShelf entries={continueEntries} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onsubmit={onSubmit}
|
onsubmit={onSubmit}
|
||||||
action="javascript:void(0)"
|
action="javascript:void(0)"
|
||||||
|
|||||||
Reference in New Issue
Block a user