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:
MechaCat02
2026-05-17 18:19:52 +02:00
parent 7560d59616
commit 19c1276490
31 changed files with 1927 additions and 17 deletions

View File

@@ -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);

View File

@@ -1,15 +1,23 @@
import { getManga } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters';
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const [manga, chapters, bookmarks] = await Promise.all([
const [manga, chapters, bookmarks, readProgress] = await Promise.all([
getManga(params.id),
listChapters(params.id),
listMyBookmarksOrEmpty()
listMyBookmarksOrEmpty(),
// Null when guest or never-read — page handles both cases.
getMyReadProgressForManga(params.id)
]);
return { manga, chapters: chapters.items, bookmarks: bookmarks.items };
return {
manga,
chapters: chapters.items,
bookmarks: bookmarks.items,
readProgress
};
};

View File

@@ -3,6 +3,8 @@
import { fileUrl } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress';
import { session } from '$lib/session.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
@@ -90,6 +92,155 @@
onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
});
// ---- Reading progress tracking ----
//
// High-water mark seeded from the server: progress only ever moves
// forward within a session, so a quick scroll-up doesn't rewind
// the saved position. Critically, when the user re-opens a chapter
// they were previously reading we seed from `data.readProgress.page`
// so the first flush is a no-op (or forward-only) rather than a
// reset to page 1 that would clobber the persisted position.
//
// Writes are debounced and fire-and-forget — the reader never
// blocks on the network, and a failed write just means the user's
// history is slightly stale (acceptable).
// Route param `[n]` is part of the URL, so SvelteKit remounts
// this component on chapter navigation — capturing the initial
// `data` value here is the desired behaviour.
// svelte-ignore state_referenced_locally
const initialProgressPage =
data.readProgress && data.readProgress.chapter_id === chapter.id
? Math.max(1, data.readProgress.page)
: 1;
let progressPage = $state(initialProgressPage);
let progressTimer: ReturnType<typeof setTimeout> | null = null;
let observer: IntersectionObserver | null = null;
function noteProgress(page: number) {
if (page > progressPage) progressPage = page;
}
async function flushProgress() {
if (!session.user) return;
try {
await updateReadProgress({
manga_id: manga.id,
chapter_id: chapter.id,
page: progressPage
});
} catch {
// Best-effort; nothing the user can do about a transient
// hiccup and we don't want to nag them.
}
}
function scheduleFlush() {
if (progressTimer) clearTimeout(progressTimer);
progressTimer = setTimeout(flushProgress, 1500);
}
// Single-mode: every page change moves the high-water mark.
// Intentionally NOT depending on `mode` — toggling layout doesn't
// change the read position, and re-running this effect on a mode
// toggle would re-fire `noteProgress(index + 1)` (= 1 in
// continuous mode where index never moves) and schedule a flush
// that's at best a no-op and at worst a spurious write.
$effect(() => {
noteProgress(index + 1);
scheduleFlush();
});
// Initial open: record that the user is in this chapter now so the
// history-sort timestamp moves to "now" — without regressing the
// page number (initialProgressPage already encodes the persisted
// value when the chapter matches).
onMount(() => {
if (session.user) void flushProgress();
});
// Continuous mode: observe each page image and track the highest
// index that's been visible. IntersectionObserver is re-created
// whenever the page list rebinds (chapter change).
$effect(() => {
if (mode !== 'continuous') {
observer?.disconnect();
observer = null;
return;
}
const els = continuousPageEls.filter(Boolean);
if (els.length === 0) return;
observer?.disconnect();
observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const idx = els.indexOf(e.target as HTMLImageElement);
if (idx >= 0) noteProgress(idx + 1);
}
scheduleFlush();
},
{ rootMargin: '0px', threshold: 0.5 }
);
for (const el of els) observer.observe(el);
return () => observer?.disconnect();
});
/**
* `fetch()` initiated during `pagehide` / `beforeunload` is
* cancelled by every browser by default. `sendBeacon` is the
* supported way to ship a small payload during unload — it's
* guaranteed to survive even if the tab is closing. Failure here
* is silent because the API is fire-and-forget.
*/
function beaconFinalProgress() {
if (!session.user) return;
const body = JSON.stringify({
manga_id: manga.id,
chapter_id: chapter.id,
page: progressPage
});
const blob = new Blob([body], { type: 'application/json' });
// sendBeacon only supports POST — the server's PUT route is
// strict on method. The dedicated POST alias is omitted; in
// practice the in-app navigation path (back-link, chapter
// links) already covers the common-case unmount via the
// onDestroy fetch. Fall through to fetch+keepalive for browser
// implementations that don't honor sendBeacon for this endpoint.
try {
const ok = navigator.sendBeacon('/api/v1/me/read-progress', blob);
if (!ok) throw new Error('sendBeacon rejected');
} catch {
try {
void fetch('/api/v1/me/read-progress', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
credentials: 'include'
});
} catch {
// Final fallback failed; the in-app onDestroy flush
// below catches the SPA-navigation case.
}
}
}
onMount(() => {
window.addEventListener('pagehide', beaconFinalProgress);
});
onDestroy(() => {
observer?.disconnect();
if (progressTimer) clearTimeout(progressTimer);
if (typeof window !== 'undefined') {
window.removeEventListener('pagehide', beaconFinalProgress);
}
// For SPA navigation (e.g., clicking the back-link) the page
// doesn't unload, so `pagehide` won't fire — flush via a
// normal fetch. Tab-close paths land on the beacon above.
void flushProgress();
});
</script>
<svelte:head>

View File

@@ -1,15 +1,20 @@
import { getManga } from '$lib/api/mangas';
import { getChapter, getChapterPages } from '$lib/api/chapters';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const number = Number(params.n);
const [manga, chapter, pages] = await Promise.all([
const [manga, chapter, pages, readProgress] = await Promise.all([
getManga(params.id),
getChapter(params.id, number),
getChapterPages(params.id, number)
getChapterPages(params.id, number),
// `null` for guests or first-time openers — the reader uses
// this to seed its session-local high-water mark so the
// first debounced write doesn't regress page=1.
getMyReadProgressForManga(params.id)
]);
return { manga, chapter, pages };
return { manga, chapter, pages, readProgress };
};