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>
210 lines
6.8 KiB
Svelte
210 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
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 Upload from '@lucide/svelte/icons/upload';
|
|
import Eye from '@lucide/svelte/icons/eye';
|
|
|
|
let { data } = $props();
|
|
const uploads = $derived(data.uploads);
|
|
|
|
function clearOne(p: ReadProgressSummary): Promise<void> {
|
|
return clearReadProgress(p.manga_id);
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString();
|
|
}
|
|
</script>
|
|
|
|
{#if data.error}
|
|
<p class="error" role="alert" data-testid="history-error">
|
|
Couldn't load history: {data.error}
|
|
</p>
|
|
{:else if !data.authenticated}
|
|
<p class="hint" data-testid="history-signin">
|
|
<a href="/login?next=/profile/history">Sign in</a> to see your reading and upload history.
|
|
</p>
|
|
{:else}
|
|
<section aria-labelledby="reading-heading">
|
|
<h2 id="reading-heading">
|
|
<Eye size={18} aria-hidden="true" />
|
|
<span>Reading history</span>
|
|
</h2>
|
|
<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">
|
|
<h2 id="uploads-heading">
|
|
<Upload size={18} aria-hidden="true" />
|
|
<span>Uploads</span>
|
|
</h2>
|
|
{#if uploads.length === 0}
|
|
<p class="hint" data-testid="history-uploads-empty">
|
|
You haven't uploaded anything yet. Head to
|
|
<a href="/upload">Upload</a> to add a manga or a chapter.
|
|
</p>
|
|
{:else}
|
|
<ul class="entry-list" data-testid="history-uploads-list">
|
|
{#each uploads as u}
|
|
{#if u.kind === 'manga'}
|
|
<li class="entry">
|
|
<a
|
|
href="/manga/{u.manga.id}"
|
|
class="cover-link"
|
|
tabindex="-1"
|
|
aria-hidden="true"
|
|
>
|
|
{#if u.manga.cover_image_path}
|
|
<img
|
|
src={fileUrl(u.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/{u.manga.id}" class="title">
|
|
{u.manga.title}
|
|
</a>
|
|
<span class="target muted">New manga</span>
|
|
<span class="when">Uploaded {formatDate(u.created_at)}</span>
|
|
</div>
|
|
</li>
|
|
{:else}
|
|
<li class="entry">
|
|
<a
|
|
href="/manga/{u.manga_id}"
|
|
class="cover-link"
|
|
tabindex="-1"
|
|
aria-hidden="true"
|
|
>
|
|
{#if u.manga_cover_image_path}
|
|
<img
|
|
src={fileUrl(u.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/{u.manga_id}" class="title">{u.manga_title}</a>
|
|
<span class="target">
|
|
<a href="/manga/{u.manga_id}/chapter/{u.chapter.id}">
|
|
{chapterLabel(u.chapter)}
|
|
</a>
|
|
<span class="muted">({u.chapter.page_count} pages)</span>
|
|
</span>
|
|
<span class="when">Uploaded {formatDate(u.created_at)}</span>
|
|
</div>
|
|
</li>
|
|
{/if}
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
h2 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
font-size: var(--font-lg);
|
|
margin: 0 0 var(--space-2);
|
|
}
|
|
|
|
.uploads-section {
|
|
margin-top: var(--space-5);
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.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);
|
|
}
|
|
</style>
|