Merge branch 'feat/shared-history-list' into chore/reconcile-ui-review
# Conflicts: # backend/Cargo.lock # backend/Cargo.toml # frontend/package.json
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.88.1"
|
||||
version = "0.89.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.88.1"
|
||||
version = "0.89.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.88.1",
|
||||
"version": "0.89.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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">
|
||||
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