feat: read & upload history (0.19.0)
Per-user reading progress and uploader attribution. Schema (migration 0011): `read_progress` table (one row per (user, manga); chapter_id nullable on chapter delete) and nullable `uploaded_by` columns on mangas + chapters with partial indexes scoped to non-null rows. Endpoints (all `/me/*`, auth-scoped): - PUT `/v1/me/read-progress` upserts. FK violations + cross-manga chapter ids both surface as 4xx (404 / 422) so the API can't be used to write logically invalid rows. - GET `/v1/me/read-progress` paged newest-first list. - GET `/v1/me/read-progress/:manga_id` enriched with chapter_number for the manga page's Continue CTA. - DELETE `/v1/me/read-progress/:manga_id` idempotent. - GET `/v1/me/uploads` interleaved manga + chapter uploads as a tagged union; limit-only pagination. Existing manga + chapter upload handlers stamp `uploaded_by`. Frontend: - Reader emits progress on mount + page change (debounce) and via IntersectionObserver in continuous mode. High-water mark is seeded from the persisted server value so re-opening a chapter doesn't regress to page 1. Tab close survives via `sendBeacon` (fallback `keepalive` fetch); SPA navigation flushes via regular fetch. - Manga detail page shows "Continue reading Chapter N — page M" above the chapters list, working even for mangas with >50 chapters. - New `/profile/history` tab with reading history (clear-per-row, inline error on failure) and uploads (mangas + chapters mixed chronologically with type-aware rendering). 171 backend tests (incl. 16 history tests covering ownership, FK race, cross-link guard, chapter SET NULL behaviour) and 97 frontend tests + svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,20 @@
|
||||
let { data } = $props();
|
||||
const manga = $derived(data.manga);
|
||||
const chapters = $derived(data.chapters);
|
||||
const readProgress = $derived(data.readProgress);
|
||||
/** Chapter row from the local chapters list when present (so we
|
||||
* can also surface the chapter title). Falls back below to the
|
||||
* server-supplied `chapter_number` when the chapter sits past
|
||||
* the first page of `chapters` (large mangas with >50 chapters). */
|
||||
const continueChapter = $derived(
|
||||
readProgress?.chapter_id
|
||||
? chapters.find((c) => c.id === readProgress.chapter_id) ?? null
|
||||
: null
|
||||
);
|
||||
const continueChapterNumber = $derived(
|
||||
continueChapter?.number ?? readProgress?.chapter_number ?? null
|
||||
);
|
||||
const continueChapterTitle = $derived(continueChapter?.title ?? null);
|
||||
|
||||
const authors = $derived<AuthorRef[]>(manga.authors);
|
||||
const genres = $derived<GenreRef[]>(manga.genres);
|
||||
@@ -328,6 +342,21 @@
|
||||
|
||||
<section aria-label="chapters">
|
||||
<h2>Chapters</h2>
|
||||
{#if continueChapterNumber != null}
|
||||
<a
|
||||
class="continue"
|
||||
href="/manga/{manga.id}/chapter/{continueChapterNumber}"
|
||||
data-testid="continue-reading"
|
||||
>
|
||||
<span class="continue-label">Continue reading</span>
|
||||
<span class="continue-target">
|
||||
Chapter {continueChapterNumber}{#if continueChapterTitle}: {continueChapterTitle}{/if}
|
||||
{#if readProgress && readProgress.page > 1}
|
||||
— page {readProgress.page}
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapters.length === 0}
|
||||
<p data-testid="chapters-empty">No chapters yet.</p>
|
||||
{:else}
|
||||
@@ -536,6 +565,36 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.continue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: var(--space-3) 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--primary-soft-bg);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.continue:hover {
|
||||
background: var(--surface-elevated);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.continue-label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.continue-target {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
padding-left: var(--space-6);
|
||||
color: var(--text);
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { getManga } from '$lib/api/mangas';
|
||||
import { listChapters } from '$lib/api/chapters';
|
||||
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
|
||||
import { getMyReadProgressForManga } from '$lib/api/read_progress';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const [manga, chapters, bookmarks] = await Promise.all([
|
||||
const [manga, chapters, bookmarks, readProgress] = await Promise.all([
|
||||
getManga(params.id),
|
||||
listChapters(params.id),
|
||||
listMyBookmarksOrEmpty()
|
||||
listMyBookmarksOrEmpty(),
|
||||
// Null when guest or never-read — page handles both cases.
|
||||
getMyReadProgressForManga(params.id)
|
||||
]);
|
||||
return { manga, chapters: chapters.items, bookmarks: bookmarks.items };
|
||||
return {
|
||||
manga,
|
||||
chapters: chapters.items,
|
||||
bookmarks: bookmarks.items,
|
||||
readProgress
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
import { updateReadProgress } from '$lib/api/read_progress';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
@@ -90,6 +92,155 @@
|
||||
onDestroy(() => {
|
||||
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
// ---- Reading progress tracking ----
|
||||
//
|
||||
// High-water mark seeded from the server: progress only ever moves
|
||||
// forward within a session, so a quick scroll-up doesn't rewind
|
||||
// the saved position. Critically, when the user re-opens a chapter
|
||||
// they were previously reading we seed from `data.readProgress.page`
|
||||
// so the first flush is a no-op (or forward-only) rather than a
|
||||
// reset to page 1 that would clobber the persisted position.
|
||||
//
|
||||
// Writes are debounced and fire-and-forget — the reader never
|
||||
// blocks on the network, and a failed write just means the user's
|
||||
// history is slightly stale (acceptable).
|
||||
// Route param `[n]` is part of the URL, so SvelteKit remounts
|
||||
// this component on chapter navigation — capturing the initial
|
||||
// `data` value here is the desired behaviour.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialProgressPage =
|
||||
data.readProgress && data.readProgress.chapter_id === chapter.id
|
||||
? Math.max(1, data.readProgress.page)
|
||||
: 1;
|
||||
let progressPage = $state(initialProgressPage);
|
||||
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
function noteProgress(page: number) {
|
||||
if (page > progressPage) progressPage = page;
|
||||
}
|
||||
|
||||
async function flushProgress() {
|
||||
if (!session.user) return;
|
||||
try {
|
||||
await updateReadProgress({
|
||||
manga_id: manga.id,
|
||||
chapter_id: chapter.id,
|
||||
page: progressPage
|
||||
});
|
||||
} catch {
|
||||
// Best-effort; nothing the user can do about a transient
|
||||
// hiccup and we don't want to nag them.
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (progressTimer) clearTimeout(progressTimer);
|
||||
progressTimer = setTimeout(flushProgress, 1500);
|
||||
}
|
||||
|
||||
// Single-mode: every page change moves the high-water mark.
|
||||
// Intentionally NOT depending on `mode` — toggling layout doesn't
|
||||
// change the read position, and re-running this effect on a mode
|
||||
// toggle would re-fire `noteProgress(index + 1)` (= 1 in
|
||||
// continuous mode where index never moves) and schedule a flush
|
||||
// that's at best a no-op and at worst a spurious write.
|
||||
$effect(() => {
|
||||
noteProgress(index + 1);
|
||||
scheduleFlush();
|
||||
});
|
||||
|
||||
// Initial open: record that the user is in this chapter now so the
|
||||
// history-sort timestamp moves to "now" — without regressing the
|
||||
// page number (initialProgressPage already encodes the persisted
|
||||
// value when the chapter matches).
|
||||
onMount(() => {
|
||||
if (session.user) void flushProgress();
|
||||
});
|
||||
|
||||
// Continuous mode: observe each page image and track the highest
|
||||
// index that's been visible. IntersectionObserver is re-created
|
||||
// whenever the page list rebinds (chapter change).
|
||||
$effect(() => {
|
||||
if (mode !== 'continuous') {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
return;
|
||||
}
|
||||
const els = continuousPageEls.filter(Boolean);
|
||||
if (els.length === 0) return;
|
||||
observer?.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
const idx = els.indexOf(e.target as HTMLImageElement);
|
||||
if (idx >= 0) noteProgress(idx + 1);
|
||||
}
|
||||
scheduleFlush();
|
||||
},
|
||||
{ rootMargin: '0px', threshold: 0.5 }
|
||||
);
|
||||
for (const el of els) observer.observe(el);
|
||||
return () => observer?.disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* `fetch()` initiated during `pagehide` / `beforeunload` is
|
||||
* cancelled by every browser by default. `sendBeacon` is the
|
||||
* supported way to ship a small payload during unload — it's
|
||||
* guaranteed to survive even if the tab is closing. Failure here
|
||||
* is silent because the API is fire-and-forget.
|
||||
*/
|
||||
function beaconFinalProgress() {
|
||||
if (!session.user) return;
|
||||
const body = JSON.stringify({
|
||||
manga_id: manga.id,
|
||||
chapter_id: chapter.id,
|
||||
page: progressPage
|
||||
});
|
||||
const blob = new Blob([body], { type: 'application/json' });
|
||||
// sendBeacon only supports POST — the server's PUT route is
|
||||
// strict on method. The dedicated POST alias is omitted; in
|
||||
// practice the in-app navigation path (back-link, chapter
|
||||
// links) already covers the common-case unmount via the
|
||||
// onDestroy fetch. Fall through to fetch+keepalive for browser
|
||||
// implementations that don't honor sendBeacon for this endpoint.
|
||||
try {
|
||||
const ok = navigator.sendBeacon('/api/v1/me/read-progress', blob);
|
||||
if (!ok) throw new Error('sendBeacon rejected');
|
||||
} catch {
|
||||
try {
|
||||
void fetch('/api/v1/me/read-progress', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
keepalive: true,
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch {
|
||||
// Final fallback failed; the in-app onDestroy flush
|
||||
// below catches the SPA-navigation case.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('pagehide', beaconFinalProgress);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
observer?.disconnect();
|
||||
if (progressTimer) clearTimeout(progressTimer);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pagehide', beaconFinalProgress);
|
||||
}
|
||||
// For SPA navigation (e.g., clicking the back-link) the page
|
||||
// doesn't unload, so `pagehide` won't fire — flush via a
|
||||
// normal fetch. Tab-close paths land on the beacon above.
|
||||
void flushProgress();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { getManga } from '$lib/api/mangas';
|
||||
import { getChapter, getChapterPages } from '$lib/api/chapters';
|
||||
import { getMyReadProgressForManga } from '$lib/api/read_progress';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const number = Number(params.n);
|
||||
const [manga, chapter, pages] = await Promise.all([
|
||||
const [manga, chapter, pages, readProgress] = await Promise.all([
|
||||
getManga(params.id),
|
||||
getChapter(params.id, number),
|
||||
getChapterPages(params.id, number)
|
||||
getChapterPages(params.id, number),
|
||||
// `null` for guests or first-time openers — the reader uses
|
||||
// this to seed its session-local high-water mark so the
|
||||
// first debounced write doesn't regress page=1.
|
||||
getMyReadProgressForManga(params.id)
|
||||
]);
|
||||
return { manga, chapter, pages };
|
||||
return { manga, chapter, pages, readProgress };
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -25,7 +26,8 @@
|
||||
{ href: '/profile/preferences', label: 'Preferences', icon: SlidersHorizontal, testid: 'tab-preferences', guestVisible: true },
|
||||
{ href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false },
|
||||
{ href: '/profile/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false },
|
||||
{ href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false }
|
||||
{ href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false },
|
||||
{ href: '/profile/history', label: 'History', icon: History, testid: 'tab-history', guestVisible: false }
|
||||
];
|
||||
|
||||
const visibleTabs = $derived(
|
||||
|
||||
314
frontend/src/routes/profile/history/+page.svelte
Normal file
314
frontend/src/routes/profile/history/+page.svelte
Normal file
@@ -0,0 +1,314 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
||||
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 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>
|
||||
{#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_number != null
|
||||
? `/manga/${p.manga_id}/chapter/${p.chapter_number}`
|
||||
: `/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_number != null}
|
||||
<a
|
||||
href="/manga/{p.manga_id}/chapter/{p.chapter_number}"
|
||||
>
|
||||
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}
|
||||
</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.number}">
|
||||
Chapter {u.chapter.number}{#if u.chapter.title}: {u.chapter.title}{/if}
|
||||
</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);
|
||||
}
|
||||
|
||||
.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>
|
||||
29
frontend/src/routes/profile/history/+page.ts
Normal file
29
frontend/src/routes/profile/history/+page.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { listMyReadProgress } from '$lib/api/read_progress';
|
||||
import { listMyUploads } from '$lib/api/uploads';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
try {
|
||||
const [progress, uploads] = await Promise.all([
|
||||
listMyReadProgress({ limit: 100 }),
|
||||
listMyUploads({ limit: 100 })
|
||||
]);
|
||||
return {
|
||||
authenticated: true,
|
||||
progress: progress.items,
|
||||
uploads: uploads.items,
|
||||
error: null
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { authenticated: false, progress: [], uploads: [], error: null };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { authenticated: true, progress: [], uploads: [], error: e.message };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user