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>
218 lines
7.1 KiB
Svelte
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>
|