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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.88.0"
|
version = "0.89.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.88.0"
|
version = "0.89.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.88.0",
|
"version": "0.89.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
230
frontend/src/lib/components/HistoryList.svelte
Normal file
230
frontend/src/lib/components/HistoryList.svelte
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<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';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
|
// Shared reading-history list, used by both the desktop /profile/history
|
||||||
|
// page and the mobile Library "History" tab so the two can't drift in
|
||||||
|
// layout, labels, or fallback handling (they previously did). The clear
|
||||||
|
// affordance is opt-in: pass `onClear` to enable per-row removal — the
|
||||||
|
// component owns the optimistic-removal UX (instant remove, rollback +
|
||||||
|
// inline error on failure) so every caller gets identical behavior.
|
||||||
|
let {
|
||||||
|
entries,
|
||||||
|
onClear,
|
||||||
|
emptyText = 'Nothing here yet — open any manga and a row will land here once you turn a page.',
|
||||||
|
testid = 'history'
|
||||||
|
}: {
|
||||||
|
entries: ReadProgressSummary[];
|
||||||
|
onClear?: (p: ReadProgressSummary) => Promise<void>;
|
||||||
|
emptyText?: string;
|
||||||
|
testid?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Internal working copy so optimistic removal can mutate without
|
||||||
|
// reaching back into the caller's loaded data. Seeded from `entries`;
|
||||||
|
// the caller's list is the initial truth, this owns it from then on.
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let rows = $state<ReadProgressSummary[]>([...entries]);
|
||||||
|
let clearError = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function clear(p: ReadProgressSummary) {
|
||||||
|
if (!onClear) return;
|
||||||
|
clearError = null;
|
||||||
|
const snapshot = rows;
|
||||||
|
rows = rows.filter((x) => x.manga_id !== p.manga_id);
|
||||||
|
try {
|
||||||
|
await onClear(p);
|
||||||
|
} catch (e) {
|
||||||
|
// Roll back the optimistic removal and surface inline rather than
|
||||||
|
// via alert() — keeps the surface non-modal and unit-testable.
|
||||||
|
rows = snapshot;
|
||||||
|
clearError = `Couldn't clear "${p.manga_title}": ${(e as Error).message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built in script rather than inline so the " — page N" suffix keeps its
|
||||||
|
// surrounding spaces — Svelte trims whitespace at `{#if}` block edges,
|
||||||
|
// which previously rendered "Chapter 3— page 7" with no space.
|
||||||
|
function continueLabel(p: ReadProgressSummary): string {
|
||||||
|
// Only rendered behind a `chapter_number != null` guard in the
|
||||||
|
// template; narrow here so the helper doesn't depend on that.
|
||||||
|
const label = p.chapter_number != null ? chapterLabel({ number: p.chapter_number, title: null }) : '';
|
||||||
|
const base = `Continue ${label}`;
|
||||||
|
return p.page > 1 ? `${base} — page ${p.page}` : base;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if clearError}
|
||||||
|
<p class="error" role="alert" data-testid="{testid}-error">
|
||||||
|
{clearError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if rows.length === 0}
|
||||||
|
<p class="hint" data-testid="{testid}-empty">{emptyText}</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="entry-list" data-testid="{testid}-list">
|
||||||
|
{#each rows 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="{testid}-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}">
|
||||||
|
{continueLabel(p)}
|
||||||
|
</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>
|
||||||
|
{#if onClear}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn danger"
|
||||||
|
onclick={() => clear(p)}
|
||||||
|
aria-label={`Clear ${p.manga_title} from history`}
|
||||||
|
title="Clear from history"
|
||||||
|
data-testid="{testid}-clear-{p.manga_id}"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.entry-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px 1fr auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-link {
|
||||||
|
display: block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 56px;
|
||||||
|
height: 84px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.when {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
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>
|
||||||
103
frontend/src/lib/components/HistoryList.svelte.test.ts
Normal file
103
frontend/src/lib/components/HistoryList.svelte.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
|
import HistoryList from './HistoryList.svelte';
|
||||||
|
import type { ReadProgressSummary } from '$lib/api/read_progress';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
function entry(over: Partial<ReadProgressSummary> = {}): ReadProgressSummary {
|
||||||
|
return {
|
||||||
|
manga_id: 'm1',
|
||||||
|
manga_title: 'Berserk',
|
||||||
|
manga_cover_image_path: null,
|
||||||
|
chapter_id: 'c1',
|
||||||
|
chapter_number: 5,
|
||||||
|
page: 1,
|
||||||
|
updated_at: '2026-01-02T00:00:00Z',
|
||||||
|
...over
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HistoryList', () => {
|
||||||
|
it('renders one row per entry with a continue link to the chapter', () => {
|
||||||
|
render(HistoryList, {
|
||||||
|
props: { entries: [entry({ manga_id: 'm1', chapter_id: 'c9', chapter_number: 9 })] }
|
||||||
|
});
|
||||||
|
const cont = screen.getByRole('link', { name: /Continue Chapter 9/i });
|
||||||
|
expect(cont.getAttribute('href')).toBe('/manga/m1/chapter/c9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the page number to the continue line only past page 1', () => {
|
||||||
|
// The page suffix is a separate text node from "Continue Chapter N",
|
||||||
|
// so assert against the link's normalized text rather than getByText.
|
||||||
|
render(HistoryList, { props: { entries: [entry({ page: 7, chapter_number: 3 })] } });
|
||||||
|
const link = screen.getByRole('link', { name: /Continue Chapter 3/i });
|
||||||
|
expect(link.textContent?.replace(/\s+/g, ' ').trim()).toBe('Continue Chapter 3 — page 7');
|
||||||
|
cleanup();
|
||||||
|
render(HistoryList, { props: { entries: [entry({ page: 1, chapter_number: 3 })] } });
|
||||||
|
expect(screen.queryByText(/page 1/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a "chapter removed" fallback when the chapter id survives but its number is gone', () => {
|
||||||
|
render(HistoryList, {
|
||||||
|
props: { entries: [entry({ chapter_id: 'c1', chapter_number: null })] }
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/chapter removed/i)).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('link', { name: /Continue/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a "whole manga" fallback when there is no chapter', () => {
|
||||||
|
render(HistoryList, {
|
||||||
|
props: { entries: [entry({ chapter_id: null, chapter_number: null, page: 4 })] }
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/Whole manga, page 4/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state with the provided copy and testid when there are no entries', () => {
|
||||||
|
render(HistoryList, {
|
||||||
|
props: { entries: [], emptyText: 'Nothing here yet.', testid: 'library-history' }
|
||||||
|
});
|
||||||
|
const empty = screen.getByTestId('library-history-empty');
|
||||||
|
expect(empty.textContent).toContain('Nothing here yet.');
|
||||||
|
expect(screen.queryByTestId('library-history-list')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits clear buttons entirely when no onClear is supplied (read-only mode)', () => {
|
||||||
|
render(HistoryList, { props: { entries: [entry()] } });
|
||||||
|
expect(screen.queryByRole('button', { name: /Clear/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('optimistically removes a row and calls onClear when the clear button is pressed', async () => {
|
||||||
|
const onClear = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(HistoryList, {
|
||||||
|
props: {
|
||||||
|
entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })],
|
||||||
|
onClear,
|
||||||
|
testid: 'history-reading'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i }));
|
||||||
|
expect(onClear).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ manga_id: 'm1' })
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Berserk')).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the row and surfaces an inline error when onClear rejects', async () => {
|
||||||
|
const onClear = vi.fn().mockRejectedValue(new Error('network down'));
|
||||||
|
render(HistoryList, {
|
||||||
|
props: {
|
||||||
|
entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })],
|
||||||
|
onClear,
|
||||||
|
testid: 'history-reading'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const err = screen.getByTestId('history-reading-error');
|
||||||
|
expect(err.textContent).toMatch(/network down/i);
|
||||||
|
});
|
||||||
|
// Optimistic removal rolled back — the row is back.
|
||||||
|
expect(screen.getByText('Berserk')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { fileUrl } from '$lib/api/client';
|
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
||||||
import { chapterLabel } from '$lib/api/chapters';
|
|
||||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||||
import PageTagsList from '$lib/components/PageTagsList.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();
|
let { data } = $props();
|
||||||
|
|
||||||
|
function clearOne(p: ReadProgressSummary): Promise<void> {
|
||||||
|
return clearReadProgress(p.manga_id);
|
||||||
|
}
|
||||||
|
|
||||||
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
|
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
|
||||||
const TABS: { label: string; value: Tab }[] = [
|
const TABS: { label: string; value: Tab }[] = [
|
||||||
{ label: 'Bookmarks', value: 'bookmarks' },
|
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||||
@@ -91,53 +94,13 @@
|
|||||||
initialItems={data.pageTags}
|
initialItems={data.pageTags}
|
||||||
initialDistinct={data.distinctPageTags}
|
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}
|
{:else}
|
||||||
<ul class="history-list" data-testid="library-history-list">
|
<HistoryList
|
||||||
{#each data.history as p (p.manga_id)}
|
entries={data.history}
|
||||||
<li class="history-row">
|
onClear={clearOne}
|
||||||
<a
|
emptyText="Nothing here yet — open any manga and a row will land here once you turn a page."
|
||||||
href={p.chapter_id != null
|
testid="library-history"
|
||||||
? `/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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -165,65 +128,4 @@
|
|||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,30 +2,16 @@
|
|||||||
import { fileUrl } from '$lib/api/client';
|
import { fileUrl } from '$lib/api/client';
|
||||||
import { chapterLabel } from '$lib/api/chapters';
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
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 BookImage from '@lucide/svelte/icons/book-image';
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import Eye from '@lucide/svelte/icons/eye';
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
|
|
||||||
let { data } = $props();
|
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);
|
const uploads = $derived(data.uploads);
|
||||||
|
|
||||||
async function clearOne(p: ReadProgressSummary) {
|
function clearOne(p: ReadProgressSummary): Promise<void> {
|
||||||
clearError = null;
|
return clearReadProgress(p.manga_id);
|
||||||
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 formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
@@ -47,77 +33,12 @@
|
|||||||
<Eye size={18} aria-hidden="true" />
|
<Eye size={18} aria-hidden="true" />
|
||||||
<span>Reading history</span>
|
<span>Reading history</span>
|
||||||
</h2>
|
</h2>
|
||||||
{#if clearError}
|
<HistoryList
|
||||||
<p class="error inline" role="alert" data-testid="history-clear-error">
|
entries={data.progress}
|
||||||
{clearError}
|
onClear={clearOne}
|
||||||
</p>
|
emptyText="Nothing here yet — open any manga and a row will land here once you turn a page."
|
||||||
{/if}
|
testid="history-reading"
|
||||||
{#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}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="uploads-heading" class="uploads-section">
|
<section aria-labelledby="uploads-heading" class="uploads-section">
|
||||||
@@ -285,31 +206,4 @@
|
|||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user