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:
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