feat(home): add a Continue-reading shelf with new-chapter badges
Surface a horizontal "Continue reading" shelf at the top of the homepage, fed by the user's read-progress history. Each card jumps back to the exact page they left off (deep-linking `?page=N` past the first) and flags how many chapters have landed since — the personal new_chapters_count from the read-progress list. The shelf is fetched after the catalogue load so the public browse path stays unauthenticated; guests get an empty list (401 swallowed) and the shelf stays hidden. Add new_chapters_count to the frontend ReadProgressSummary type to match the backend. Component unit tests cover href/deep-link, badge visibility, and the no-chapter fallback; e2e covers signed-in (shelf + badge) and anonymous (hidden). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
168
frontend/src/lib/components/ContinueReadingShelf.svelte
Normal file
168
frontend/src/lib/components/ContinueReadingShelf.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
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"
|
||||
/>
|
||||
{: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}">{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>
|
||||
Reference in New Issue
Block a user