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

@@ -0,0 +1,314 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
import BookImage from '@lucide/svelte/icons/book-image';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Eye from '@lucide/svelte/icons/eye';
let { data } = $props();
// svelte-ignore state_referenced_locally
let progress = $state<ReadProgressSummary[]>([...data.progress]);
let clearError = $state<string | null>(null);
const uploads = $derived(data.uploads);
async function clearOne(p: ReadProgressSummary) {
clearError = null;
const snapshot = progress;
progress = progress.filter((x) => x.manga_id !== p.manga_id);
try {
await clearReadProgress(p.manga_id);
} catch (e) {
// Roll back optimistic removal and surface inline rather
// than via alert() — keeps the page non-modal and
// testable.
progress = snapshot;
clearError = `Couldn't clear "${p.manga_title}": ${(e as Error).message}`;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
</script>
{#if data.error}
<p class="error" role="alert" data-testid="history-error">
Couldn't load history: {data.error}
</p>
{:else if !data.authenticated}
<p class="hint" data-testid="history-signin">
<a href="/login?next=/profile/history">Sign in</a> to see your reading and upload history.
</p>
{:else}
<section aria-labelledby="reading-heading">
<h2 id="reading-heading">
<Eye size={18} aria-hidden="true" />
<span>Reading history</span>
</h2>
{#if clearError}
<p class="error inline" role="alert" data-testid="history-clear-error">
{clearError}
</p>
{/if}
{#if progress.length === 0}
<p class="hint" data-testid="history-reading-empty">
Nothing here yet — open any manga and a row will land here once you turn a page.
</p>
{:else}
<ul class="entry-list" data-testid="history-reading-list">
{#each progress as p (p.manga_id)}
<li class="entry">
<a
href={p.chapter_number != null
? `/manga/${p.manga_id}/chapter/${p.chapter_number}`
: `/manga/${p.manga_id}`}
class="cover-link"
tabindex="-1"
aria-hidden="true"
>
{#if p.manga_cover_image_path}
<img
src={fileUrl(p.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">
<BookImage size={20} aria-hidden="true" />
</div>
{/if}
</a>
<div class="meta">
<a
href="/manga/{p.manga_id}"
class="title"
data-testid="history-reading-title"
>
{p.manga_title}
</a>
<span class="target">
{#if p.chapter_number != null}
<a
href="/manga/{p.manga_id}/chapter/{p.chapter_number}"
>
Continue Ch. {p.chapter_number}{#if p.page > 1} — page {p.page}{/if}
</a>
{:else if p.chapter_id}
<span class="muted">(chapter removed)</span>
{:else}
<span class="muted">Whole manga, page {p.page}</span>
{/if}
</span>
<span class="when">Read {formatDate(p.updated_at)}</span>
</div>
<button
type="button"
class="icon-btn danger"
onclick={() => clearOne(p)}
aria-label={`Clear ${p.manga_title} from history`}
title="Clear from history"
data-testid={`history-clear-${p.manga_id}`}
>
<Trash2 size={16} aria-hidden="true" />
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section aria-labelledby="uploads-heading" class="uploads-section">
<h2 id="uploads-heading">
<Upload size={18} aria-hidden="true" />
<span>Uploads</span>
</h2>
{#if uploads.length === 0}
<p class="hint" data-testid="history-uploads-empty">
You haven't uploaded anything yet. Head to
<a href="/upload">Upload</a> to add a manga or a chapter.
</p>
{:else}
<ul class="entry-list" data-testid="history-uploads-list">
{#each uploads as u}
{#if u.kind === 'manga'}
<li class="entry">
<a
href="/manga/{u.manga.id}"
class="cover-link"
tabindex="-1"
aria-hidden="true"
>
{#if u.manga.cover_image_path}
<img
src={fileUrl(u.manga.cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">
<BookImage size={20} aria-hidden="true" />
</div>
{/if}
</a>
<div class="meta">
<a href="/manga/{u.manga.id}" class="title">
{u.manga.title}
</a>
<span class="target muted">New manga</span>
<span class="when">Uploaded {formatDate(u.created_at)}</span>
</div>
</li>
{:else}
<li class="entry">
<a
href="/manga/{u.manga_id}"
class="cover-link"
tabindex="-1"
aria-hidden="true"
>
{#if u.manga_cover_image_path}
<img
src={fileUrl(u.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">
<BookImage size={20} aria-hidden="true" />
</div>
{/if}
</a>
<div class="meta">
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
<span class="target">
<a href="/manga/{u.manga_id}/chapter/{u.chapter.number}">
Chapter {u.chapter.number}{#if u.chapter.title}: {u.chapter.title}{/if}
</a>
<span class="muted">({u.chapter.page_count} pages)</span>
</span>
<span class="when">Uploaded {formatDate(u.created_at)}</span>
</div>
</li>
{/if}
{/each}
</ul>
{/if}
</section>
{/if}
<style>
h2 {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-lg);
margin: 0 0 var(--space-2);
}
.uploads-section {
margin-top: var(--space-5);
}
.entry-list {
list-style: none;
padding: 0;
margin: 0;
}
.entry {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border);
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 56px;
height: 84px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
color: var(--text);
}
.title:hover {
color: var(--primary);
}
.target {
font-size: var(--font-sm);
}
.muted {
color: var(--text-muted);
}
.when {
color: var(--text-muted);
font-size: var(--font-xs);
}
.hint {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
.error.inline {
background: var(--danger-soft-bg);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
margin: 0 0 var(--space-2);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
color: var(--text-muted);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
}
.icon-btn.danger:hover {
color: var(--danger);
background: var(--surface-elevated);
}
</style>