books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests

This commit is contained in:
2026-10-24 01:06:33 +00:00
parent 8b2dfed239
commit 9c46e281c2
65 changed files with 25709 additions and 24 deletions

View File

@@ -8,7 +8,7 @@
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">trackers</h1>
<p class="mb-10 text-[var(--site-muted)]">
Personal data I keep track of: games, money, houses, prices, animals and lists.
Personal data I keep track of: games, money, houses, books, prices, animals and lists.
</p>
<CatalogGrid items={visibleTrackers(data.isOwner)} />
</div>

View File

@@ -0,0 +1,12 @@
import { isOwner } from '#lib/server/owner.js';
import { libraryCards, libraryStats } from '#lib/server/books/library.js';
import type { PageServerLoad } from './$types';
// Public catalogue: covers, titles, status, progress and rating. Files, formats and
// placeholders (no file yet) are only included for the owner.
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
const [books, stats] = await Promise.all([libraryCards(owner), libraryStats()]);
return { books, stats, owner };
};

View File

@@ -0,0 +1,670 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import BookCover from '#lib/books/BookCover.svelte';
import Stars from '#lib/books/Stars.svelte';
import {
authorLine,
readingStatuses,
statusLabels,
type BookCard,
type ReadingStatus
} from '#lib/books/shared.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Search from 'lucide-svelte/icons/search';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import ListIcon from 'lucide-svelte/icons/list';
import BookOpen from 'lucide-svelte/icons/book-open';
import FileQuestion from 'lucide-svelte/icons/file-question';
import X from 'lucide-svelte/icons/x';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Sort = 'recent' | 'title' | 'author' | 'progress' | 'rating' | 'added';
const sorts: Record<Sort, string> = {
recent: 'recently read',
title: 'title',
author: 'author',
progress: 'progress',
rating: 'rating',
added: 'recently added'
};
let query = $state('');
let status = $state<ReadingStatus | 'all' | 'none'>('all');
let subject = $state('');
let format = $state('');
let sort = $state<Sort>('recent');
let view = $state<'grid' | 'list'>('grid');
const numberFormat = new Intl.NumberFormat('en-GB');
const dateFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const reading = $derived(
data.books
.filter((b) => b.status === 'reading')
.toSorted((a, b) => (b.lastReadAt ?? '').localeCompare(a.lastReadAt ?? ''))
);
const counts = $derived.by(() => {
const c: Record<string, number> = { all: data.books.length, none: 0 };
for (const s of readingStatuses) c[s] = 0;
for (const b of data.books) c[b.status ?? 'none']++;
return c;
});
const subjects = $derived.by(() => {
const tally: Record<string, number> = {};
for (const b of data.books) for (const t of b.tags) tally[t] = (tally[t] ?? 0) + 1;
return Object.entries(tally)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 80);
});
const formats = $derived([...new Set(data.books.flatMap((b) => b.formats))].sort());
const placeholders = $derived(data.owner ? data.books.filter((b) => !b.hasFile).length : 0);
const surname = (b: BookCard) => (b.authors[0] ?? '~').split(' ').at(-1)!.toLowerCase();
const compare: Record<Sort, (a: BookCard, b: BookCard) => number> = {
recent: (a, b) =>
(b.lastReadAt ?? b.finishedAt ?? '').localeCompare(a.lastReadAt ?? a.finishedAt ?? '') ||
a.title.localeCompare(b.title),
title: (a, b) => a.title.localeCompare(b.title),
author: (a, b) => surname(a).localeCompare(surname(b)) || a.title.localeCompare(b.title),
progress: (a, b) => b.percent - a.percent || a.title.localeCompare(b.title),
rating: (a, b) => (b.rating ?? 0) - (a.rating ?? 0) || a.title.localeCompare(b.title),
added: (a, b) => b.addedAt.localeCompare(a.addedAt)
};
const filtered = $derived.by(() => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return data.books
.filter((b) => {
if (status !== 'all' && (b.status ?? 'none') !== status) return false;
if (subject && !b.tags.includes(subject)) return false;
if (format === 'placeholder' ? b.hasFile : format && !b.formats.includes(format))
return false;
if (!words.length) return true;
const haystack = [b.title, b.subtitle, ...b.authors, ...b.tags, b.year]
.join(' ')
.toLowerCase();
return words.every((w) => haystack.includes(w));
})
.toSorted(compare[sort]);
});
const filtering = $derived(!!query || status !== 'all' || !!subject || !!format);
function clearFilters() {
query = '';
status = 'all';
subject = '';
format = '';
}
function setView(next: 'grid' | 'list') {
view = next;
try {
localStorage.setItem('books:view', next);
} catch {
// Not persisted; fine.
}
}
// Restore the last grid/list choice (client only).
onMount(() => {
try {
const saved = localStorage.getItem('books:view');
if (saved === 'list' || saved === 'grid') view = saved;
} catch {
// Ignore.
}
});
const pct = (n: number) => `${Math.round(n)}%`;
const bookHref = (b: BookCard) => resolve('/trackers/books/[id=integer]', { id: String(b.id) });
const readHref = (b: BookCard) =>
resolve('/trackers/books/[id=integer]/read', { id: String(b.id) });
</script>
{#snippet progressBar(b: BookCard, thin = false)}
{#if b.status === 'reading' || (b.percent > 0 && b.status !== 'finished')}
<span
class={['bar', thin && 'thin']}
role="progressbar"
aria-label="Progress"
aria-valuenow={Math.round(b.percent)}
aria-valuemin={0}
aria-valuemax={100}><span style:width={pct(b.percent)}></span></span
>
{/if}
{/snippet}
{#snippet statusChip(s: ReadingStatus | null)}
{#if s}<span class={['chip', `s-${s}`]}>{statusLabels[s]}</span>{/if}
{/snippet}
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<header class="mb-8">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">books</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
What I'm reading, what I've read and what's next.
</p>
</header>
<dl class="stats mb-10">
<div>
<dt>finished in {data.stats.year}</dt>
<dd>{numberFormat.format(data.stats.finishedThisYear)}</dd>
</div>
<div>
<dt>pages read in {data.stats.year}</dt>
<dd>{numberFormat.format(data.stats.pagesThisYear)}</dd>
</div>
<div>
<dt>currently reading</dt>
<dd>{data.stats.reading}</dd>
</div>
<div>
<dt>in the library</dt>
<dd>
{numberFormat.format(data.stats.total)}
{#if placeholders}<small title="Books without a file yet"
>{placeholders} placeholder{placeholders === 1 ? '' : 's'}</small
>{/if}
</dd>
</div>
</dl>
{#if reading.length && !filtering}
<section class="mb-12" aria-labelledby="now-reading">
<h2 id="now-reading" class="mb-4 text-lg font-semibold tracking-tight">now reading</h2>
<ul class="now">
{#each reading.slice(0, 6) as b (b.id)}
<li class="now-card">
<a href={bookHref(b)} class="now-cover" aria-label={b.title}>
<BookCover src={b.cover} title={b.title} authors={b.authors} size="md" />
</a>
<div class="min-w-0 flex-1">
<a href={bookHref(b)} class="line-clamp-2 leading-snug font-medium hover:underline"
>{b.title}</a
>
<p class="mt-0.5 truncate text-sm text-[var(--site-muted)]">
{authorLine(b.authors)}
</p>
<div class="mt-3 flex items-center gap-2 text-xs text-[var(--site-muted)]">
<span class="bar grow"><span style:width={pct(b.percent)}></span></span>
<span class="tabular-nums">{pct(b.percent)}</span>
</div>
{#if data.owner && b.hasFile}
<a href={readHref(b)} class="continue"><BookOpen size={14} /> continue</a>
{/if}
</div>
</li>
{/each}
</ul>
</section>
{/if}
<div class="toolbar">
<label class="search">
<Search size={16} aria-hidden="true" />
<Input
type="search"
placeholder="Search title, author or subject"
aria-label="Search books"
bind:value={query}
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="flex flex-wrap items-center gap-2">
{#if subjects.length}
<NativeSelect aria-label="Subject" bind:value={subject} class="max-w-44">
<option value="">all subjects</option>
{#each subjects as [name, count] (name)}<option value={name}>{name} ({count})</option
>{/each}
</NativeSelect>
{/if}
{#if data.owner && (formats.length || placeholders)}
<NativeSelect aria-label="Format" bind:value={format}>
<option value="">all formats</option>
{#each formats as f (f)}<option value={f}>{f.toUpperCase()}</option>{/each}
{#if placeholders}<option value="placeholder">no file yet</option>{/if}
</NativeSelect>
{/if}
<NativeSelect aria-label="Sort by" bind:value={sort}>
{#each Object.entries(sorts) as [key, label] (key)}<option value={key}>{label}</option
>{/each}
</NativeSelect>
<div class="toggle" role="group" aria-label="View">
<button
type="button"
aria-pressed={view === 'grid'}
aria-label="Cover grid"
onclick={() => setView('grid')}><LayoutGrid size={16} /></button
>
<button
type="button"
aria-pressed={view === 'list'}
aria-label="List"
onclick={() => setView('list')}><ListIcon size={16} /></button
>
</div>
</div>
</div>
<div class="tabs" role="tablist" aria-label="Status">
{#each ['all', ...readingStatuses] as const as s (s)}
<button
type="button"
role="tab"
aria-selected={status === s}
onclick={() => (status = s)}
class={[status === s && 'active']}
>{s === 'all' ? 'all' : statusLabels[s]} <span>{counts[s]}</span></button
>
{/each}
{#if counts.none}
<button
type="button"
role="tab"
aria-selected={status === 'none'}
onclick={() => (status = 'none')}
class={[status === 'none' && 'active']}>unsorted <span>{counts.none}</span></button
>
{/if}
</div>
{#if filtering}
<p class="mb-4 flex items-center gap-2 text-sm text-[var(--site-muted)]">
{filtered.length} of {data.books.length} books
<button type="button" class="clear" onclick={clearFilters}><X size={12} /> clear</button>
</p>
{/if}
{#if !data.books.length}
<div class="empty">
<BookOpen size={28} aria-hidden="true" />
<p>The library is still being catalogued. Check back soon.</p>
</div>
{:else if !filtered.length}
<div class="empty"><p>No books match these filters.</p></div>
{:else if view === 'grid'}
<ul class="grid">
{#each filtered as b (b.id)}
<li>
<a
href={bookHref(b)}
class="card"
title={b.subtitle ? `${b.title}: ${b.subtitle}` : b.title}
>
<div class="cover-wrap">
<BookCover
src={b.cover}
title={b.title}
authors={b.authors}
placeholder={data.owner && !b.hasFile}
/>
{#if data.owner && !b.hasFile}
<span class="badge" title="No file yet"><FileQuestion size={12} /> no file</span>
{/if}
{#if b.status === 'finished'}<span class="done" title="Finished">✓</span>{/if}
<div class="cover-bar">{@render progressBar(b, true)}</div>
</div>
<span class="mt-2.5 line-clamp-2 text-sm leading-snug font-medium">{b.title}</span>
<span class="mt-0.5 truncate text-xs text-[var(--site-muted)]"
>{authorLine(b.authors) || 'unknown author'}</span
>
<span class="mt-1 flex min-h-4 items-center gap-2">
<Stars value={b.rating} size={11} />
{#if b.status === 'reading'}<span
class="text-xs text-[var(--site-muted)] tabular-nums">{pct(b.percent)}</span
>{/if}
</span>
</a>
</li>
{/each}
</ul>
{:else}
<ul class="list">
{#each filtered as b (b.id)}
<li>
<a href={bookHref(b)} class="row">
<span class="w-10 shrink-0"
><BookCover
src={b.cover}
title={b.title}
size="sm"
placeholder={data.owner && !b.hasFile}
/></span
>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">{b.title}</span>
<span class="block truncate text-sm text-[var(--site-muted)]"
>{authorLine(b.authors) || 'unknown author'}{b.year ? ` · ${b.year}` : ''}</span
>
</span>
<span class="hidden w-28 sm:block">{@render statusChip(b.status)}</span>
<span class="hidden w-32 items-center gap-2 md:flex">
{#if b.percent > 0}<span class="bar grow"
><span style:width={pct(b.percent)}></span></span
><span class="w-9 text-right text-xs text-[var(--site-muted)] tabular-nums"
>{pct(b.percent)}</span
>{/if}
</span>
<span class="hidden w-20 lg:block"><Stars value={b.rating} size={11} /></span>
<span class="hidden w-24 text-right text-xs text-[var(--site-muted)] lg:block">
{#if b.lastReadAt}{dateFormat.format(new Date(b.lastReadAt))}{/if}
</span>
{#if data.owner && !b.hasFile}
<span class="badge static" title="No file yet"><FileQuestion size={12} /></span>
{/if}
</a>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@media (min-width: 768px) {
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.stats > div {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 0.9rem 1.1rem;
}
.stats dt {
font-size: 0.8rem;
color: var(--site-muted);
}
.stats dd {
margin-top: 0.15rem;
font-size: 1.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.stats small {
font-size: 0.75rem;
font-weight: 400;
color: var(--site-muted);
letter-spacing: 0;
}
.now {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
}
.now-card {
display: flex;
gap: 1rem;
align-items: flex-start;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 0.9rem;
}
.now-cover {
width: 4.5rem;
flex-shrink: 0;
}
.continue {
margin-top: 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 500;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.2rem 0.7rem;
transition: background 0.15s;
}
.continue:hover {
background: var(--site-hover);
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 16rem;
max-width: 26rem;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0 0.75rem;
color: var(--site-muted);
background: var(--site-surface);
}
.search:focus-within {
border-color: var(--site-muted);
}
.toggle {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
overflow: hidden;
}
.toggle button {
padding: 0.45rem 0.6rem;
color: var(--site-muted);
}
.toggle button[aria-pressed='true'] {
background: var(--site-hover);
color: var(--site-text);
}
.tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
border-bottom: 1px solid var(--site-border);
margin-bottom: 1.5rem;
scrollbar-width: none;
}
.tabs button {
white-space: nowrap;
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
color: var(--site-muted);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tabs button:hover {
color: var(--site-text);
}
.tabs button.active {
color: var(--site-text);
border-color: var(--site-text);
}
.tabs button span {
font-size: 0.75rem;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.clear {
display: inline-flex;
align-items: center;
gap: 0.2rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.5rem;
font-size: 0.75rem;
}
.grid {
display: grid;
gap: 1.75rem 1.1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 480px) {
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.card {
display: flex;
flex-direction: column;
min-width: 0;
}
.cover-wrap {
position: relative;
transition:
transform 0.2s ease,
filter 0.2s ease;
}
.card:hover .cover-wrap,
.card:focus-visible .cover-wrap {
transform: translateY(-4px) rotate(-0.4deg);
filter: drop-shadow(0 12px 18px rgb(0 0 0 / 0.35));
}
.cover-bar {
position: absolute;
left: 8%;
right: 6%;
bottom: 6%;
}
.badge {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: rgb(0 0 0 / 0.7);
color: #fff;
backdrop-filter: blur(4px);
}
.badge.static {
position: static;
background: var(--site-hover);
color: var(--site-muted);
}
.done {
position: absolute;
top: 6px;
left: 10%;
display: grid;
place-items: center;
width: 1.3rem;
height: 1.3rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
background: #16a34a;
color: #fff;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.4);
}
.bar {
display: block;
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--site-muted) 25%, transparent);
overflow: hidden;
}
.bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--site-text);
}
.bar.thin {
height: 0.3rem;
background: rgb(0 0 0 / 0.45);
backdrop-filter: blur(4px);
}
.bar.thin > span {
background: #fff;
}
.list {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
overflow: hidden;
}
.list li + li {
border-top: 1px solid var(--site-border);
}
.row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
transition: background 0.15s;
}
.row:hover {
background: var(--site-hover);
}
.chip {
display: inline-block;
font-size: 0.72rem;
padding: 0.1rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
}
.chip.s-reading {
color: #2563eb;
border-color: color-mix(in srgb, #2563eb 40%, transparent);
}
.chip.s-finished {
color: #16a34a;
border-color: color-mix(in srgb, #16a34a 40%, transparent);
}
.chip.s-abandoned {
color: #b45309;
border-color: color-mix(in srgb, #b45309 40%, transparent);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 4rem 1rem;
border: 1px dashed var(--site-border);
border-radius: 0.75rem;
color: var(--site-muted);
text-align: center;
}
</style>

View File

@@ -0,0 +1,182 @@
import { error, fail } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookSessions } from '#lib/server/db/schema/index.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import {
coverSrc,
getWork,
logManualSession,
setManualProgress,
setRating,
setStatus,
workFiles,
workNotes,
workSessions
} from '#lib/server/books/library.js';
import { createNote, deleteNote, updateNote } from '#lib/server/books/notes.js';
import { isReadingStatus } from '#lib/books/shared.js';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const id = Number(event.params.id);
const found = await getWork(id);
if (!found) error(404, 'Not Found');
const owner = isOwner(event);
const { work, progress } = found;
const book = {
id: work.id,
title: work.title,
subtitle: work.subtitle,
authors: work.authors,
year: work.year,
publisher: work.publisher,
isbn13: work.isbn13,
isbn10: work.isbn10,
language: work.language,
pageCount: work.pageCount,
description: work.description,
tags: work.tags,
cover: coverSrc(work),
openlibraryWorkId: work.openlibraryWorkId
};
const reading = {
status: progress?.status ?? null,
percent: progress?.percent ?? 0,
page: progress?.page ?? null,
rating: progress?.rating ?? null,
startedAt: progress?.startedAt?.toISOString() ?? null,
finishedAt: progress?.finishedAt?.toISOString() ?? null,
lastReadAt: progress?.lastReadAt?.toISOString() ?? null
};
const seo = {
title: `${work.title}${work.authors.length ? ` by ${work.authors.join(', ')}` : ''}`,
description:
work.description?.slice(0, 160) ??
`${work.title} on Stan Runge's bookshelf${reading.status ? ` (${reading.status.replace(/_/g, ' ')})` : ''}.`
};
if (!owner) return { book, reading, owner: false as const, seo };
event.setHeaders({ 'cache-control': 'private, no-store' });
const [files, notes, sessions] = await Promise.all([
workFiles(id),
workNotes(id),
workSessions(id)
]);
return {
book,
reading,
owner: true as const,
seo,
files: files.map(({ id, format, sizeBytes, readable, path }) => ({
id,
format,
sizeBytes,
readable,
name: path.split('/').at(-1) ?? path
})),
notes,
sessions
};
};
const int = (value: FormDataEntryValue | null) => {
if (value === null || value === '') return null;
const n = Number(value);
return Number.isInteger(n) ? n : NaN;
};
const date = (value: FormDataEntryValue | null) => {
if (!value) return null;
const d = new Date(String(value));
return Number.isNaN(d.getTime()) ? undefined : d;
};
export const actions: Actions = {
status: async (event) => {
requireOwner(event);
const status = (await event.request.formData()).get('status');
if (!isReadingStatus(status)) return fail(400, { message: 'Unknown status' });
await setStatus(Number(event.params.id), status);
return { ok: true };
},
rating: async (event) => {
requireOwner(event);
const rating = int((await event.request.formData()).get('rating'));
if (rating !== null && !(rating >= 1 && rating <= 5))
return fail(400, { message: 'Rating is 1-5' });
await setRating(Number(event.params.id), rating);
return { ok: true };
},
progress: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const page = int(form.get('page'));
const pageCount = int(form.get('pageCount'));
let percent = form.get('percent') === '' ? null : Number(form.get('percent'));
if (Number.isNaN(page) || (percent !== null && !(percent >= 0 && percent <= 100)))
return fail(400, { message: 'Invalid progress' });
if (percent === null && page !== null && pageCount)
percent = Math.min(100, (page / pageCount) * 100);
if (percent === null) return fail(400, { message: 'Enter a percent or a page' });
await setManualProgress(Number(event.params.id), Math.round(percent * 100) / 100, page);
return { ok: true };
},
addNote: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const note = String(form.get('note') ?? '').trim();
const page = int(form.get('page'));
if (!note) return fail(400, { message: 'Write something first' });
if (Number.isNaN(page)) return fail(400, { message: 'Invalid page' });
await createNote(Number(event.params.id), { note, page });
return { ok: true };
},
editNote: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const id = int(form.get('id'));
if (!id) return fail(400, { message: 'Missing note' });
const updated = await updateNote(Number(event.params.id), id, {
note: String(form.get('note') ?? '').trim() || null
});
if (!updated) return fail(404, { message: 'Note not found' });
return { ok: true };
},
deleteNote: async (event) => {
requireOwner(event);
const id = int((await event.request.formData()).get('id'));
if (!id || !(await deleteNote(Number(event.params.id), id)))
return fail(404, { message: 'Note not found' });
return { ok: true };
},
logSession: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const startedAt = date(form.get('startedAt'));
const endedAt = date(form.get('endedAt'));
const startPage = int(form.get('startPage'));
const endPage = int(form.get('endPage'));
if (!startedAt || endedAt === undefined || Number.isNaN(startPage) || Number.isNaN(endPage))
return fail(400, { message: 'Check the dates and pages' });
if (endedAt && endedAt < startedAt) return fail(400, { message: 'End is before start' });
await logManualSession(Number(event.params.id), {
startedAt,
endedAt,
startPage,
endPage,
startPercent: null,
endPercent: null,
note: String(form.get('note') ?? '').trim() || null
});
return { ok: true };
},
deleteSession: async (event) => {
requireOwner(event);
const id = int((await event.request.formData()).get('id'));
if (!id) return fail(400, { message: 'Missing session' });
await db
.delete(bookSessions)
.where(and(eq(bookSessions.id, id), eq(bookSessions.workId, Number(event.params.id))));
return { ok: true };
}
};

View File

@@ -0,0 +1,683 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import BookCover from '#lib/books/BookCover.svelte';
import Stars from '#lib/books/Stars.svelte';
import {
highlightCss,
readingStatuses,
statusLabels,
type HighlightColor
} from '#lib/books/shared.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import BookOpen from 'lucide-svelte/icons/book-open';
import Highlighter from 'lucide-svelte/icons/highlighter';
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
import History from 'lucide-svelte/icons/history';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Pencil from 'lucide-svelte/icons/pencil';
import FileQuestion from 'lucide-svelte/icons/file-question';
import Lock from 'lucide-svelte/icons/lock';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
const book = $derived(data.book);
const reading = $derived(data.reading);
let descriptionOpen = $state(false);
let editing = $state<number | null>(null);
let showSessionForm = $state(false);
const dateFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const timeFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
const localInput = (d: Date) =>
new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Amsterdam',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.format(d)
.replace(' ', 'T');
const size = (bytes: number) =>
bytes > 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.ceil(bytes / 1e3)} kB`;
const minutes = (a: string, b: string | null) =>
b ? Math.max(1, Math.round((new Date(b).getTime() - new Date(a).getTime()) / 60000)) : null;
const readable = $derived(data.owner ? data.files.find((f) => f.readable) : undefined);
const highlights = $derived(data.owner ? data.notes.filter((n) => n.highlight) : []);
const freeNotes = $derived(data.owner ? data.notes.filter((n) => !n.highlight) : []);
const readHref = $derived(resolve('/trackers/books/[id=integer]/read', { id: String(book.id) }));
const meta = $derived(
[
['published', book.year],
['publisher', book.publisher],
['pages', book.pageCount],
['language', book.language?.toUpperCase()],
['ISBN', book.isbn13 ?? book.isbn10]
].filter(([, v]) => v != null && v !== '') as [string, string | number][]
);
const keep: SubmitFunction =
() =>
async ({ update }) => {
await update({ reset: false });
};
const done: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') {
editing = null;
showSessionForm = false;
}
};
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/trackers/books')}
class="mb-8 inline-flex items-center gap-1.5 text-sm text-[var(--site-muted)] hover:text-[var(--site-text)]"
><ArrowLeft size={14} /> all books</a
>
<div class="layout">
<aside class="side">
<div class="cover-box">
<BookCover
src={book.cover}
title={book.title}
authors={book.authors}
size="lg"
placeholder={data.owner && !data.files.length}
/>
</div>
{#if data.owner}
{#if readable}
<Button href={readHref} class="mt-5 w-full gap-2" size="lg"
><BookOpen size={16} />
{reading.percent > 0 && reading.status !== 'finished'
? 'continue reading'
: 'read'}</Button
>
{:else if data.files.length}
<p class="mt-5 text-center text-sm text-[var(--site-muted)]">
{data.files.map((f) => f.format.toUpperCase()).join(', ')} can't be opened in the browser
reader yet.
</p>
{:else}
<p class="placeholder-note">
<FileQuestion size={16} /> No file yet. This is a placeholder until a copy lands in the library.
</p>
{/if}
<form method="POST" action="?/status" use:enhance={keep} class="status-group">
{#each readingStatuses as s (s)}
<button
name="status"
value={s}
class={['seg', reading.status === s && 'active']}
aria-pressed={reading.status === s}>{statusLabels[s]}</button
>
{/each}
</form>
<form method="POST" action="?/rating" use:enhance={keep} class="mt-3 flex justify-center">
<Stars value={reading.rating} size={22} name="rating" />
</form>
{:else}
<div class="mt-5 flex flex-col items-center gap-2">
{#if reading.status}<span class={['chip', `s-${reading.status}`]}
>{statusLabels[reading.status]}</span
>{/if}
<Stars value={reading.rating} size={18} />
</div>
{/if}
</aside>
<div class="min-w-0">
<h1 class="text-3xl leading-tight font-semibold tracking-tight sm:text-4xl">{book.title}</h1>
{#if book.subtitle}<p class="mt-2 text-lg text-[var(--site-muted)]">{book.subtitle}</p>{/if}
{#if book.authors.length}<p class="mt-3 text-lg">{book.authors.join(', ')}</p>{/if}
{#if meta.length}
<dl class="meta">
{#each meta as [label, value] (label)}
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
{/each}
</dl>
{/if}
{#if book.tags.length}
<ul class="mt-5 flex flex-wrap gap-1.5">
{#each book.tags.slice(0, 16) as tag (tag)}<li class="tag">{tag}</li>{/each}
</ul>
{/if}
{#if book.description}
<div class="mt-6">
<p class={['description', !descriptionOpen && 'clamped']}>{book.description}</p>
{#if book.description.length > 400}
<button
type="button"
class="mt-1 text-sm text-[var(--site-muted)] underline-offset-2 hover:underline"
onclick={() => (descriptionOpen = !descriptionOpen)}
>{descriptionOpen ? 'less' : 'more'}</button
>
{/if}
</div>
{/if}
<section class="panel mt-8">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h2 class="text-lg font-semibold tracking-tight">progress</h2>
<p class="text-sm text-[var(--site-muted)]">
{#if reading.startedAt}started {dateFormat.format(new Date(reading.startedAt))}{/if}
{#if reading.finishedAt}· finished {dateFormat.format(
new Date(reading.finishedAt)
)}{/if}
{#if reading.lastReadAt && reading.status === 'reading'}· last read {timeFormat.format(
new Date(reading.lastReadAt)
)}{/if}
</p>
</div>
<div class="mt-3 flex items-center gap-3">
<span class="bar grow"><span style:width={`${reading.percent}%`}></span></span>
<span class="text-sm font-medium tabular-nums">{Math.round(reading.percent)}%</span>
</div>
{#if reading.page && book.pageCount}
<p class="mt-1 text-xs text-[var(--site-muted)]">
page {reading.page} of {book.pageCount}
</p>
{/if}
{#if data.owner}
<form
method="POST"
action="?/progress"
use:enhance={done}
class="mt-4 flex flex-wrap items-end gap-2"
>
<label class="field"
><span>percent</span><Input
name="percent"
type="number"
min="0"
max="100"
step="0.1"
class="w-24"
value={reading.percent ? Math.round(reading.percent * 10) / 10 : ''}
/></label
>
{#if book.pageCount}
<label class="field"
><span>or page</span><Input
name="page"
type="number"
min="0"
max={book.pageCount}
class="w-24"
value={reading.page ?? ''}
/></label
>
<input type="hidden" name="pageCount" value={book.pageCount} />
{/if}
<Button type="submit" variant="outline" size="sm">update</Button>
</form>
{/if}
</section>
{#if data.owner}
{#if form?.message}<p class="mt-4 text-sm text-red-500" role="alert">{form.message}</p>{/if}
<section class="mt-10">
<h2 class="section-title"><NotebookPen size={18} /> notes</h2>
<form method="POST" action="?/addNote" use:enhance={done} class="mb-4 space-y-2">
<Textarea name="note" rows={3} placeholder="Thoughts, quotes, a summary…" required />
<div class="flex items-center gap-2">
<Input name="page" type="number" min="0" placeholder="page" class="w-24" />
<Button type="submit" size="sm">add note</Button>
</div>
</form>
{#if freeNotes.length}
<ul class="space-y-3">
{#each freeNotes as n (n.id)}
<li class="note">
{#if editing === n.id}
<form method="POST" action="?/editNote" use:enhance={done} class="space-y-2">
<input type="hidden" name="id" value={n.id} />
<Textarea name="note" rows={4} value={n.note ?? ''} />
<div class="flex gap-2">
<Button type="submit" size="sm">save</Button>
<Button
type="button"
size="sm"
variant="ghost"
onclick={() => (editing = null)}>cancel</Button
>
</div>
</form>
{:else}
<p class="whitespace-pre-wrap">{n.note}</p>
<footer>
<span
>{dateFormat.format(new Date(n.createdAt))}{n.page
? ` · p. ${n.page}`
: ''}</span
>
<span class="flex gap-1">
<button
type="button"
class="icon"
aria-label="Edit note"
onclick={() => (editing = n.id)}><Pencil size={13} /></button
>
<form method="POST" action="?/deleteNote" use:enhance={done}>
<input type="hidden" name="id" value={n.id} />
<button class="icon" aria-label="Delete note"><Trash2 size={13} /></button
>
</form>
</span>
</footer>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-[var(--site-muted)]">No notes yet.</p>
{/if}
</section>
<section class="mt-10">
<h2 class="section-title"><Highlighter size={18} /> highlights</h2>
{#if highlights.length}
<ul class="space-y-3">
{#each highlights as h (h.id)}
<li
class="highlight"
style:--hl={highlightCss[(h.color as HighlightColor) ?? 'yellow'] ??
highlightCss.yellow}
>
<blockquote>{h.highlight}</blockquote>
{#if editing === h.id}
<form
method="POST"
action="?/editNote"
use:enhance={done}
class="mt-2 space-y-2"
>
<input type="hidden" name="id" value={h.id} />
<Textarea
name="note"
rows={3}
value={h.note ?? ''}
placeholder="Add a note"
/>
<div class="flex gap-2">
<Button type="submit" size="sm">save</Button>
<Button
type="button"
size="sm"
variant="ghost"
onclick={() => (editing = null)}>cancel</Button
>
</div>
</form>
{:else if h.note}
<p class="mt-2 text-sm whitespace-pre-wrap">{h.note}</p>
{/if}
<footer>
<span>
{dateFormat.format(new Date(h.createdAt))}{h.page ? ` · p. ${h.page}` : ''}
{#if readable && h.locator}
· <a
class="underline-offset-2 hover:underline"
href={`${readHref}?at=${encodeURIComponent(h.locator)}`}>open in reader</a
>
{/if}
</span>
<span class="flex gap-1">
<button
type="button"
class="icon"
aria-label="Edit note"
onclick={() => (editing = h.id)}><Pencil size={13} /></button
>
<form method="POST" action="?/deleteNote" use:enhance={done}>
<input type="hidden" name="id" value={h.id} />
<button class="icon" aria-label="Delete highlight"
><Trash2 size={13} /></button
>
</form>
</span>
</footer>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-[var(--site-muted)]">
Select text in the reader to highlight it{readable ? '' : ' (once there is a file)'}.
</p>
{/if}
</section>
<section class="mt-10">
<div class="mb-3 flex items-center justify-between">
<h2 class="section-title mb-0"><History size={18} /> reading history</h2>
<Button size="sm" variant="outline" onclick={() => (showSessionForm = !showSessionForm)}
>{showSessionForm ? 'cancel' : 'log a session'}</Button
>
</div>
{#if showSessionForm}
<form
method="POST"
action="?/logSession"
use:enhance={done}
class="panel mb-4 grid gap-3 sm:grid-cols-2"
>
<label class="field"
><span>start</span><Input
name="startedAt"
type="datetime-local"
required
value={localInput(new Date(Date.now() - 30 * 60000))}
/></label
>
<label class="field"
><span>end</span><Input
name="endedAt"
type="datetime-local"
value={localInput(new Date())}
/></label
>
<label class="field"
><span>from page</span><Input
name="startPage"
type="number"
min="0"
value={reading.page ?? ''}
/></label
>
<label class="field"
><span>to page</span><Input name="endPage" type="number" min="0" /></label
>
<label class="field sm:col-span-2"
><span>note</span><Input name="note" placeholder="optional" /></label
>
<div class="sm:col-span-2"><Button type="submit" size="sm">save session</Button></div>
</form>
{/if}
{#if data.sessions.length}
<ul class="sessions">
{#each data.sessions as s (s.id)}
<li>
<span class="min-w-0 flex-1">
<span class="block">{timeFormat.format(new Date(s.startedAt))}</span>
<span class="block text-xs text-[var(--site-muted)]">
{#if minutes(s.startedAt, s.endedAt)}{minutes(s.startedAt, s.endedAt)} min{/if}
{#if s.startPercent != null && s.endPercent != null}
· {Math.round(s.startPercent)}% → {Math.round(s.endPercent)}%{/if}
{#if s.startPage != null && s.endPage != null}
· p. {s.startPage} → {s.endPage}{/if}
{#if s.source === 'manual'}· logged by hand{/if}
{#if s.note}· {s.note}{/if}
</span>
</span>
{#if s.pagesRead}<span class="pages">{s.pagesRead} p.</span>{/if}
<form method="POST" action="?/deleteSession" use:enhance={done}>
<input type="hidden" name="id" value={s.id} />
<button class="icon" aria-label="Delete session"><Trash2 size={13} /></button>
</form>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-[var(--site-muted)]">
Sessions are logged automatically while you read.
</p>
{/if}
</section>
{#if data.files.length}
<section class="mt-10">
<h2 class="section-title"><Lock size={16} /> files</h2>
<ul class="text-sm text-[var(--site-muted)]">
{#each data.files as f (f.id)}
<li class="flex justify-between gap-4 py-1">
<span class="truncate">{f.name}</span>
<span class="shrink-0 tabular-nums"
>{f.format.toUpperCase()} · {size(f.sizeBytes)}</span
>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
</div>
</div>
</div>
<style>
.layout {
display: grid;
gap: 2.5rem;
}
@media (min-width: 768px) {
.layout {
grid-template-columns: 16rem minmax(0, 1fr);
gap: 3.5rem;
}
.side {
position: sticky;
top: 6rem;
align-self: start;
}
}
.cover-box {
max-width: 14rem;
margin: 0 auto;
}
@media (min-width: 768px) {
.cover-box {
max-width: none;
}
}
.placeholder-note {
margin-top: 1.25rem;
display: flex;
gap: 0.5rem;
align-items: flex-start;
font-size: 0.85rem;
color: var(--site-muted);
border: 1px dashed var(--site-border);
border-radius: 0.5rem;
padding: 0.6rem 0.75rem;
}
.status-group {
margin-top: 1rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
}
.seg {
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
border-radius: 0.5rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
transition:
background 0.15s,
color 0.15s;
}
.seg:hover {
background: var(--site-hover);
color: var(--site-text);
}
.seg.active {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.meta {
margin-top: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
}
.meta dt {
font-size: 0.75rem;
color: var(--site-muted);
}
.meta dd {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.tag {
font-size: 0.75rem;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: var(--site-hover);
color: var(--site-muted);
}
.description {
line-height: 1.7;
white-space: pre-line;
}
.description.clamped {
display: -webkit-box;
-webkit-line-clamp: 6;
line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
.panel {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1rem 1.25rem;
}
.bar {
display: block;
height: 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--site-muted) 25%, transparent);
overflow: hidden;
}
.bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--site-text);
transition: width 0.4s ease;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--site-muted);
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.01em;
margin-bottom: 0.75rem;
}
.note,
.highlight {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
padding: 0.85rem 1rem;
background: var(--site-surface);
}
.highlight blockquote {
border-left: 3px solid var(--hl);
padding-left: 0.85rem;
font-family: Georgia, 'Times New Roman', serif;
line-height: 1.6;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--hl) 12%, transparent),
transparent 70%
);
}
.note footer,
.highlight footer {
margin-top: 0.6rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
color: var(--site-muted);
}
.icon {
display: inline-flex;
border-radius: 0.375rem;
padding: 0.3rem;
color: var(--site-muted);
}
.icon:hover {
background: var(--site-hover);
color: var(--site-text);
}
.sessions {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
overflow: hidden;
}
.sessions li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 0.9rem;
font-size: 0.875rem;
}
.sessions li + li {
border-top: 1px solid var(--site-border);
}
.pages {
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
color: var(--site-muted);
}
.chip {
display: inline-block;
font-size: 0.8rem;
padding: 0.15rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
}
.chip.s-reading {
color: #2563eb;
}
.chip.s-finished {
color: #16a34a;
}
.chip.s-abandoned {
color: #b45309;
}
</style>

View File

@@ -0,0 +1,14 @@
// Owner-only: create a highlight or note from the reader.
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { createNote, notePayload } from '#lib/server/books/notes.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
requireOwner(event);
const parsed = notePayload.safeParse(await event.request.json().catch(() => null));
if (!parsed.success) error(400, 'Invalid note');
const note = await createNote(Number(event.params.id), parsed.data);
if (!note) error(404, 'Not Found');
return json(note, { status: 201 });
};

View File

@@ -0,0 +1,21 @@
// Owner-only: edit or delete one highlight/note.
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { deleteNote, noteUpdate, updateNote } from '#lib/server/books/notes.js';
import type { RequestHandler } from './$types';
export const PATCH: RequestHandler = async (event) => {
requireOwner(event);
const parsed = noteUpdate.safeParse(await event.request.json().catch(() => null));
if (!parsed.success) error(400, 'Invalid note');
const note = await updateNote(Number(event.params.id), Number(event.params.noteId), parsed.data);
if (!note) error(404, 'Not Found');
return json(note);
};
export const DELETE: RequestHandler = async (event) => {
requireOwner(event);
const ok = await deleteNote(Number(event.params.id), Number(event.params.noteId));
if (!ok) error(404, 'Not Found');
return new Response(null, { status: 204 });
};

View File

@@ -0,0 +1,21 @@
// Owner-only: the reader saves its position here (debounced, and via sendBeacon on close).
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { parseProgress } from '#lib/books/progress.js';
import { saveProgress } from '#lib/server/books/library.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
requireOwner(event);
let body: unknown;
try {
body = JSON.parse(await event.request.text());
} catch {
error(400, 'Invalid JSON');
}
const input = parseProgress(body);
if (!input) error(400, 'Invalid progress');
const result = await saveProgress(Number(event.params.id), input);
if (!result) error(404, 'Not Found');
return json(result, { headers: { 'cache-control': 'private, no-store' } });
};

View File

@@ -0,0 +1,52 @@
// Owner-only in-browser reader. Everyone else gets the same 404 as a missing page.
import { error } from '@sveltejs/kit';
import { resolve } from '$app/paths';
import { requireOwner } from '#lib/server/owner.js';
import { getWork, workFiles, workNotes } from '#lib/server/books/library.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
const id = Number(event.params.id);
const found = await getWork(id);
if (!found) error(404, 'Not Found');
const { work, progress } = found;
const files = (await workFiles(id)).filter((f) => f.readable);
const wanted = Number(event.url.searchParams.get('file'));
const file =
files.find((f) => f.id === wanted) ?? files.find((f) => f.id === progress?.fileId) ?? files[0];
if (!file) error(404, 'No readable file for this book yet');
// Resume where we left off, but only if the saved position belongs to this file.
const sameFile = progress?.fileId === file.id;
const notes = (await workNotes(id)).filter(
(n) => n.highlight && n.locator && (n.fileId === file.id || n.fileId === null)
);
return {
book: {
id: work.id,
title: work.title,
authors: work.authors,
pageCount: work.pageCount
},
file: {
id: file.id,
format: file.format,
engine: file.format === 'pdf' ? ('pdf' as const) : ('foliate' as const),
url: resolve('/trackers/books/files/[id=integer]', { id: String(file.id) }),
name: file.path.split('/').at(-1) ?? `book.${file.format}`
},
otherFiles: files.filter((f) => f.id !== file.id).map((f) => ({ id: f.id, format: f.format })),
resume: {
locator: sameFile ? (progress?.locator ?? null) : null,
percent: sameFile ? (progress?.percent ?? 0) : 0,
page: sameFile ? (progress?.page ?? null) : null
},
jumpTo: event.url.searchParams.get('at'),
highlights: notes,
seo: { title: `Reading ${work.title}`, description: work.title, index: false }
};
};

View File

@@ -0,0 +1,999 @@
<script lang="ts">
import { onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { resolve } from '$app/paths';
import EpubReader from '#lib/books/reader/EpubReader.svelte';
import PdfReader from '#lib/books/reader/PdfReader.svelte';
import {
defaultSettings,
themeColors,
type ReaderEngine,
type ReaderSettings,
type ReaderTheme,
type Relocation,
type TextSelection,
type TocItem
} from '#lib/books/reader/types.js';
import {
highlightColors,
highlightCss,
type Highlight,
type HighlightColor
} from '#lib/books/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import ListTree from 'lucide-svelte/icons/list-tree';
import Highlighter from 'lucide-svelte/icons/highlighter';
import Type from 'lucide-svelte/icons/type';
import X from 'lucide-svelte/icons/x';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Copy from 'lucide-svelte/icons/copy';
import MessageSquarePlus from 'lucide-svelte/icons/message-square-plus';
import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const SETTINGS_KEY = 'books-reader-settings';
const isPdf = $derived(data.file.engine === 'pdf');
const progressUrl = $derived(
resolve('/trackers/books/[id=integer]/progress', { id: String(data.book.id) })
);
const notesUrl = $derived(
resolve('/trackers/books/[id=integer]/notes', { id: String(data.book.id) })
);
const detailUrl = $derived(resolve('/trackers/books/[id=integer]', { id: String(data.book.id) }));
let engine = $state<ReaderEngine>();
let settings = $state<ReaderSettings>({ ...defaultSettings });
let siteDark = $state(true);
// Seeded from the load; the reader adds/edits/deletes in place (writable derived).
let highlights = $derived<Highlight[]>(data.highlights);
let toc = $state<TocItem[]>([]);
let ready = $state(false);
let loading = $state<number | null>(null);
let failure = $state<string | null>(null);
let location = $state<Relocation | null>(null);
let panel = $state<'toc' | 'highlights' | 'settings' | null>(null);
let selection = $state<TextSelection | null>(null);
let noteFor = $state<'selection' | number | null>(null);
let noteDraft = $state('');
let activeId = $state<number | null>(null);
let saveState = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let scrubbing = $state<number | null>(null);
const theme = $derived<ReaderTheme>(
settings.theme === 'auto' ? (siteDark ? 'dark' : 'light') : settings.theme
);
const colors = $derived(themeColors[theme]);
const active = $derived(highlights.find((h) => h.id === activeId) ?? null);
const percent = $derived(scrubbing ?? location?.percent ?? data.resume.percent);
const start = $derived(data.jumpTo ?? data.resume.locator);
onMount(() => {
try {
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? 'null');
if (saved && typeof saved === 'object') settings = { ...defaultSettings, ...saved };
} catch {
// ignore broken settings
}
siteDark = !getComputedStyle(document.documentElement).colorScheme.includes('light');
const onHide = () => flush(true);
const onVisibility = () => document.visibilityState === 'hidden' && flush(true);
window.addEventListener('pagehide', onHide);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('pagehide', onHide);
document.removeEventListener('visibilitychange', onVisibility);
flush(true);
};
});
function updateSettings(patch: Partial<ReaderSettings>) {
settings = { ...settings, ...patch };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
// ---- progress saving: debounced while reading, flushed on leave ----
let sessionId: number | null = null;
let pending: Relocation | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let baseline: string | null = null;
function onrelocate(r: Relocation) {
location = r;
if (!ready) return;
if (baseline === null) {
baseline = r.locator; // the restored position itself isn't reading
return;
}
if (r.locator === baseline && !pending) return;
pending = r;
clearTimeout(timer);
timer = setTimeout(() => flush(), 2000);
}
async function flush(leaving = false) {
clearTimeout(timer);
if (!pending) return;
const r = pending;
pending = null;
const body = JSON.stringify({
fileId: data.file.id,
locator: r.locator,
page: r.page,
percent: Math.round(r.percent * 100) / 100,
pageCount: r.pageCount,
sessionId
});
saveState = 'saving';
try {
const res = await fetch(progressUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: leaving
});
if (!res.ok) throw new Error(String(res.status));
sessionId = (await res.json()).sessionId ?? sessionId;
saveState = 'saved';
} catch {
saveState = 'error';
if (!leaving) pending ??= r;
}
}
beforeNavigate(() => void flush(true));
// ---- highlights ----
async function createHighlight(color: HighlightColor, note: string | null = null) {
const sel = selection;
if (!sel) return;
const res = await fetch(notesUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
fileId: data.file.id,
locator: sel.locator,
page: sel.page ?? location?.page ?? null,
highlight: sel.text.slice(0, 20000),
note: note || null,
color
})
});
if (!res.ok) return;
const h: Highlight = await res.json();
highlights = [h, ...highlights];
engine?.addHighlight(h);
engine?.clearSelection();
selection = null;
noteFor = null;
noteDraft = '';
}
async function patchHighlight(
h: Highlight,
patch: { note?: string | null; color?: HighlightColor }
) {
const res = await fetch(`${notesUrl}/${h.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch)
});
if (!res.ok) return;
const updated: Highlight = await res.json();
highlights = highlights.map((x) => (x.id === h.id ? updated : x));
if (patch.color) engine?.addHighlight(updated);
}
async function removeHighlight(h: Highlight) {
const res = await fetch(`${notesUrl}/${h.id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) return;
highlights = highlights.filter((x) => x.id !== h.id);
engine?.removeHighlight(h);
activeId = null;
}
function openHighlight(id: number) {
activeId = id;
noteDraft = highlights.find((h) => h.id === id)?.note ?? '';
noteFor = null;
}
function onselection(s: TextSelection | null) {
selection = s;
if (s) activeId = null;
else if (noteFor === 'selection') noteFor = null;
}
// ---- navigation ----
function onkey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]')) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowRight':
case 'PageDown':
case 'l':
engine?.next();
break;
case 'ArrowLeft':
case 'PageUp':
case 'h':
engine?.prev();
break;
case ' ':
if (isPdf) return;
if (e.shiftKey) engine?.prev();
else engine?.next();
break;
case 'Escape':
if (panel || activeId || selection) {
panel = null;
activeId = null;
engine?.clearSelection();
selection = null;
} else return;
break;
case 't':
panel = panel === 'toc' ? null : 'toc';
break;
default:
return;
}
e.preventDefault();
}
async function jump(target: string | number) {
await engine?.goTo(target);
if (window.matchMedia('(max-width: 900px)').matches) panel = null;
}
const fmtPercent = (p: number) => `${p < 10 ? p.toFixed(1) : Math.round(p)}%`;
const tocLabel = $derived(location?.label ?? null);
</script>
<svelte:window onkeydown={onkey} />
<div class="reader" style:--r-bg={colors.bg} style:--r-fg={colors.fg} data-theme={theme}>
<header class="bar top">
<a href={detailUrl} class="icon-btn" aria-label="Back to the book"><ArrowLeft size={18} /></a>
<div class="min-w-0 flex-1 text-center">
<p class="truncate text-sm font-medium">{data.book.title}</p>
<p class="truncate text-xs opacity-60">{tocLabel ?? data.book.authors.join(', ')}</p>
</div>
<button
type="button"
class={['icon-btn', panel === 'toc' && 'on']}
aria-label="Contents"
title="Contents (t)"
onclick={() => (panel = panel === 'toc' ? null : 'toc')}><ListTree size={18} /></button
>
<button
type="button"
class={['icon-btn', panel === 'highlights' && 'on']}
aria-label="Highlights"
onclick={() => (panel = panel === 'highlights' ? null : 'highlights')}
><Highlighter size={18} />{#if highlights.length}<span class="count">{highlights.length}</span
>{/if}</button
>
<button
type="button"
class={['icon-btn', panel === 'settings' && 'on']}
aria-label="Display settings"
onclick={() => (panel = panel === 'settings' ? null : 'settings')}><Type size={18} /></button
>
</header>
<div class="stage">
<main class="book">
{#if isPdf}
<PdfReader
bind:this={engine}
url={data.file.url}
name={data.file.name}
{start}
startPercent={data.resume.percent}
{highlights}
{settings}
{theme}
onready={(t) => {
toc = t;
ready = true;
}}
{onrelocate}
{onselection}
onhighlightclick={openHighlight}
{onkey}
onloading={(f) => (loading = f)}
onerror={(m) => (failure = m)}
/>
{:else}
<EpubReader
bind:this={engine}
url={data.file.url}
name={data.file.name}
{start}
startPercent={data.resume.percent}
{highlights}
{settings}
{theme}
onready={(t) => {
toc = t;
ready = true;
}}
{onrelocate}
{onselection}
onhighlightclick={openHighlight}
{onkey}
onloading={(f) => (loading = f)}
onerror={(m) => (failure = m)}
/>
{#if ready && settings.flow === 'paginated'}
<button
type="button"
class="turn left"
aria-label="Previous page"
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
>
<button
type="button"
class="turn right"
aria-label="Next page"
onclick={() => engine?.next()}><ChevronRight size={22} /></button
>
{/if}
{/if}
{#if failure}
<div class="overlay">
<p class="font-medium">Couldn't open this book</p>
<p class="mt-1 text-sm opacity-70">{failure}</p>
<a href={detailUrl} class="mt-4 text-sm underline">back to the book</a>
</div>
{:else if !ready}
<div class="overlay">
<p class="text-sm opacity-70">
{loading === null || loading === 0
? 'Opening…'
: `Downloading ${Math.round(loading * 100)}%`}
</p>
<span class="loadbar"><span style:width={`${(loading ?? 0) * 100}%`}></span></span>
</div>
{/if}
</main>
{#if panel}
<aside class="panel" aria-label={panel}>
<div class="panel-head">
<h2>
{panel === 'toc' ? 'Contents' : panel === 'highlights' ? 'Highlights' : 'Display'}
</h2>
<button type="button" class="icon-btn" aria-label="Close" onclick={() => (panel = null)}
><X size={16} /></button
>
</div>
<div class="panel-body">
{#if panel === 'toc'}
{#snippet tree(items: TocItem[], depth: number)}
<ul>
{#each items as item, i (i)}
<li>
<button
type="button"
class={['toc-item', tocLabel === item.label && 'current']}
style:padding-left={`${0.75 + depth * 0.9}rem`}
onclick={() => jump(item.target)}>{item.label}</button
>
{#if item.children.length && depth < 4}{@render tree(
item.children,
depth + 1
)}{/if}
</li>
{/each}
</ul>
{/snippet}
{#if toc.length}{@render tree(toc, 0)}{:else}<p class="empty">
No table of contents in this file.
</p>{/if}
{:else if panel === 'highlights'}
{#if highlights.length}
<ul class="space-y-2">
{#each highlights as h (h.id)}
<li>
<button
type="button"
class="hl-item"
style:--hl={highlightCss[(h.color as HighlightColor) ?? 'yellow'] ??
highlightCss.yellow}
onclick={() => {
if (h.locator) void jump(h.locator);
openHighlight(h.id);
}}
>
<span class="quote">{h.highlight}</span>
{#if h.note}<span class="note">{h.note}</span>{/if}
{#if h.page}<span class="meta">p. {h.page}</span>{/if}
</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">
Select text to highlight it. Highlights also show on the book page.
</p>
{/if}
{:else}
<div class="space-y-5">
<fieldset>
<legend>Theme</legend>
<div class="themes">
{#each ['auto', 'light', 'sepia', 'dark'] as const as t (t)}
<button
type="button"
class={['theme-swatch', settings.theme === t && 'on']}
style:background={t === 'auto'
? 'linear-gradient(135deg, #fdfdfb 50%, #121214 50%)'
: themeColors[t].bg}
style:color={t === 'auto' ? '#888' : themeColors[t].fg}
onclick={() => updateSettings({ theme: t })}>{t}</button
>
{/each}
</div>
</fieldset>
{#if isPdf}
<fieldset>
<legend>Zoom</legend>
<div class="stepper">
<button
type="button"
aria-label="Zoom out"
onclick={() =>
updateSettings({
pdfZoom: Math.max(0.5, +(settings.pdfZoom - 0.1).toFixed(2))
})}><Minus size={14} /></button
>
<span>{Math.round(settings.pdfZoom * 100)}%</span>
<button
type="button"
aria-label="Zoom in"
onclick={() =>
updateSettings({
pdfZoom: Math.min(3, +(settings.pdfZoom + 0.1).toFixed(2))
})}><Plus size={14} /></button
>
</div>
</fieldset>
{:else}
<fieldset>
<legend>Text size</legend>
<div class="stepper">
<button
type="button"
aria-label="Smaller text"
onclick={() =>
updateSettings({ fontSize: Math.max(70, settings.fontSize - 10) })}
><Minus size={14} /></button
>
<span>{settings.fontSize}%</span>
<button
type="button"
aria-label="Larger text"
onclick={() =>
updateSettings({ fontSize: Math.min(220, settings.fontSize + 10) })}
><Plus size={14} /></button
>
</div>
</fieldset>
<fieldset>
<legend>Font</legend>
<div class="seg">
{#each [['publisher', 'Book'], ['serif', 'Serif'], ['sans', 'Sans']] as const as [value, label] (value)}
<button
type="button"
class={settings.font === value && 'on'}
onclick={() => updateSettings({ font: value })}>{label}</button
>
{/each}
</div>
</fieldset>
<fieldset>
<legend>Line spacing</legend>
<div class="seg">
{#each [1.3, 1.5, 1.75] as value (value)}
<button
type="button"
class={settings.lineHeight === value && 'on'}
onclick={() => updateSettings({ lineHeight: value })}
>{value === 1.3 ? 'Tight' : value === 1.5 ? 'Normal' : 'Loose'}</button
>
{/each}
</div>
</fieldset>
<fieldset>
<legend>Layout</legend>
<div class="seg">
<button
type="button"
class={settings.flow === 'paginated' && 'on'}
onclick={() => updateSettings({ flow: 'paginated' })}>Pages</button
>
<button
type="button"
class={settings.flow === 'scrolled' && 'on'}
onclick={() => updateSettings({ flow: 'scrolled' })}>Scroll</button
>
</div>
</fieldset>
{/if}
<p class="text-xs opacity-60">
Keys: ← → or h l to turn pages, t for contents, Esc to close.
</p>
</div>
{/if}
</div>
</aside>
{/if}
</div>
{#if selection || active}
<div class="sheet" role="dialog" aria-label={active ? 'Highlight' : 'Highlight selection'}>
{#if active}
<p class="sheet-quote">{active.highlight}</p>
<div class="flex items-center gap-2">
{#each highlightColors as c (c)}
<button
type="button"
class={['dot', active.color === c && 'on']}
style:background={highlightCss[c]}
aria-label={`Colour ${c}`}
onclick={() => patchHighlight(active, { color: c })}
></button>
{/each}
<span class="flex-1"></span>
<button
type="button"
class="icon-btn"
aria-label="Delete highlight"
onclick={() => removeHighlight(active)}><Trash2 size={16} /></button
>
<button
type="button"
class="icon-btn"
aria-label="Close"
onclick={() => (activeId = null)}><X size={16} /></button
>
</div>
<textarea
class="sheet-note"
rows="2"
placeholder="Add a note…"
bind:value={noteDraft}
onblur={() => {
if ((active.note ?? '') !== noteDraft.trim())
void patchHighlight(active, { note: noteDraft.trim() || null });
}}></textarea>
{:else if selection}
<div class="flex items-center gap-2">
{#each highlightColors as c (c)}
<button
type="button"
class="dot"
style:background={highlightCss[c]}
aria-label={`Highlight ${c}`}
title={`Highlight ${c}`}
onclick={() => createHighlight(c, noteFor === 'selection' ? noteDraft.trim() : null)}
></button>
{/each}
<span class="flex-1"></span>
<button
type="button"
class={['icon-btn', noteFor === 'selection' && 'on']}
aria-label="Highlight with a note"
onclick={() => {
noteFor = noteFor === 'selection' ? null : 'selection';
noteDraft = '';
}}><MessageSquarePlus size={16} /></button
>
<button
type="button"
class="icon-btn"
aria-label="Copy"
onclick={() => {
void navigator.clipboard?.writeText(selection?.text ?? '');
engine?.clearSelection();
selection = null;
}}><Copy size={16} /></button
>
</div>
{#if noteFor === 'selection'}
<!-- svelte-ignore a11y_autofocus -->
<textarea
class="sheet-note"
rows="2"
placeholder="Note, then pick a colour"
autofocus
bind:value={noteDraft}></textarea>
{/if}
{/if}
</div>
{/if}
<footer class="bar bottom">
<span class="w-12 text-xs tabular-nums opacity-70">{fmtPercent(percent)}</span>
<input
class="scrub"
type="range"
min="0"
max="100"
step="0.1"
value={percent}
aria-label="Position in book"
style:--fill={`${percent}%`}
oninput={(e) => (scrubbing = Number(e.currentTarget.value))}
onchange={async (e) => {
await engine?.goToPercent(Number(e.currentTarget.value));
scrubbing = null;
}}
/>
<span class="w-24 text-right text-xs tabular-nums opacity-70">
{#if location?.page && location.pageCount}{location.page} / {location.pageCount}{:else if saveState === 'saving'}saving…{:else if saveState === 'error'}not
saved{:else if saveState === 'saved'}saved{/if}
</span>
</footer>
</div>
<style>
.reader {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
flex-direction: column;
background: var(--r-bg);
color: var(--r-fg);
--r-line: color-mix(in srgb, var(--r-fg) 12%, transparent);
--r-soft: color-mix(in srgb, var(--r-fg) 7%, transparent);
}
.bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.75rem;
flex: none;
}
.top {
height: 3.25rem;
border-bottom: 1px solid var(--r-line);
}
.bottom {
height: 3rem;
border-top: 1px solid var(--r-line);
padding-bottom: env(safe-area-inset-bottom);
}
.icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
opacity: 0.75;
transition:
background 0.15s,
opacity 0.15s;
}
.icon-btn:hover,
.icon-btn.on {
opacity: 1;
background: var(--r-soft);
}
.count {
position: absolute;
top: 0.1rem;
right: 0.05rem;
font-size: 0.6rem;
min-width: 1rem;
line-height: 1rem;
border-radius: 999px;
background: var(--r-fg);
color: var(--r-bg);
font-variant-numeric: tabular-nums;
}
.stage {
position: relative;
flex: 1;
display: flex;
min-height: 0;
}
.book {
position: relative;
flex: 1;
min-width: 0;
}
.turn {
position: absolute;
top: 50%;
translate: 0 -50%;
z-index: 2;
width: 2.5rem;
height: 5rem;
display: none;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
opacity: 0.25;
transition: opacity 0.15s;
}
.turn:hover {
opacity: 0.8;
background: var(--r-soft);
}
.turn.left {
left: 0.5rem;
}
.turn.right {
right: 0.5rem;
}
@media (min-width: 768px) {
.turn {
display: flex;
}
}
.overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
background: var(--r-bg);
padding: 2rem;
}
.loadbar {
margin-top: 0.75rem;
width: 12rem;
height: 3px;
border-radius: 999px;
background: var(--r-line);
overflow: hidden;
}
.loadbar > span {
display: block;
height: 100%;
background: var(--r-fg);
transition: width 0.2s;
}
.panel {
width: 22rem;
max-width: 100%;
flex: none;
display: flex;
flex-direction: column;
border-left: 1px solid var(--r-line);
background: var(--r-bg);
}
@media (max-width: 900px) {
.panel {
position: absolute;
inset: 0 0 0 auto;
z-index: 5;
box-shadow: -12px 0 32px rgb(0 0 0 / 0.2);
}
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.75rem 0.6rem 1rem;
border-bottom: 1px solid var(--r-line);
}
.panel-head h2 {
font-size: 0.9rem;
font-weight: 600;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.empty {
font-size: 0.85rem;
opacity: 0.65;
padding: 0.5rem;
}
.toc-item {
display: block;
width: 100%;
text-align: left;
font-size: 0.875rem;
padding-block: 0.4rem;
padding-right: 0.5rem;
border-radius: 0.375rem;
opacity: 0.85;
}
.toc-item:hover {
background: var(--r-soft);
opacity: 1;
}
.toc-item.current {
font-weight: 600;
opacity: 1;
}
.hl-item {
display: block;
width: 100%;
text-align: left;
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
border-left: 3px solid var(--hl);
background: color-mix(in srgb, var(--hl) 10%, transparent);
}
.hl-item:hover {
background: color-mix(in srgb, var(--hl) 18%, transparent);
}
.hl-item .quote {
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
font-family: Georgia, serif;
font-size: 0.875rem;
line-height: 1.5;
}
.hl-item .note {
display: block;
margin-top: 0.35rem;
font-size: 0.8rem;
opacity: 0.8;
}
.hl-item .meta {
display: block;
margin-top: 0.25rem;
font-size: 0.7rem;
opacity: 0.55;
}
fieldset legend {
font-size: 0.75rem;
opacity: 0.65;
margin-bottom: 0.4rem;
}
.themes {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.4rem;
}
.theme-swatch {
height: 2.75rem;
border-radius: 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--r-line);
text-transform: capitalize;
}
.theme-swatch.on {
outline: 2px solid var(--r-fg);
outline-offset: 2px;
}
.stepper,
.seg {
display: flex;
align-items: center;
border: 1px solid var(--r-line);
border-radius: 0.5rem;
overflow: hidden;
}
.stepper button {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.25rem;
}
.stepper button:hover,
.seg button:hover {
background: var(--r-soft);
}
.stepper span {
flex: 1;
text-align: center;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.seg button {
flex: 1;
height: 2.25rem;
font-size: 0.8rem;
}
.seg button + button {
border-left: 1px solid var(--r-line);
}
.seg button.on {
background: var(--r-fg);
color: var(--r-bg);
}
.sheet {
position: absolute;
left: 50%;
bottom: 3.75rem;
translate: -50% 0;
z-index: 10;
width: min(26rem, calc(100% - 1.5rem));
padding: 0.6rem 0.75rem;
border-radius: 0.875rem;
border: 1px solid var(--r-line);
background: color-mix(in srgb, var(--r-bg) 92%, var(--r-fg));
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
animation: rise 0.15s ease-out;
}
@keyframes rise {
from {
opacity: 0;
translate: -50% 6px;
}
}
.sheet-quote {
font-family: Georgia, serif;
font-size: 0.875rem;
line-height: 1.5;
max-height: 6rem;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.sheet-note {
margin-top: 0.5rem;
width: 100%;
resize: vertical;
border-radius: 0.5rem;
border: 1px solid var(--r-line);
background: transparent;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
color: inherit;
}
.dot {
width: 1.6rem;
height: 1.6rem;
border-radius: 999px;
border: 2px solid transparent;
transition: transform 0.1s;
}
.dot:hover {
transform: scale(1.12);
}
.dot.on {
border-color: var(--r-fg);
}
.scrub {
flex: 1;
appearance: none;
height: 4px;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--r-fg) var(--fill),
color-mix(in srgb, var(--r-fg) 18%, transparent) var(--fill)
);
cursor: pointer;
}
.scrub::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 999px;
background: var(--r-fg);
}
.scrub::-moz-range-thumb {
width: 14px;
height: 14px;
border: 0;
border-radius: 999px;
background: var(--r-fg);
}
</style>

View File

@@ -0,0 +1,157 @@
// Auth gating for the book tracker: files, reader, progress and notes are owner-only and
// answer 404 to everyone else; the public pages never leak files or notes.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
vi.mock('#lib/server/db/index.js', () => ({ db: {} }));
vi.mock('#lib/server/books/library.js', () => ({
coverSrc: vi.fn(() => null),
getWork: vi.fn(),
workFiles: vi.fn(async () => []),
workNotes: vi.fn(async () => []),
workSessions: vi.fn(async () => []),
saveProgress: vi.fn(async () => ({ sessionId: 7 })),
setStatus: vi.fn(),
setRating: vi.fn(),
setManualProgress: vi.fn(),
logManualSession: vi.fn(),
libraryCards: vi.fn(async () => []),
libraryStats: vi.fn(async () => ({}))
}));
vi.mock('#lib/server/books/notes.js', async (original) => ({
...(await original<typeof import('#lib/server/books/notes.js')>()),
createNote: vi.fn(async () => ({ id: 1 })),
updateNote: vi.fn(async () => ({ id: 1 })),
deleteNote: vi.fn(async () => true)
}));
import * as library from '#lib/server/books/library.js';
import * as notes from '#lib/server/books/notes.js';
import { POST as progress } from './[id=integer]/progress/+server';
import { POST as createNote } from './[id=integer]/notes/+server';
import {
PATCH as patchNote,
DELETE as deleteNote
} from './[id=integer]/notes/[noteId=integer]/+server';
import { GET as file } from './files/[id=integer]/+server';
import { load as readerLoad } from './[id=integer]/read/+page.server';
import { actions, load as detailLoad } from './[id=integer]/+page.server';
const OWNER = { id: 'u1', email: 'stan@stanrunge.dev', role: 'admin' };
const STRANGER = { id: 'u2', email: 'someone@example.com', role: 'admin' };
function event(user: object | null, init: RequestInit = {}, params: Record<string, string> = {}) {
return {
locals: { user, session: null },
params: { id: '1', noteId: '1', ...params },
request: new Request('https://site.test/trackers/books/1', init),
url: new URL('https://site.test/trackers/books/1'),
setHeaders: vi.fn()
} as never;
}
async function status(fn: () => unknown) {
try {
const result = await fn();
return result instanceof Response ? result.status : 200;
} catch (e) {
if (isHttpError(e)) return e.status;
throw e;
}
}
const json = (body: unknown, method = 'POST') => ({
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
const work = {
id: 1,
title: 'Dune',
subtitle: null,
authors: ['Frank Herbert'],
year: 1965,
publisher: null,
isbn13: null,
isbn10: null,
language: 'en',
pageCount: 412,
description: null,
tags: [],
coverPath: null,
coverUrl: null,
openlibraryWorkId: null
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(library.getWork).mockResolvedValue({ work, progress: null } as never);
});
describe.each([
['anonymous', null],
['another signed-in user', STRANGER]
])('%s', (_, user) => {
it('gets 404 from every owner-only endpoint', async () => {
expect(await status(() => progress(event(user, json({ percent: 10 }))))).toBe(404);
expect(await status(() => createNote(event(user, json({ note: 'hi' }))))).toBe(404);
expect(await status(() => patchNote(event(user, json({ note: 'x' }, 'PATCH'))))).toBe(404);
expect(await status(() => deleteNote(event(user, { method: 'DELETE' })))).toBe(404);
expect(await status(() => file(event(user)))).toBe(404);
expect(await status(() => readerLoad(event(user)))).toBe(404);
for (const action of Object.values(actions))
expect(
await status(() => action(event(user, { method: 'POST', body: new FormData() })))
).toBe(404);
expect(library.saveProgress).not.toHaveBeenCalled();
expect(notes.createNote).not.toHaveBeenCalled();
expect(library.setStatus).not.toHaveBeenCalled();
});
it('sees the public catalogue page without files, notes or sessions', async () => {
const data = (await detailLoad(event(user))) as Record<string, unknown>;
expect(data.owner).toBe(false);
expect(data).not.toHaveProperty('files');
expect(data).not.toHaveProperty('notes');
expect(data).not.toHaveProperty('sessions');
expect(library.workNotes).not.toHaveBeenCalled();
});
});
describe('owner', () => {
it('saves progress and gets a session id back', async () => {
const res = await progress(
event(OWNER, json({ fileId: 2, locator: 'page:5', page: 5, percent: 1.234 }))
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ sessionId: 7 });
expect(library.saveProgress).toHaveBeenCalledWith(
1,
expect.objectContaining({ percent: 1.23, page: 5 })
);
});
it('rejects invalid progress', async () => {
expect(await status(() => progress(event(OWNER, json({ percent: 140 }))))).toBe(400);
expect(await status(() => progress(event(OWNER, { method: 'POST', body: '{' })))).toBe(400);
expect(library.saveProgress).not.toHaveBeenCalled();
});
it('404s progress for a book that does not exist', async () => {
vi.mocked(library.saveProgress).mockResolvedValueOnce(null);
expect(await status(() => progress(event(OWNER, json({ percent: 3 }))))).toBe(404);
});
it('creates highlights and refuses empty notes', async () => {
expect(
await status(() => createNote(event(OWNER, json({ highlight: 'quote', locator: 'page:1' }))))
).toBe(201);
expect(await status(() => createNote(event(OWNER, json({ note: ' ' }))))).toBe(400);
});
it('sees notes, files and sessions on the detail page', async () => {
const data = (await detailLoad(event(OWNER))) as Record<string, unknown>;
expect(data.owner).toBe(true);
expect(data).toHaveProperty('notes');
expect(data).toHaveProperty('files');
});
it('gets 404 from the reader when the book has no readable file', async () => {
expect(await status(() => readerLoad(event(OWNER)))).toBe(404);
});
});

View File

@@ -0,0 +1,23 @@
// Public cover images for the catalogue (covers only, never book files).
import { error } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookWorks } from '#lib/server/db/schema/index.js';
import { safeResolve, streamFile } from '#lib/server/books/files.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async (event) => {
const [work] = await db
.select({ coverPath: bookWorks.coverPath })
.from(bookWorks)
.where(eq(bookWorks.id, Number(event.params.id)))
.limit(1);
const resolved = work?.coverPath ? await safeResolve('covers', work.coverPath) : null;
if (!resolved) error(404, 'Not Found');
const response = await streamFile(resolved, event.request, {
'cache-control': 'public, max-age=86400, stale-while-revalidate=604800',
'x-content-type-options': 'nosniff'
});
if (!response.headers.get('content-type')?.startsWith('image/')) error(404, 'Not Found');
return response;
};

View File

@@ -0,0 +1,30 @@
// Owner-only book file download/stream for the reader. Everyone else gets 404, the same as
// a file that doesn't exist. Files are read from BOOKS_ROOT/library, never from static/.
import { error } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookFiles } from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { safeResolve, streamFile } from '#lib/server/books/files.js';
import type { RequestHandler } from './$types';
const handler: RequestHandler = async (event) => {
requireOwner(event);
const [file] = await db
.select({ path: bookFiles.path })
.from(bookFiles)
.where(eq(bookFiles.id, Number(event.params.id)))
.limit(1);
const resolved = file ? await safeResolve('library', file.path) : null;
if (!resolved) error(404, 'Not Found');
const name = resolved.split('/').at(-1) ?? 'book';
return streamFile(resolved, event.request, {
'cache-control': 'private, no-store',
'x-robots-tag': 'noindex, nofollow, noarchive',
'x-content-type-options': 'nosniff',
'content-disposition': `inline; filename*=UTF-8''${encodeURIComponent(name)}`
});
};
export const GET = handler;
export const HEAD = handler;