feat(detail): mark read chapters and count new ones on the manga page
On the manga detail page, dim chapters at or before the reader's last-read chapter (with an accent check + screen-reader "Read." label) and show a "N new since last read" badge counting chapters that have landed since. All client-side from the already-loaded chapter list and per-manga read progress — no extra requests. The count reflects the loaded (paginated) chapter list. Logic lives in a pure, unit-tested chapterProgress helper; e2e covers the mid-series and never-opened cases. 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.96.0"
|
||||
version = "0.97.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.96.0"
|
||||
version = "0.97.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
82
frontend/e2e/detail-read-markers.spec.ts
Normal file
82
frontend/e2e/detail-read-markers.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Detail page reflects the reader's personal progress: chapters at or before
|
||||
// the last-read chapter are marked read, and a "N new since last read" badge
|
||||
// counts chapters that have landed since.
|
||||
|
||||
const mangaId = 'a1111111-1111-1111-1111-111111111111';
|
||||
const ch1 = 'c1111111-1111-1111-1111-111111111111';
|
||||
const ch2 = 'c2222222-2222-2222-2222-222222222222';
|
||||
const ch3 = 'c3333333-3333-3333-3333-333333333333';
|
||||
|
||||
// Newest-first, as the API returns them.
|
||||
const chapters = [
|
||||
{ id: ch3, manga_id: mangaId, number: 3, title: null, page_count: 10, created_at: '2026-03-01T00:00:00Z' },
|
||||
{ id: ch2, manga_id: mangaId, number: 2, title: null, page_count: 10, created_at: '2026-02-01T00:00:00Z' },
|
||||
{ id: ch1, manga_id: mangaId, number: 1, title: null, page_count: 8, created_at: '2026-01-01T00:00:00Z' }
|
||||
];
|
||||
|
||||
async function mockDetail(
|
||||
page: Page,
|
||||
readProgress: { chapter_id: string; chapter_number: number; page: number } | null
|
||||
) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) })
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } }) })
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: chapters, page: { limit: 50, offset: 0, total: 3 } }) })
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||
);
|
||||
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
|
||||
r.fulfill({
|
||||
status: readProgress ? 200 : 404,
|
||||
contentType: 'application/json',
|
||||
body: readProgress ? JSON.stringify(readProgress) : JSON.stringify({ error: { code: 'not_found', message: 'no' } })
|
||||
})
|
||||
);
|
||||
// getManga — registered last so it wins over the chapters glob above.
|
||||
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
|
||||
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
|
||||
})
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test('marks read chapters and counts new ones when mid-series', async ({ page }) => {
|
||||
await mockDetail(page, { chapter_id: ch1, chapter_number: 1, page: 1 });
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
// Chapter 1 is read; 2 and 3 are not.
|
||||
await expect(page.getByTestId(`chapter-read-${ch1}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`chapter-read-${ch2}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`chapter-read-${ch3}`)).toHaveCount(0);
|
||||
|
||||
// Two chapters landed since last read.
|
||||
await expect(page.getByTestId('new-chapters-summary')).toContainText('2 new');
|
||||
});
|
||||
|
||||
test('no markers or new-badge when the manga was never opened', async ({ page }) => {
|
||||
await mockDetail(page, null);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
await expect(page.getByTestId('chapter-list')).toBeVisible();
|
||||
await expect(page.getByTestId(`chapter-read-${ch1}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId('new-chapters-summary')).toHaveCount(0);
|
||||
});
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.0",
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.16.0",
|
||||
"@playwright/test": "^1.48.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
33
frontend/src/lib/chapterProgress.test.ts
Normal file
33
frontend/src/lib/chapterProgress.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isChapterRead, countNewChapters } from './chapterProgress';
|
||||
|
||||
describe('isChapterRead', () => {
|
||||
it('marks chapters at or before the last-read number as read', () => {
|
||||
expect(isChapterRead(1, 3)).toBe(true);
|
||||
expect(isChapterRead(3, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves later chapters unread', () => {
|
||||
expect(isChapterRead(4, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it('marks nothing when the last-read position is unknown', () => {
|
||||
expect(isChapterRead(1, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countNewChapters', () => {
|
||||
const chapters = [{ number: 1 }, { number: 2 }, { number: 3 }];
|
||||
|
||||
it('counts chapters past the last-read number', () => {
|
||||
expect(countNewChapters(chapters, 1)).toBe(2);
|
||||
});
|
||||
|
||||
it('is zero when caught up to the latest', () => {
|
||||
expect(countNewChapters(chapters, 3)).toBe(0);
|
||||
});
|
||||
|
||||
it('is zero when the last-read position is unknown', () => {
|
||||
expect(countNewChapters(chapters, null)).toBe(0);
|
||||
});
|
||||
});
|
||||
28
frontend/src/lib/chapterProgress.ts
Normal file
28
frontend/src/lib/chapterProgress.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Pure helpers for reflecting a reader's personal progress on a manga's
|
||||
// chapter list. Kept UI-free so the read/unread and "new since last read"
|
||||
// logic is unit-testable without rendering the detail page.
|
||||
|
||||
export type NumberedChapter = { number: number };
|
||||
|
||||
/**
|
||||
* A chapter counts as read once its number is at or before the reader's
|
||||
* last-read chapter number. An unknown last-read position (guest, never
|
||||
* opened, or a deleted chapter) marks nothing — we never guess.
|
||||
*/
|
||||
export function isChapterRead(chapterNumber: number, lastReadNumber: number | null): boolean {
|
||||
return lastReadNumber != null && chapterNumber <= lastReadNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of the given chapters sit past the reader's last-read chapter
|
||||
* (by number) — the personal "new since you last read" count. `0` when the
|
||||
* last-read position is unknown. Counts over the provided (loaded) list, so
|
||||
* for a paginated chapter list it reflects what's currently shown.
|
||||
*/
|
||||
export function countNewChapters(
|
||||
chapters: NumberedChapter[],
|
||||
lastReadNumber: number | null
|
||||
): number {
|
||||
if (lastReadNumber == null) return 0;
|
||||
return chapters.reduce((n, c) => (c.number > lastReadNumber ? n + 1 : n), 0);
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
} from '$lib/api/mangas';
|
||||
import { resyncManga } from '$lib/api/admin';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { isChapterRead, countNewChapters } from '$lib/chapterProgress';
|
||||
import { formatBytes } from '$lib/upload-validation';
|
||||
import { listTags, type Tag } from '$lib/api/tags';
|
||||
import type { ContentWarning } from '$lib/api/page_tags';
|
||||
@@ -27,6 +28,7 @@
|
||||
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
|
||||
import BookmarkIcon from '@lucide/svelte/icons/bookmark';
|
||||
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
|
||||
@@ -68,6 +70,12 @@
|
||||
: null
|
||||
);
|
||||
|
||||
/** The reader's last-read chapter number, used to mark chapters as read
|
||||
* and count what's landed since. `null` for guests / never-read. */
|
||||
const lastReadNumber = $derived(readProgress?.chapter_number ?? null);
|
||||
/** New chapters since last read, over the loaded chapter list. */
|
||||
const newChapterCount = $derived(countNewChapters(chapters, lastReadNumber));
|
||||
|
||||
const authors = $derived<AuthorRef[]>(manga.authors);
|
||||
const genres = $derived<GenreRef[]>(manga.genres);
|
||||
/** Deduped content warnings across the manga's pages (analysis worker). */
|
||||
@@ -597,7 +605,14 @@
|
||||
|
||||
<section aria-label="chapters">
|
||||
<div class="chapters-head">
|
||||
<h2>Chapters</h2>
|
||||
<div class="chapters-head-left">
|
||||
<h2>Chapters</h2>
|
||||
{#if newChapterCount > 0}
|
||||
<span class="new-chapters" data-testid="new-chapters-summary">
|
||||
{newChapterCount} new since last read
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if contentBytes === null || contentBytes > 0}
|
||||
<span class="content-size" data-testid="manga-content-size">
|
||||
Content: {contentBytes === null ? '—' : formatBytes(contentBytes)}
|
||||
@@ -624,7 +639,12 @@
|
||||
{:else}
|
||||
<ol class="chapter-list" data-testid="chapter-list">
|
||||
{#each chapters as c (c.id)}
|
||||
<li>
|
||||
{@const read = isChapterRead(c.number, lastReadNumber)}
|
||||
<li class:read data-testid={read ? `chapter-read-${c.id}` : undefined}>
|
||||
{#if read}
|
||||
<Check size={15} class="read-check" aria-hidden="true" />
|
||||
<span class="sr-only">Read.</span>
|
||||
{/if}
|
||||
<a href="/manga/{manga.id}/chapter/{c.id}">
|
||||
{chapterLabel(c)}
|
||||
</a>
|
||||
@@ -1009,6 +1029,40 @@
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
/* Already-read chapters (at or before the reader's last-read chapter)
|
||||
dim so the eye is drawn to what's still unread; the check keeps its
|
||||
accent colour as a positive "done" marker. */
|
||||
.chapter-list li.read {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chapter-list li.read :global(svg) {
|
||||
vertical-align: -2px;
|
||||
margin-right: var(--space-1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.new-chapters {
|
||||
color: var(--primary);
|
||||
background: var(--primary-soft-bg);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.pages {
|
||||
color: var(--text-muted);
|
||||
margin-left: var(--space-2);
|
||||
@@ -1022,6 +1076,13 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.chapters-head-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-size {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
|
||||
Reference in New Issue
Block a user