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>
43 lines
1.2 KiB
TypeScript
43 lines
1.2 KiB
TypeScript
import { ApiError, request, type Manga, type Page } from './client';
|
|
import type { Chapter } from './chapters';
|
|
|
|
/**
|
|
* Tagged union returned by `GET /v1/me/uploads`. The discriminant lives
|
|
* on the `kind` field; pattern-match on it before accessing the rest.
|
|
*/
|
|
export type UploadEntry =
|
|
| { kind: 'manga'; manga: Manga; created_at: string }
|
|
| {
|
|
kind: 'chapter';
|
|
manga_id: string;
|
|
manga_title: string;
|
|
manga_cover_image_path: string | null;
|
|
chapter: Chapter;
|
|
created_at: string;
|
|
};
|
|
|
|
export type UploadsPage = {
|
|
items: UploadEntry[];
|
|
page: Page;
|
|
};
|
|
|
|
export async function listMyUploads(
|
|
opts: { limit?: number } = {}
|
|
): Promise<UploadsPage> {
|
|
const params = new URLSearchParams();
|
|
if (opts.limit != null) params.set('limit', String(opts.limit));
|
|
const qs = params.toString();
|
|
return request<UploadsPage>(`/v1/me/uploads${qs ? `?${qs}` : ''}`);
|
|
}
|
|
|
|
export async function listMyUploadsOrEmpty(): Promise<UploadsPage> {
|
|
try {
|
|
return await listMyUploads();
|
|
} catch (e) {
|
|
if (e instanceof ApiError && e.status === 401) {
|
|
return { items: [], page: { limit: 50, offset: 0, total: null } };
|
|
}
|
|
throw e;
|
|
}
|
|
}
|