Files
Mangalord/frontend/src/lib/components/HistoryList.svelte
MechaCat02 f5692ea109 feat(ui): show a tooltip with the full text on truncated titles
Add an `overflowTooltip` Svelte action that sets a native `title` only when
an element is actually clipped (ellipsis or line-clamp), re-checking on
resize. Native title is keyboard/touch reachable and zero-dep, unlike a
hover-only popover. Applied to the clipped titles on MangaCard, BookmarkList,
HistoryList, and the Continue-reading shelf.

Unit tests cover the overflow decision and the set/clear/update behaviour;
an e2e verifies a real-browser truncated card title gets the tooltip while a
short one does not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:08:28 +02:00

218 lines
7.1 KiB
Svelte

<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import { overflowTooltip } from '$lib/actions/overflowTooltip';
import type { ReadProgressSummary } from '$lib/api/read_progress';
import IconButton from '$lib/components/IconButton.svelte';
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"
use:overflowTooltip={p.manga_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}
<IconButton
variant="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" />
</IconButton>
{/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);
}
</style>