- 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>
171 lines
5.5 KiB
Svelte
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>
|