fix: reader loads the full chapter list for prev/next and the dropdown

The reader fetched only the first 200 chapters, so a deep chapter (#250) fell
outside the window and its prev/next chevrons resolved to null — a dead end —
and the chapter dropdown was truncated. Add listAllChapters, which pages
through the API's 200-row cap, and use it in the reader loader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 15:02:44 +02:00
parent 69a9309c54
commit c308eb3eac
4 changed files with 55 additions and 9 deletions

View File

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

View File

@@ -9,6 +9,7 @@ import {
} from 'vitest'; } from 'vitest';
import { import {
listChapters, listChapters,
listAllChapters,
getChapter, getChapter,
getChapterPages, getChapterPages,
createChapter, createChapter,
@@ -142,6 +143,30 @@ describe('chapters api client', () => {
}); });
}); });
it('listAllChapters pages through the 200-row cap and returns every chapter', async () => {
const mk = (n: number) => ({ ...chapterFixture, id: `c${n}`, number: n });
const first = Array.from({ length: 200 }, (_, i) => mk(i + 1));
const second = Array.from({ length: 50 }, (_, i) => mk(i + 201));
fetchSpy
.mockResolvedValueOnce(ok({ items: first, page: { limit: 200, offset: 0, total: 250 } }))
.mockResolvedValueOnce(ok({ items: second, page: { limit: 200, offset: 200, total: 250 } }));
const all = await listAllChapters('m1');
expect(all).toHaveLength(250);
expect(all[249].number).toBe(250);
expect(fetchSpy.mock.calls[0][0]).toContain('offset=0');
expect(fetchSpy.mock.calls[1][0]).toContain('offset=200');
});
it('listAllChapters stops after a short final page (single request)', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [chapterFixture], page: { limit: 200, offset: 0, total: 1 } })
);
const all = await listAllChapters('m1');
expect(all).toHaveLength(1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('getChapterPages unwraps the {pages} envelope into the array', async () => { it('getChapterPages unwraps the {pages} envelope into the array', async () => {
fetchSpy.mockResolvedValueOnce( fetchSpy.mockResolvedValueOnce(
ok({ ok({

View File

@@ -40,6 +40,28 @@ export async function listChapters(
); );
} }
/**
* Every chapter of a manga, in display order, paging through the API (which
* clamps `limit` to 200 per request). The reader needs the complete list so
* prev/next navigation and the chapter dropdown work for a deep chapter — a
* single 200-row window dead-ended chapter #250 with null neighbours.
*/
export async function listAllChapters(mangaId: string): Promise<Chapter[]> {
const PAGE = 200;
const all: Chapter[] = [];
let offset = 0;
// Hard iteration cap (200 * 100 = 20k chapters) so a bad `total` can never
// spin forever.
for (let i = 0; i < 100; i++) {
const { items, page } = await listChapters(mangaId, { limit: PAGE, offset });
all.push(...items);
offset += items.length;
const total = page.total ?? all.length;
if (items.length < PAGE || all.length >= total) break;
}
return all;
}
export async function getChapter(mangaId: string, chapterId: string): Promise<Chapter> { export async function getChapter(mangaId: string, chapterId: string): Promise<Chapter> {
return request<Chapter>( return request<Chapter>(
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}` `/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}`

View File

@@ -1,5 +1,5 @@
import { getManga } from '$lib/api/mangas'; import { getManga } from '$lib/api/mangas';
import { getChapter, getChapterPages, listChapters } from '$lib/api/chapters'; import { getChapter, getChapterPages, listAllChapters } from '$lib/api/chapters';
import { getMyReadProgressForManga } from '$lib/api/read_progress'; import { getMyReadProgressForManga } from '$lib/api/read_progress';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
@@ -13,19 +13,18 @@ export const load: PageLoad = async ({ params, url }) => {
// `null` for guests or first-time openers — the reader uses // `null` for guests or first-time openers — the reader uses
// this to seed its session-local high-water mark. // this to seed its session-local high-water mark.
getMyReadProgressForManga(params.id), getMyReadProgressForManga(params.id),
// Loaded so the reader can compute prev/next chapter for the // The FULL chapter list (paged through the API's 200-row cap) so the
// chevron-driven chapter navigation. limit=200 covers every // prev/next chevrons and the chapter dropdown work even for a deep
// realistic series; mangas with more chapters will lose some // chapter — a single 200-row window dead-ended chapter #250 with null
// chapter-jump precision at the tail edge but the in-page // neighbours.
// navigation still works fine. listAllChapters(params.id)
listChapters(params.id, { limit: 200 })
]); ]);
return { return {
manga, manga,
chapter, chapter,
pages, pages,
readProgress, readProgress,
chapters: chapterList.items, chapters: chapterList,
// `?page=N` lets the prev-chapter chevron land directly on the // `?page=N` lets the prev-chapter chevron land directly on the
// last page of the chapter it just navigated to. `last` is a // last page of the chapter it just navigated to. `last` is a
// convenience sentinel for "however many pages this chapter // convenience sentinel for "however many pages this chapter