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:
@@ -18,6 +18,20 @@
|
||||
let { data } = $props();
|
||||
const manga = $derived(data.manga);
|
||||
const chapters = $derived(data.chapters);
|
||||
const readProgress = $derived(data.readProgress);
|
||||
/** Chapter row from the local chapters list when present (so we
|
||||
* can also surface the chapter title). Falls back below to the
|
||||
* server-supplied `chapter_number` when the chapter sits past
|
||||
* the first page of `chapters` (large mangas with >50 chapters). */
|
||||
const continueChapter = $derived(
|
||||
readProgress?.chapter_id
|
||||
? chapters.find((c) => c.id === readProgress.chapter_id) ?? null
|
||||
: null
|
||||
);
|
||||
const continueChapterNumber = $derived(
|
||||
continueChapter?.number ?? readProgress?.chapter_number ?? null
|
||||
);
|
||||
const continueChapterTitle = $derived(continueChapter?.title ?? null);
|
||||
|
||||
const authors = $derived<AuthorRef[]>(manga.authors);
|
||||
const genres = $derived<GenreRef[]>(manga.genres);
|
||||
@@ -328,6 +342,21 @@
|
||||
|
||||
<section aria-label="chapters">
|
||||
<h2>Chapters</h2>
|
||||
{#if continueChapterNumber != null}
|
||||
<a
|
||||
class="continue"
|
||||
href="/manga/{manga.id}/chapter/{continueChapterNumber}"
|
||||
data-testid="continue-reading"
|
||||
>
|
||||
<span class="continue-label">Continue reading</span>
|
||||
<span class="continue-target">
|
||||
Chapter {continueChapterNumber}{#if continueChapterTitle}: {continueChapterTitle}{/if}
|
||||
{#if readProgress && readProgress.page > 1}
|
||||
— page {readProgress.page}
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapters.length === 0}
|
||||
<p data-testid="chapters-empty">No chapters yet.</p>
|
||||
{:else}
|
||||
@@ -536,6 +565,36 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.continue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: var(--space-3) 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--primary-soft-bg);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.continue:hover {
|
||||
background: var(--surface-elevated);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.continue-label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.continue-target {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
padding-left: var(--space-6);
|
||||
color: var(--text);
|
||||
|
||||
Reference in New Issue
Block a user