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:
MechaCat02
2026-06-25 08:05:51 +02:00
parent 198a1d46b0
commit 2715275065
7 changed files with 357 additions and 228 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.88.1"
version = "0.89.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.88.1"
version = "0.89.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.88.1",
"version": "0.89.0",
"private": true,
"type": "module",
"scripts": {

View 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>

View 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();
});
});

View File

@@ -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>

View File

@@ -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>