feat(history): share one HistoryList across desktop and mobile
The desktop /profile/history page and the mobile Library "History" tab
each hand-rendered reading history with separate markup, which had drifted:
the Library tab was a read-only 2-column list with no date and no
removed-chapter / whole-manga fallbacks, while /profile/history was a
3-column list with a per-row clear button. Extract a shared HistoryList
component so the two can't diverge again.
- New HistoryList.svelte owns row rendering, the optimistic-removal UX
(instant remove, rollback + inline error on failure), and the empty
state. Clear is opt-in via an `onClear` prop so a caller can render
read-only, but both surfaces now pass it.
- The mobile Library History tab gains the clear action, the "Read {date}"
line, and the (chapter removed) / whole-manga fallbacks it was missing.
- Continue label is built in script (was inline) so the " — page N" suffix
keeps its spaces — Svelte trimmed them at the {#if} edge, rendering
"Chapter 3— page 7". Both surfaces now read "Continue Chapter N".
Net -204 lines across the two pages. Uploads parity on the mobile tab is
left as a follow-up (it needs the library loader to fetch uploads).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
import HistoryList from '$lib/components/HistoryList.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
function clearOne(p: ReadProgressSummary): Promise<void> {
|
||||
return clearReadProgress(p.manga_id);
|
||||
}
|
||||
|
||||
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
|
||||
const TABS: { label: string; value: Tab }[] = [
|
||||
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||
@@ -91,53 +94,13 @@
|
||||
initialItems={data.pageTags}
|
||||
initialDistinct={data.distinctPageTags}
|
||||
/>
|
||||
{:else if data.history.length === 0}
|
||||
<p class="hint" data-testid="library-history-empty">
|
||||
Nothing here yet — open any manga and a row will land here once you turn
|
||||
a page.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="history-list" data-testid="library-history-list">
|
||||
{#each data.history as p (p.manga_id)}
|
||||
<li class="history-row">
|
||||
<a
|
||||
href={p.chapter_id != null
|
||||
? `/manga/${p.manga_id}/chapter/${p.chapter_id}`
|
||||
: `/manga/${p.manga_id}`}
|
||||
class="cover-link"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#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={18} aria-hidden="true" />
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a href="/manga/{p.manga_id}" class="title">{p.manga_title}</a>
|
||||
{#if p.chapter_id != null && p.chapter_number != null}
|
||||
<a
|
||||
class="target"
|
||||
href="/manga/{p.manga_id}/chapter/{p.chapter_id}"
|
||||
>
|
||||
Continue {chapterLabel({
|
||||
number: p.chapter_number,
|
||||
title: null
|
||||
})}{#if p.page > 1} — page {p.page}{/if}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<HistoryList
|
||||
entries={data.history}
|
||||
onClear={clearOne}
|
||||
emptyText="Nothing here yet — open any manga and a row will land here once you turn a page."
|
||||
testid="library-history"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -165,65 +128,4 @@
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.history-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cover-link {
|
||||
display: block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 56px;
|
||||
aspect-ratio: 2 / 3;
|
||||
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);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.target {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,30 +2,16 @@
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
||||
import HistoryList from '$lib/components/HistoryList.svelte';
|
||||
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 clearOne(p: ReadProgressSummary): Promise<void> {
|
||||
return clearReadProgress(p.manga_id);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -47,77 +33,12 @@
|
||||
<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}
|
||||
<HistoryList
|
||||
entries={data.progress}
|
||||
onClear={clearOne}
|
||||
emptyText="Nothing here yet — open any manga and a row will land here once you turn a page."
|
||||
testid="history-reading"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="uploads-heading" class="uploads-section">
|
||||
@@ -285,31 +206,4 @@
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user