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]]
|
||||
name = "mangalord"
|
||||
version = "0.94.1"
|
||||
version = "0.96.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.95.0"
|
||||
version = "0.96.0"
|
||||
edition = "2021"
|
||||
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",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.95.0",
|
||||
"version": "0.96.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
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,
|
||||
page: 1,
|
||||
updated_at: '2026-01-02T00:00:00Z',
|
||||
new_chapters_count: 0,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user