Files
Mangalord/frontend/src/lib/components/ContinueReadingShelf.svelte
MechaCat02 3622dcc02f fix: reserve detail cover box and async-decode list/shelf covers
- The manga detail desktop overview cover had no reserved aspect-ratio, so
  the meta column reflowed when it decoded. Give .cover aspect-ratio: 2/3 +
  object-fit: cover (mirrors MangaCard).
- Add decoding="async" to the detail hero/overview covers and the list/shelf
  covers (bookmarks, history, continue-reading, collections, tagged rows) so
  decode stays off the main thread — previously only MangaCard had it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:43:21 +02:00

171 lines
5.5 KiB
Svelte

<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import { overflowTooltip } from '$lib/actions/overflowTooltip';
import type { ReadProgressSummary } from '$lib/api/read_progress';
import BookImage from '@lucide/svelte/icons/book-image';
// Horizontal "Continue reading" shelf for the homepage. Fed the user's
// read-progress history (newest first); each card jumps straight back to
// where they left off and flags how many chapters have landed since.
// Rendered only when there are entries (the homepage owns that gate), so
// this component has no empty state.
let {
entries,
testid = 'continue-shelf'
}: {
entries: ReadProgressSummary[];
testid?: string;
} = $props();
// Deep-link to the exact page when past the first; page 1 is the reader's
// default so the query is omitted to keep the URL clean. No chapter (a
// manga-level progress row) falls back to the manga page.
function continueHref(p: ReadProgressSummary): string {
if (p.chapter_id == null) return `/manga/${p.manga_id}`;
const base = `/manga/${p.manga_id}/chapter/${p.chapter_id}`;
return p.page > 1 ? `${base}?page=${p.page}` : base;
}
function targetLabel(p: ReadProgressSummary): string {
if (p.chapter_number == null) return `Page ${p.page}`;
const label = chapterLabel({ number: p.chapter_number, title: null });
return p.page > 1 ? `${label} · p ${p.page}` : label;
}
</script>
<section class="shelf" aria-label="Continue reading" data-testid="{testid}">
<h2 class="shelf-title">Continue reading</h2>
<ul class="track" data-testid="{testid}-list">
{#each entries as p (p.manga_id)}
<li>
<a
href={continueHref(p)}
class="card"
data-testid="continue-card-{p.manga_id}"
>
<span class="cover-wrap">
{#if p.manga_cover_image_path}
<img
src={fileUrl(p.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
decoding="async"
/>
{:else}
<span class="cover cover-placeholder">
<BookImage size={28} aria-hidden="true" />
</span>
{/if}
{#if p.new_chapters_count > 0}
<span
class="new-badge"
data-testid="continue-new-badge-{p.manga_id}"
aria-label="{p.new_chapters_count} new chapter{p.new_chapters_count === 1 ? '' : 's'} since you last read"
>
{p.new_chapters_count > 99 ? '99+' : p.new_chapters_count} new
</span>
{/if}
</span>
<span class="title" data-testid="continue-title-{p.manga_id}" use:overflowTooltip={p.manga_title}>{p.manga_title}</span>
<span class="target">{targetLabel(p)}</span>
</a>
</li>
{/each}
</ul>
</section>
<style>
.shelf {
margin: 0 0 var(--space-5);
}
.shelf-title {
font-size: var(--font-md);
font-weight: var(--weight-semibold);
margin: 0 0 var(--space-2);
}
.track {
list-style: none;
display: flex;
gap: var(--space-3);
padding: 0 0 var(--space-2);
margin: 0;
overflow-x: auto;
scroll-snap-type: x proximity;
/* Momentum scrolling on iOS; keep the scrollbar unobtrusive. */
-webkit-overflow-scrolling: touch;
}
.card {
display: flex;
flex-direction: column;
width: 108px;
scroll-snap-align: start;
color: var(--text);
text-decoration: none;
}
.cover-wrap {
position: relative;
display: block;
line-height: 0;
}
.cover {
width: 108px;
height: 162px;
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);
}
.new-badge {
position: absolute;
top: var(--space-1);
right: var(--space-1);
padding: 0 6px;
height: 20px;
display: inline-flex;
align-items: center;
background: var(--primary);
color: var(--primary-contrast);
font-size: var(--font-xs);
font-weight: var(--weight-semibold);
line-height: 1;
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
pointer-events: none;
}
.title {
margin-top: var(--space-1);
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card:hover .title {
color: var(--primary);
}
.target {
font-size: var(--font-xs);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>