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>
130 lines
3.5 KiB
Svelte
130 lines
3.5 KiB
Svelte
<script lang="ts">
|
|
import { fileUrl } from '$lib/api/client';
|
|
import type { Bookmark } from '$lib/api/bookmarks';
|
|
import BookImage from '@lucide/svelte/icons/book-image';
|
|
|
|
let {
|
|
bookmarks,
|
|
testid
|
|
}: {
|
|
bookmarks: Bookmark[];
|
|
testid?: string;
|
|
} = $props();
|
|
</script>
|
|
|
|
<ul class="bookmark-list" data-testid={testid ?? 'bookmark-list'}>
|
|
{#each bookmarks as b (b.id)}
|
|
<li class="bookmark">
|
|
<a href="/manga/{b.manga_id}" class="cover-link" aria-hidden="true" tabindex="-1">
|
|
{#if b.manga_cover_image_path}
|
|
<img
|
|
src={fileUrl(b.manga_cover_image_path)}
|
|
alt=""
|
|
class="cover"
|
|
loading="lazy"
|
|
/>
|
|
{:else}
|
|
<div class="cover cover-placeholder">
|
|
<BookImage size={22} aria-hidden="true" />
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
<div class="meta">
|
|
<a
|
|
href="/manga/{b.manga_id}"
|
|
class="title"
|
|
data-testid="bookmark-title"
|
|
>
|
|
{b.manga_title ?? 'Unknown manga'}
|
|
</a>
|
|
{#if b.chapter_id && b.chapter_number != null}
|
|
<a
|
|
href="/manga/{b.manga_id}/chapter/{b.chapter_id}"
|
|
class="target"
|
|
>
|
|
Chapter {b.chapter_number}{#if b.page != null && b.page > 0} — page {b.page}{/if}
|
|
</a>
|
|
{:else if b.chapter_id}
|
|
<!-- Chapter bookmark whose chapter was deleted;
|
|
chapter_id != null but chapter_number == null
|
|
because the LEFT JOIN found nothing. -->
|
|
<span class="target muted">(chapter removed)</span>
|
|
{:else}
|
|
<span class="target muted">Whole manga</span>
|
|
{/if}
|
|
<span class="created">
|
|
Bookmarked {new Date(b.created_at).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
<style>
|
|
.bookmark-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.bookmark {
|
|
display: grid;
|
|
grid-template-columns: 64px 1fr;
|
|
gap: var(--space-4);
|
|
align-items: start;
|
|
padding: var(--space-3) 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.cover-link {
|
|
display: block;
|
|
line-height: 0;
|
|
}
|
|
|
|
.cover {
|
|
width: 64px;
|
|
height: 96px;
|
|
object-fit: cover;
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface);
|
|
}
|
|
|
|
.cover-placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-muted);
|
|
user-select: none;
|
|
}
|
|
|
|
.meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
min-width: 0;
|
|
}
|
|
|
|
.title {
|
|
font-weight: var(--weight-semibold);
|
|
font-size: var(--font-base);
|
|
color: var(--text);
|
|
}
|
|
|
|
.title:hover {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.target {
|
|
font-size: var(--font-sm);
|
|
}
|
|
|
|
.muted {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.created {
|
|
color: var(--text-muted);
|
|
font-size: var(--font-xs);
|
|
}
|
|
</style>
|