Real-world sources publish multiple chapters at the same number:
different scanlators ("Ch.52 from bloomingdale" + "Ch.52 from mina"),
translator notices and farewells, alt-translations. The (manga_id,
number) UNIQUE constraint from 0001 silently collapsed all of those
into a single row via the upsert path in repo::crawler. Migration 0013
drops the constraint; sync_manga_chapters now plain-INSERTs each
SourceChapterRef so every parsed chapter survives as its own row.
Identity moves from the (manga_id, number) tuple to the chapter UUID:
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id` (replaces :number)
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id/pages`
- `repo::chapter::find_by_id_in_manga` (replaces find_by_manga_and_number)
- Frontend reader route renamed to `/manga/[id]/chapter/[chapter_id]`
- Chapter links throughout (manga page list, continue-reading CTA,
reader prev/next, history rows, bookmark cards) use chapter.id
- API clients getChapter/getChapterPages take a chapter id string
read_progress + bookmarks already FK chapter_id; they only enrich with
chapter_number for display, which is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
315 lines
11 KiB
Svelte
315 lines
11 KiB
Svelte
<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_id != null
|
|
? `/manga/${p.manga_id}/chapter/${p.chapter_id}`
|
|
: `/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_id != null && p.chapter_number != null}
|
|
<a
|
|
href="/manga/{p.manga_id}/chapter/{p.chapter_id}"
|
|
>
|
|
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.id}">
|
|
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>
|