feat: read & upload history (0.19.0)
Per-user reading progress and uploader attribution. Schema (migration 0011): `read_progress` table (one row per (user, manga); chapter_id nullable on chapter delete) and nullable `uploaded_by` columns on mangas + chapters with partial indexes scoped to non-null rows. Endpoints (all `/me/*`, auth-scoped): - PUT `/v1/me/read-progress` upserts. FK violations + cross-manga chapter ids both surface as 4xx (404 / 422) so the API can't be used to write logically invalid rows. - GET `/v1/me/read-progress` paged newest-first list. - GET `/v1/me/read-progress/:manga_id` enriched with chapter_number for the manga page's Continue CTA. - DELETE `/v1/me/read-progress/:manga_id` idempotent. - GET `/v1/me/uploads` interleaved manga + chapter uploads as a tagged union; limit-only pagination. Existing manga + chapter upload handlers stamp `uploaded_by`. Frontend: - Reader emits progress on mount + page change (debounce) and via IntersectionObserver in continuous mode. High-water mark is seeded from the persisted server value so re-opening a chapter doesn't regress to page 1. Tab close survives via `sendBeacon` (fallback `keepalive` fetch); SPA navigation flushes via regular fetch. - Manga detail page shows "Continue reading Chapter N — page M" above the chapters list, working even for mangas with >50 chapters. - New `/profile/history` tab with reading history (clear-per-row, inline error on failure) and uploads (mangas + chapters mixed chronologically with type-aware rendering). 171 backend tests (incl. 16 history tests covering ownership, FK race, cross-link guard, chapter SET NULL behaviour) and 97 frontend tests + svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
106
frontend/src/lib/api/read_progress.ts
Normal file
106
frontend/src/lib/api/read_progress.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ApiError, request, type Page } from './client';
|
||||
|
||||
export type ReadProgress = {
|
||||
user_id: string;
|
||||
manga_id: string;
|
||||
chapter_id: string | null;
|
||||
page: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ReadProgressSummary = {
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
manga_cover_image_path: string | null;
|
||||
chapter_id: string | null;
|
||||
/** `null` if the chapter was deleted after the progress was written. */
|
||||
chapter_number: number | null;
|
||||
page: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ReadProgressPage = {
|
||||
items: ReadProgressSummary[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type UpsertReadProgress = {
|
||||
manga_id: string;
|
||||
chapter_id?: string | null;
|
||||
page?: number | null;
|
||||
};
|
||||
|
||||
export async function updateReadProgress(
|
||||
input: UpsertReadProgress
|
||||
): Promise<ReadProgress> {
|
||||
return request<ReadProgress>('/v1/me/read-progress', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMyReadProgress(
|
||||
opts: { limit?: number; offset?: number } = {}
|
||||
): Promise<ReadProgressPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return request<ReadProgressPage>(
|
||||
`/v1/me/read-progress${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMyReadProgressOrEmpty(): Promise<ReadProgressPage> {
|
||||
try {
|
||||
return await listMyReadProgress();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { items: [], page: { limit: 50, offset: 0, total: null } };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-manga response shape returned by GET /me/read-progress/:id.
|
||||
* Includes `chapter_number` so the "Continue reading" CTA can render
|
||||
* without resolving the chapter id against a paged chapters list.
|
||||
*/
|
||||
export type ReadProgressForManga = {
|
||||
manga_id: string;
|
||||
chapter_id: string | null;
|
||||
/** `null` if the chapter was deleted after the progress was written. */
|
||||
chapter_number: number | null;
|
||||
page: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the user's progress for a specific manga, or `null` when
|
||||
* they've never opened it (or aren't signed in). Used by the manga
|
||||
* detail page's "Continue from Ch. N" CTA and by the reader to seed
|
||||
* its session-local high-water mark from the persisted value.
|
||||
*/
|
||||
export async function getMyReadProgressForManga(
|
||||
mangaId: string
|
||||
): Promise<ReadProgressForManga | null> {
|
||||
try {
|
||||
return await request<ReadProgressForManga>(
|
||||
`/v1/me/read-progress/${encodeURIComponent(mangaId)}`
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && (e.status === 404 || e.status === 401)) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearReadProgress(mangaId: string): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/me/read-progress/${encodeURIComponent(mangaId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user