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:
@@ -1,7 +1,7 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks';
|
||||
import { building } from '$app/env';
|
||||
import { auth } from '#lib/server/auth.js';
|
||||
import { isOwner, isOwnerOnlyPath } from '#lib/server/owner.js';
|
||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
@@ -11,17 +11,15 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
|
||||
event.locals.user = session?.user ?? null;
|
||||
event.locals.session = session?.session ?? null;
|
||||
// Owner-only areas (health tracker). Page loads, __data.json and the import endpoint
|
||||
// 404 for non-owners in the route itself (layout load / endpoint), which renders the
|
||||
// same 404 page as an unknown URL. As a backstop, any other method (form actions,
|
||||
// which skip layout loads) is refused here before a route runs.
|
||||
// Owner-only areas (health tracker, book files, reader and book APIs). Page loads,
|
||||
// __data.json and endpoints 404 for non-owners in the route itself (layout load /
|
||||
// endpoint), which renders the same 404 page as an unknown URL. As a backstop, any other
|
||||
// method (form actions, which skip layout loads) is refused here before a route runs,
|
||||
// including writes to the otherwise public /trackers/books pages.
|
||||
const ownerOnly = isOwnerOnlyPath(event.url.pathname);
|
||||
if (
|
||||
ownerOnly &&
|
||||
!isOwner(event) &&
|
||||
event.request.method !== 'GET' &&
|
||||
event.request.method !== 'HEAD'
|
||||
)
|
||||
const owner = isOwner(event);
|
||||
const read = event.request.method === 'GET' || event.request.method === 'HEAD';
|
||||
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
|
||||
error(404, 'Not Found');
|
||||
const response = await resolve(event);
|
||||
if (ownerOnly) {
|
||||
|
||||
137
src/lib/books/BookCover.svelte
Normal file
137
src/lib/books/BookCover.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
// A book cover: the real image when there is one, otherwise a generated cover in a
|
||||
// colour derived from the title (also used when the image fails to load).
|
||||
import { authorLine, coverHue } from './shared.js';
|
||||
|
||||
let {
|
||||
src,
|
||||
title,
|
||||
authors = [],
|
||||
placeholder = false,
|
||||
size = 'md',
|
||||
class: className = ''
|
||||
}: {
|
||||
src: string | null;
|
||||
title: string;
|
||||
authors?: readonly string[];
|
||||
placeholder?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let failed = $state<string | null>(null);
|
||||
const showImage = $derived(!!src && failed !== src);
|
||||
const hue = $derived(coverHue(title));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={['cover', `cover-${size}`, placeholder && 'is-placeholder', className]}
|
||||
style:--hue={hue}
|
||||
>
|
||||
{#if showImage}
|
||||
<img
|
||||
{src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror={() => (failed = src)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="generated" aria-hidden="true">
|
||||
<span class="g-title">{title}</span>
|
||||
{#if authors.length}<span class="g-author">{authorLine(authors)}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cover {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0.375rem 0.625rem 0.625rem 0.375rem;
|
||||
background: var(--site-hover);
|
||||
box-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 0.25),
|
||||
0 8px 24px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
/* Spine highlight, like a real paperback. */
|
||||
.cover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(0 0 0 / 0.28) 0,
|
||||
rgb(255 255 255 / 0.12) 2.5%,
|
||||
rgb(0 0 0 / 0.08) 5%,
|
||||
transparent 9%
|
||||
);
|
||||
border-radius: inherit;
|
||||
}
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.generated {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 14% 10% 10% 14%;
|
||||
color: hsl(var(--hue) 30% 96%);
|
||||
background:
|
||||
radial-gradient(
|
||||
120% 80% at 100% 0%,
|
||||
hsl(calc(var(--hue) + 40) 60% 45% / 0.55),
|
||||
transparent 60%
|
||||
),
|
||||
linear-gradient(160deg, hsl(var(--hue) 45% 32%), hsl(calc(var(--hue) + 20) 50% 16%));
|
||||
}
|
||||
.g-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
font-size: 1rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 6;
|
||||
line-clamp: 6;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.g-author {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.cover-sm .g-title {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.cover-sm .g-author {
|
||||
display: none;
|
||||
}
|
||||
.cover-lg .g-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.cover-lg .g-author {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.is-placeholder {
|
||||
box-shadow: none;
|
||||
outline: 1.5px dashed color-mix(in srgb, var(--site-muted) 60%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.is-placeholder img,
|
||||
.is-placeholder .generated {
|
||||
filter: grayscale(0.85) opacity(0.55);
|
||||
}
|
||||
</style>
|
||||
68
src/lib/books/Stars.svelte
Normal file
68
src/lib/books/Stars.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
// 1-5 star rating. Read-only by default; with `name` it renders radio buttons for a form.
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
|
||||
let {
|
||||
value,
|
||||
size = 14,
|
||||
name,
|
||||
onchange
|
||||
}: {
|
||||
value: number | null;
|
||||
size?: number;
|
||||
name?: string;
|
||||
onchange?: (value: number | null) => void;
|
||||
} = $props();
|
||||
|
||||
let hover = $state<number | null>(null);
|
||||
const shown = $derived(hover ?? value ?? 0);
|
||||
</script>
|
||||
|
||||
{#if name || onchange}
|
||||
<span class="stars interactive" role="radiogroup" aria-label="Rating">
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<button
|
||||
type={name ? 'submit' : 'button'}
|
||||
{name}
|
||||
value={value === n ? '' : String(n)}
|
||||
role="radio"
|
||||
aria-checked={value === n}
|
||||
aria-label={`${n} star${n > 1 ? 's' : ''}${value === n ? ' (click to clear)' : ''}`}
|
||||
onpointerenter={() => (hover = n)}
|
||||
onpointerleave={() => (hover = null)}
|
||||
onclick={() => onchange?.(value === n ? null : n)}
|
||||
>
|
||||
<Star {size} class={n <= shown ? 'on' : 'off'} />
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
{:else if value}
|
||||
<span class="stars" aria-label={`${value} out of 5 stars`} title={`${value}/5`}>
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<Star {size} class={n <= value ? 'on' : 'off'} aria-hidden="true" />
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
.stars :global(.on) {
|
||||
fill: #f5b301;
|
||||
color: #f5b301;
|
||||
}
|
||||
.stars :global(.off) {
|
||||
color: color-mix(in srgb, var(--site-muted) 50%, transparent);
|
||||
}
|
||||
.interactive button {
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
}
|
||||
.interactive button:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
</style>
|
||||
56
src/lib/books/progress.test.ts
Normal file
56
src/lib/books/progress.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pagesRead, parseProgress } from './progress';
|
||||
import { parsePdfLocator, pdfLocator } from './shared';
|
||||
|
||||
describe('progress payload', () => {
|
||||
it('accepts a reader save and rounds the percent', () => {
|
||||
expect(
|
||||
parseProgress({
|
||||
fileId: 3,
|
||||
locator: 'epubcfi(/6/4!/4/2)',
|
||||
percent: 12.34567,
|
||||
sessionId: null
|
||||
})
|
||||
).toMatchObject({ fileId: 3, locator: 'epubcfi(/6/4!/4/2)', percent: 12.35 });
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
'nope',
|
||||
{},
|
||||
{ percent: -1 },
|
||||
{ percent: 101 },
|
||||
{ percent: 'ten' },
|
||||
{ percent: 10, page: 1.5 },
|
||||
{ percent: 10, fileId: -2 },
|
||||
{ percent: 10, locator: 'x'.repeat(2001) }
|
||||
])('rejects %j', (body) => {
|
||||
expect(parseProgress(body)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pages read', () => {
|
||||
it('uses the page delta when pages are known', () => {
|
||||
expect(pagesRead({ startPage: 10, endPage: 34 })).toBe(24);
|
||||
});
|
||||
it('scales the percent delta by the page count', () => {
|
||||
expect(pagesRead({ startPercent: 10, endPercent: 20, pageCount: 300 })).toBe(30);
|
||||
});
|
||||
it('never goes negative and needs enough data', () => {
|
||||
expect(pagesRead({ startPage: 50, endPage: 20 })).toBe(0);
|
||||
expect(pagesRead({ startPercent: 40, endPercent: 30, pageCount: 100 })).toBe(0);
|
||||
expect(pagesRead({ startPercent: 10, endPercent: 20 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pdf locators', () => {
|
||||
it('round-trips pages and rects', () => {
|
||||
const loc = pdfLocator(12, [{ x: 0.1, y: 0.2, w: 0.3333333, h: 0.02 }]);
|
||||
expect(loc).toBe('page:12#r=0.1,0.2,0.3333,0.02');
|
||||
expect(parsePdfLocator(loc)).toEqual({
|
||||
page: 12,
|
||||
rects: [{ x: 0.1, y: 0.2, w: 0.3333, h: 0.02 }]
|
||||
});
|
||||
expect(parsePdfLocator('page:3')).toEqual({ page: 3, rects: [] });
|
||||
expect(parsePdfLocator('epubcfi(/6/2)')).toBeNull();
|
||||
});
|
||||
});
|
||||
41
src/lib/books/progress.ts
Normal file
41
src/lib/books/progress.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Validation and maths for the reader's progress API (pure, unit tested).
|
||||
import { z } from 'zod';
|
||||
|
||||
export const progressPayload = z.object({
|
||||
fileId: z.number().int().positive().nullish(),
|
||||
locator: z.string().max(2000).nullish(),
|
||||
page: z.number().int().min(0).max(100000).nullish(),
|
||||
percent: z.number().min(0).max(100),
|
||||
sessionId: z.number().int().positive().nullish(),
|
||||
/** Total pages in the opened file (PDF), used for pages-read on sessions. */
|
||||
pageCount: z.number().int().positive().max(100000).nullish()
|
||||
});
|
||||
export type ProgressPayload = z.infer<typeof progressPayload>;
|
||||
|
||||
export function parseProgress(body: unknown): ProgressPayload | null {
|
||||
const result = progressPayload.safeParse(body);
|
||||
if (!result.success) return null;
|
||||
const value = result.data;
|
||||
return { ...value, percent: Math.round(value.percent * 100) / 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages read in a session: the page delta when both pages are known (PDF), otherwise the
|
||||
* percent delta scaled by the book's page count. Never negative (going back isn't reading).
|
||||
*/
|
||||
export function pagesRead(input: {
|
||||
startPage?: number | null;
|
||||
endPage?: number | null;
|
||||
startPercent?: number | null;
|
||||
endPercent?: number | null;
|
||||
pageCount?: number | null;
|
||||
}): number | null {
|
||||
const { startPage, endPage, startPercent, endPercent, pageCount } = input;
|
||||
if (startPage != null && endPage != null) return Math.max(0, endPage - startPage);
|
||||
if (startPercent != null && endPercent != null && pageCount)
|
||||
return Math.max(0, Math.round(((endPercent - startPercent) / 100) * pageCount));
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A reader session continues if the last save was less than this long ago. */
|
||||
export const SESSION_GAP_MS = 30 * 60 * 1000;
|
||||
228
src/lib/books/reader/EpubReader.svelte
Normal file
228
src/lib/books/reader/EpubReader.svelte
Normal file
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
// Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js.
|
||||
import { onMount } from 'svelte';
|
||||
import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js';
|
||||
import {
|
||||
fetchWithProgress,
|
||||
themeColors,
|
||||
type EngineProps,
|
||||
type ReaderSettings,
|
||||
type ReaderTheme,
|
||||
type TocItem
|
||||
} from './types.js';
|
||||
|
||||
type FoliateToc = { label?: string; href?: string; subitems?: FoliateToc[] | null };
|
||||
type Annotation = { value: string; color?: string };
|
||||
type Renderer = HTMLElement & {
|
||||
setStyles(css: string): void;
|
||||
getContents(): { doc: Document; index: number }[];
|
||||
};
|
||||
interface FoliateView extends HTMLElement {
|
||||
book: { toc?: FoliateToc[] };
|
||||
renderer: Renderer;
|
||||
open(file: File): Promise<void>;
|
||||
init(options: { lastLocation?: unknown; showTextStart?: boolean }): Promise<void>;
|
||||
close(): void;
|
||||
goTo(target: string | number): Promise<unknown>;
|
||||
goToFraction(fraction: number): Promise<void>;
|
||||
goLeft(): Promise<void>;
|
||||
goRight(): Promise<void>;
|
||||
addAnnotation(annotation: Annotation): Promise<unknown>;
|
||||
deleteAnnotation(annotation: Annotation): Promise<unknown>;
|
||||
getCFI(index: number, range: Range): string;
|
||||
}
|
||||
type DrawFn = (rects: unknown, options: Record<string, unknown>) => SVGElement;
|
||||
|
||||
let {
|
||||
url,
|
||||
name,
|
||||
start,
|
||||
startPercent,
|
||||
highlights,
|
||||
settings,
|
||||
theme,
|
||||
onready,
|
||||
onrelocate,
|
||||
onselection,
|
||||
onhighlightclick,
|
||||
onkey,
|
||||
onloading,
|
||||
onerror
|
||||
}: EngineProps = $props();
|
||||
|
||||
let host = $state<FoliateView>();
|
||||
let view = $state<FoliateView>();
|
||||
let highlightDraw: DrawFn | undefined;
|
||||
|
||||
const fonts = {
|
||||
serif: 'Charter, "Iowan Old Style", Georgia, "Palatino Linotype", serif',
|
||||
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
|
||||
};
|
||||
|
||||
function css(s: ReaderSettings, t: ReaderTheme) {
|
||||
const c = themeColors[t];
|
||||
const family = s.font === 'publisher' ? null : fonts[s.font];
|
||||
return `
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
html { color-scheme: ${t === 'dark' ? 'dark' : 'light'}; background: ${c.bg} !important; color: ${c.fg} !important; font-size: ${s.fontSize}% !important; }
|
||||
body { background: transparent !important; color: inherit !important; ${family ? `font-family: ${family} !important;` : ''} }
|
||||
${family ? 'body :not(pre, code, kbd, samp, tt, pre *, code *) { font-family: inherit !important; }' : ''}
|
||||
p, li, blockquote, dd, dt { line-height: ${s.lineHeight} !important; }
|
||||
p { hyphens: auto; -webkit-hyphens: auto; widows: 2; orphans: 2; }
|
||||
a:any-link { color: ${c.link} !important; }
|
||||
${t === 'light' ? '' : 'body *:not(a, a *) { color: inherit !important; background-color: transparent !important; border-color: currentColor; }'}
|
||||
img, svg, video { max-width: 100%; height: auto; }
|
||||
aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
|
||||
::selection { background: rgba(250, 204, 21, 0.45); }`;
|
||||
}
|
||||
|
||||
function apply(v: FoliateView) {
|
||||
const r = v.renderer;
|
||||
r.setAttribute('flow', settings.flow);
|
||||
r.setAttribute('margin', '48px');
|
||||
r.setAttribute('gap', '6%');
|
||||
r.setAttribute('max-inline-size', '720px');
|
||||
r.setAttribute('max-column-count', '2');
|
||||
r.setStyles(css(settings, theme));
|
||||
v.style.background = themeColors[theme].bg;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (view) apply(view);
|
||||
});
|
||||
|
||||
const color = (h: Highlight) =>
|
||||
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
|
||||
const toToc = (items: FoliateToc[] = []): TocItem[] =>
|
||||
items
|
||||
.filter((i) => i.href)
|
||||
.map((i) => ({
|
||||
label: (i.label ?? '').trim() || 'Untitled',
|
||||
target: i.href!,
|
||||
children: toToc(i.subitems ?? [])
|
||||
}));
|
||||
|
||||
export async function goTo(target: string | number) {
|
||||
await view?.goTo(target);
|
||||
}
|
||||
export async function goToPercent(percent: number) {
|
||||
await view?.goToFraction(Math.min(0.9999, Math.max(0, percent / 100)));
|
||||
}
|
||||
export function next() {
|
||||
void view?.goRight();
|
||||
}
|
||||
export function prev() {
|
||||
void view?.goLeft();
|
||||
}
|
||||
export function addHighlight(h: Highlight) {
|
||||
if (h.locator) void view?.addAnnotation({ value: h.locator, color: color(h) }).catch(() => {});
|
||||
}
|
||||
export function removeHighlight(h: Highlight) {
|
||||
if (h.locator) void view?.deleteAnnotation({ value: h.locator }).catch(() => {});
|
||||
}
|
||||
export function clearSelection() {
|
||||
for (const { doc } of view?.renderer.getContents() ?? []) doc.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
let el: FoliateView | undefined;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const base = '/vendor/foliate-js';
|
||||
const [, overlayer] = await Promise.all([
|
||||
import(/* @vite-ignore */ `${base}/view.js`),
|
||||
import(/* @vite-ignore */ `${base}/overlayer.js`)
|
||||
]);
|
||||
highlightDraw = overlayer.Overlayer.highlight;
|
||||
onloading(0);
|
||||
const blob = await fetchWithProgress(url, onloading, abort.signal);
|
||||
if (disposed) return;
|
||||
onloading(null);
|
||||
|
||||
el = host;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener('relocate', (e) => {
|
||||
const d = (e as CustomEvent).detail;
|
||||
onrelocate({
|
||||
percent: Math.max(0, Math.min(100, (d.fraction ?? 0) * 100)),
|
||||
locator: d.cfi,
|
||||
page: null,
|
||||
pageCount: null,
|
||||
label: d.tocItem?.label?.trim() || null
|
||||
});
|
||||
});
|
||||
el.addEventListener('load', (e) => {
|
||||
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
|
||||
doc.addEventListener('keydown', onkey);
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
doc.addEventListener('selectionchange', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const sel = doc.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
if (!sel || sel.isCollapsed || !sel.rangeCount || !text) return onselection(null);
|
||||
try {
|
||||
onselection({ text, locator: el!.getCFI(index, sel.getRangeAt(0)), page: null });
|
||||
} catch {
|
||||
onselection(null);
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
});
|
||||
el.addEventListener('create-overlay', () => {
|
||||
for (const h of highlights) addHighlight(h);
|
||||
});
|
||||
el.addEventListener('draw-annotation', (e) => {
|
||||
const { draw, annotation } = (e as CustomEvent).detail as {
|
||||
draw: (fn: DrawFn, opts: Record<string, unknown>) => void;
|
||||
annotation: Annotation;
|
||||
};
|
||||
if (highlightDraw)
|
||||
draw(highlightDraw, { color: annotation.color ?? highlightCss.yellow });
|
||||
});
|
||||
el.addEventListener('show-annotation', (e) => {
|
||||
const value = (e as CustomEvent).detail.value as string;
|
||||
const h = highlights.find((x) => x.locator === value);
|
||||
if (h) onhighlightclick(h.id);
|
||||
});
|
||||
|
||||
await el.open(new File([blob], name, { type: blob.type }));
|
||||
if (disposed) return;
|
||||
apply(el);
|
||||
view = el;
|
||||
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
|
||||
await el.init({ lastLocation, showTextStart: !lastLocation });
|
||||
onready(toToc(el.book.toc));
|
||||
} catch (e) {
|
||||
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
|
||||
onerror(e instanceof Error ? e.message : 'Could not open this book');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
el?.close?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="host">
|
||||
<foliate-view bind:this={host}></foliate-view>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.host :global(foliate-view) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
538
src/lib/books/reader/PdfReader.svelte
Normal file
538
src/lib/books/reader/PdfReader.svelte
Normal file
@@ -0,0 +1,538 @@
|
||||
<script lang="ts">
|
||||
// PDFs via pdf.js: continuous scroll, pages rendered lazily near the viewport, a text layer
|
||||
// for selection, and highlights stored as page-relative rectangles.
|
||||
import { onMount } from 'svelte';
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import {
|
||||
highlightCss,
|
||||
parsePdfLocator,
|
||||
pdfLocator,
|
||||
type Highlight,
|
||||
type HighlightColor,
|
||||
type Rect
|
||||
} from '#lib/books/shared.js';
|
||||
import { type EngineProps, type TocItem } from './types.js';
|
||||
|
||||
let {
|
||||
url,
|
||||
start,
|
||||
startPercent,
|
||||
highlights,
|
||||
settings,
|
||||
theme,
|
||||
onready,
|
||||
onrelocate,
|
||||
onselection,
|
||||
onhighlightclick,
|
||||
onloading,
|
||||
onerror
|
||||
}: EngineProps = $props();
|
||||
|
||||
type PageInfo = { num: number; width: number; height: number }; // at scale 1
|
||||
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement };
|
||||
|
||||
let scroller: HTMLDivElement;
|
||||
let pdf: PDFDocumentProxy | undefined;
|
||||
let pdfjs: typeof import('pdfjs-dist') | undefined;
|
||||
let pages = $state<PageInfo[]>([]);
|
||||
let width = $state(0);
|
||||
let current = 1;
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
|
||||
const live = new Map<number, Live>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
|
||||
const pageEls = new Map<number, HTMLElement>();
|
||||
let observer: IntersectionObserver | undefined;
|
||||
|
||||
const scale = $derived.by(() => {
|
||||
const first = pages[0];
|
||||
if (!first || !width) return 1;
|
||||
const fit = Math.min(width - 24, 920) / first.width;
|
||||
return Math.max(0.3, fit * settings.pdfZoom);
|
||||
});
|
||||
|
||||
const byPage = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
|
||||
const map = new Map<number, { h: Highlight; rects: Rect[] }[]>();
|
||||
for (const h of highlights) {
|
||||
const loc = parsePdfLocator(h.locator);
|
||||
if (!loc || !loc.rects.length) continue;
|
||||
const list = map.get(loc.page) ?? [];
|
||||
list.push({ h, rects: loc.rects });
|
||||
map.set(loc.page, list);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const color = (h: Highlight) =>
|
||||
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
|
||||
|
||||
async function render(num: number) {
|
||||
const el = pageEls.get(num);
|
||||
if (!pdf || !pdfjs || !el || live.has(num)) return;
|
||||
const layer = el.querySelector<HTMLElement>('.layer')!;
|
||||
const entry: Live = { el, layer };
|
||||
live.set(num, entry);
|
||||
const page: PDFPageProxy = await pdf.getPage(num);
|
||||
if (live.get(num) !== entry) return;
|
||||
const s = scale;
|
||||
const viewport = page.getViewport({ scale: s });
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.floor(viewport.width * dpr);
|
||||
canvas.height = Math.floor(viewport.height * dpr);
|
||||
const text = document.createElement('div');
|
||||
text.className = 'textLayer';
|
||||
layer.replaceChildren(canvas, text);
|
||||
el.style.setProperty('--scale-factor', String(s));
|
||||
el.style.setProperty('--total-scale-factor', String(s));
|
||||
entry.task = page.render({
|
||||
canvas,
|
||||
viewport,
|
||||
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined
|
||||
});
|
||||
try {
|
||||
await entry.task.promise;
|
||||
if (live.get(num) !== entry) return;
|
||||
await new pdfjs.TextLayer({
|
||||
textContentSource: page.streamTextContent(),
|
||||
container: text,
|
||||
viewport
|
||||
}).render();
|
||||
} catch {
|
||||
// cancelled (scrolled away or re-zoomed)
|
||||
}
|
||||
}
|
||||
|
||||
function unrender(num: number) {
|
||||
const entry = live.get(num);
|
||||
if (!entry) return;
|
||||
entry.task?.cancel();
|
||||
entry.layer.replaceChildren();
|
||||
live.delete(num);
|
||||
}
|
||||
|
||||
function page(node: HTMLElement, num: number) {
|
||||
pageEls.set(num, node);
|
||||
observer?.observe(node);
|
||||
return {
|
||||
destroy() {
|
||||
observer?.unobserve(node);
|
||||
pageEls.delete(num);
|
||||
unrender(num);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Re-render everything when the zoom or the width changes, keeping the reading position.
|
||||
let lastScale = 0;
|
||||
$effect(() => {
|
||||
const s = scale;
|
||||
if (!lastScale) {
|
||||
lastScale = s;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(s - lastScale) < 0.001) return;
|
||||
lastScale = s;
|
||||
const keep = position();
|
||||
for (const num of [...live.keys()]) unrender(num);
|
||||
requestAnimationFrame(() => {
|
||||
scrollToPosition(keep.page, keep.fraction);
|
||||
for (const [num, node] of pageEls) {
|
||||
const r = node.getBoundingClientRect();
|
||||
const v = scroller.getBoundingClientRect();
|
||||
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function position() {
|
||||
if (!scroller) return { page: 1, fraction: 0 };
|
||||
const anchor = scroller.scrollTop + scroller.clientHeight * 0.3;
|
||||
for (const p of pages) {
|
||||
const node = pageEls.get(p.num);
|
||||
if (!node) continue;
|
||||
const top = node.offsetTop;
|
||||
const bottom = top + node.offsetHeight;
|
||||
if (anchor < bottom + 8)
|
||||
return {
|
||||
page: p.num,
|
||||
fraction: Math.max(0, Math.min(1, (anchor - top) / node.offsetHeight))
|
||||
};
|
||||
}
|
||||
return { page: pages.length, fraction: 1 };
|
||||
}
|
||||
|
||||
function scrollToPosition(num: number, fraction = 0) {
|
||||
const node = pageEls.get(Math.max(1, Math.min(pages.length, num)));
|
||||
if (!node) return;
|
||||
scroller.scrollTop =
|
||||
node.offsetTop + node.offsetHeight * fraction - (fraction ? scroller.clientHeight * 0.3 : 12);
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
function onscroll() {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const { page: num, fraction } = position();
|
||||
current = num;
|
||||
onrelocate({
|
||||
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
|
||||
locator: pdfLocator(num),
|
||||
page: num,
|
||||
pageCount: pages.length,
|
||||
label: labelFor(num)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let flatToc: { page: number; label: string }[] = [];
|
||||
const labelFor = (num: number) => {
|
||||
let label: string | null = null;
|
||||
for (const item of flatToc) if (item.page <= num) label = item.label;
|
||||
return label;
|
||||
};
|
||||
|
||||
export async function goTo(target: string | number) {
|
||||
if (typeof target === 'number') return scrollToPosition(target);
|
||||
const loc = parsePdfLocator(target);
|
||||
if (!loc) return;
|
||||
const y = loc.rects[0]?.y;
|
||||
if (y != null) scrollToPosition(loc.page, Math.max(0, y - 0.05) || 0.0001);
|
||||
else scrollToPosition(loc.page);
|
||||
}
|
||||
export async function goToPercent(percent: number) {
|
||||
const p = (percent / 100) * pages.length;
|
||||
scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
|
||||
}
|
||||
export function next() {
|
||||
scrollToPosition(current + 1);
|
||||
}
|
||||
export function prev() {
|
||||
const { fraction } = position();
|
||||
scrollToPosition(fraction > 0.1 ? current : current - 1);
|
||||
}
|
||||
export function addHighlight() {
|
||||
// Highlights are derived from props; nothing to draw imperatively.
|
||||
}
|
||||
export function removeHighlight() {}
|
||||
export function clearSelection() {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
/** Selection rects as fractions of the page, merged per line. */
|
||||
function selectionRects(range: Range, pageEl: HTMLElement): Rect[] {
|
||||
const box = pageEl.getBoundingClientRect();
|
||||
const rects = [...range.getClientRects()]
|
||||
.filter((r) => r.width > 1 && r.height > 1)
|
||||
.map((r) => ({
|
||||
x: (r.left - box.left) / box.width,
|
||||
y: (r.top - box.top) / box.height,
|
||||
w: r.width / box.width,
|
||||
h: r.height / box.height
|
||||
}))
|
||||
.filter((r) => r.x >= -0.01 && r.y >= -0.01 && r.x < 1 && r.y < 1)
|
||||
.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const merged: Rect[] = [];
|
||||
for (const r of rects) {
|
||||
const last = merged.at(-1);
|
||||
if (last && Math.abs(last.y - r.y) < last.h * 0.5 && r.x <= last.x + last.w + 0.02) {
|
||||
const right = Math.max(last.x + last.w, r.x + r.w);
|
||||
const bottom = Math.max(last.y + last.h, r.y + r.h);
|
||||
last.x = Math.min(last.x, r.x);
|
||||
last.y = Math.min(last.y, r.y);
|
||||
last.w = right - last.x;
|
||||
last.h = bottom - last.y;
|
||||
} else merged.push({ ...r });
|
||||
}
|
||||
return merged.slice(0, 60);
|
||||
}
|
||||
|
||||
function hitHighlight(e: MouseEvent, num: number) {
|
||||
if (!document.getSelection()?.isCollapsed) return;
|
||||
const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = (e.clientX - box.left) / box.width;
|
||||
const y = (e.clientY - box.top) / box.height;
|
||||
const hit = byPage
|
||||
.get(num)
|
||||
?.find(({ rects }) =>
|
||||
rects.some((r) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h)
|
||||
);
|
||||
if (hit) onhighlightclick(hit.h.id);
|
||||
}
|
||||
|
||||
async function resolveToc(doc: PDFDocumentProxy): Promise<TocItem[]> {
|
||||
const outline = await doc.getOutline().catch(() => null);
|
||||
if (!outline) return [];
|
||||
let budget = 600;
|
||||
const walk = async (items: typeof outline): Promise<TocItem[]> => {
|
||||
const out: TocItem[] = [];
|
||||
for (const item of items) {
|
||||
if (budget-- <= 0) break;
|
||||
let num: number | null = null;
|
||||
try {
|
||||
const dest =
|
||||
typeof item.dest === 'string' ? await doc.getDestination(item.dest) : item.dest;
|
||||
const ref = dest?.[0];
|
||||
if (ref && typeof ref === 'object') num = (await doc.getPageIndex(ref)) + 1;
|
||||
else if (typeof ref === 'number') num = ref + 1;
|
||||
} catch {
|
||||
num = null;
|
||||
}
|
||||
const children = item.items?.length ? await walk(item.items) : [];
|
||||
if (num) {
|
||||
out.push({ label: item.title.trim() || `Page ${num}`, target: num, children });
|
||||
flatToc.push({ page: num, label: item.title.trim() });
|
||||
} else out.push(...children);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const toc = await walk(outline);
|
||||
flatToc = flatToc.sort((a, b) => a.page - b.page);
|
||||
return toc;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
const resize = new ResizeObserver(() => (width = scroller.clientWidth));
|
||||
resize.observe(scroller);
|
||||
width = scroller.clientWidth;
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const num = Number((entry.target as HTMLElement).dataset.page);
|
||||
if (entry.isIntersecting) void render(num);
|
||||
else unrender(num);
|
||||
}
|
||||
},
|
||||
{ root: scroller, rootMargin: '150% 0px' }
|
||||
);
|
||||
|
||||
let selTimer: ReturnType<typeof setTimeout>;
|
||||
const onSelectionChange = () => {
|
||||
clearTimeout(selTimer);
|
||||
selTimer = setTimeout(() => {
|
||||
const sel = document.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
const node = sel?.anchorNode;
|
||||
const pageEl = (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(
|
||||
'.pdf-page'
|
||||
);
|
||||
if (!sel || sel.isCollapsed || !text || !pageEl || !scroller.contains(pageEl))
|
||||
return onselection(null);
|
||||
const num = Number(pageEl.dataset.page);
|
||||
const rects = selectionRects(sel.getRangeAt(0), pageEl);
|
||||
if (!rects.length) return onselection(null);
|
||||
onselection({ text, locator: pdfLocator(num, rects), page: num });
|
||||
}, 250);
|
||||
};
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
pdfjs = await import('pdfjs-dist');
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
onloading(0);
|
||||
const task = pdfjs.getDocument({
|
||||
url,
|
||||
withCredentials: true,
|
||||
rangeChunkSize: 1 << 20
|
||||
});
|
||||
task.onProgress = ({ loaded, total }: { loaded: number; total: number }) =>
|
||||
onloading(total ? Math.min(1, loaded / total) : null);
|
||||
const doc = await task.promise;
|
||||
if (disposed) return void doc.destroy();
|
||||
pdf = doc;
|
||||
onloading(null);
|
||||
// Use the first page's size for every page up front; real sizes fill in quickly.
|
||||
const first = (await doc.getPage(1)).getViewport({ scale: 1 });
|
||||
pages = Array.from({ length: doc.numPages }, (_, i) => ({
|
||||
num: i + 1,
|
||||
width: first.width,
|
||||
height: first.height
|
||||
}));
|
||||
void (async () => {
|
||||
for (let i = 2; i <= Math.min(doc.numPages, 2000) && !disposed; i++) {
|
||||
const vp = (await doc.getPage(i)).getViewport({ scale: 1 });
|
||||
if (vp.width !== first.width || vp.height !== first.height)
|
||||
pages[i - 1] = { num: i, width: vp.width, height: vp.height };
|
||||
}
|
||||
})();
|
||||
requestAnimationFrame(async () => {
|
||||
const loc = parsePdfLocator(start);
|
||||
if (loc) scrollToPosition(loc.page, loc.rects[0]?.y ?? 0);
|
||||
else if (startPercent > 0) await goToPercent(startPercent);
|
||||
onscroll();
|
||||
onready(await resolveToc(doc));
|
||||
});
|
||||
} catch (e) {
|
||||
if (!disposed) onerror(e instanceof Error ? e.message : 'Could not open this PDF');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
resize.disconnect();
|
||||
observer?.disconnect();
|
||||
for (const num of [...live.keys()]) unrender(num);
|
||||
void pdf?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={['scroller', `t-${theme}`]}
|
||||
style:background={theme === 'light' ? '#e9e9e7' : theme === 'sepia' ? '#e6dcc4' : '#0b0b0c'}
|
||||
bind:this={scroller}
|
||||
{onscroll}
|
||||
>
|
||||
<div class="pages">
|
||||
{#each pages as p (p.num)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="pdf-page"
|
||||
data-page={p.num}
|
||||
style:width={`${Math.floor(p.width * scale)}px`}
|
||||
style:height={`${Math.floor(p.height * scale)}px`}
|
||||
use:page={p.num}
|
||||
onclick={(e) => hitHighlight(e, p.num)}
|
||||
>
|
||||
<div class="layer"></div>
|
||||
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
|
||||
{#each rects as r, i (i)}
|
||||
<span
|
||||
class="hl"
|
||||
style:left={`${r.x * 100}%`}
|
||||
style:top={`${r.y * 100}%`}
|
||||
style:width={`${r.w * 100}%`}
|
||||
style:height={`${r.h * 100}%`}
|
||||
style:background={color(h)}
|
||||
></span>
|
||||
{/each}
|
||||
{/each}
|
||||
<span class="num">{p.num}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroller {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 12px 96px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
.pdf-page {
|
||||
position: relative;
|
||||
background: white;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 0.12),
|
||||
0 8px 24px rgb(0 0 0 / 0.08);
|
||||
flex: none;
|
||||
}
|
||||
.t-sepia .pdf-page :global(canvas) {
|
||||
filter: sepia(0.35) brightness(0.97);
|
||||
}
|
||||
.t-dark .pdf-page {
|
||||
background: #1a1a1c;
|
||||
}
|
||||
.t-dark .pdf-page :global(canvas) {
|
||||
filter: invert(0.9) hue-rotate(180deg);
|
||||
}
|
||||
.layer,
|
||||
.layer :global(canvas) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.hl {
|
||||
position: absolute;
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.45;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.t-dark .hl {
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.num {
|
||||
position: absolute;
|
||||
right: -2.25rem;
|
||||
bottom: 0;
|
||||
font-size: 0.7rem;
|
||||
color: rgb(128 128 128 / 0.8);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.num {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* pdf.js text layer (from pdfjs-dist/web/pdf_viewer.css, trimmed) */
|
||||
.pdf-page :global(.textLayer) {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
inset: 0;
|
||||
overflow: clip;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
text-size-adjust: none;
|
||||
forced-color-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
caret-color: CanvasText;
|
||||
z-index: 2;
|
||||
--min-font-size: 1;
|
||||
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
--scale-round-x: 1px;
|
||||
--scale-round-y: 1px;
|
||||
}
|
||||
.pdf-page :global(.textLayer :is(span, br)) {
|
||||
color: transparent;
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
user-select: text;
|
||||
}
|
||||
.pdf-page :global(.textLayer > :not(.markedContent)),
|
||||
.pdf-page :global(.textLayer .markedContent span:not(.markedContent)) {
|
||||
z-index: 1;
|
||||
--font-height: 0;
|
||||
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
||||
--scale-x: 1;
|
||||
--rotate: 0deg;
|
||||
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
|
||||
}
|
||||
.pdf-page :global(.textLayer .markedContent) {
|
||||
display: contents;
|
||||
}
|
||||
.pdf-page :global(.textLayer ::selection) {
|
||||
background: rgb(250 204 21 / 0.4);
|
||||
color: transparent;
|
||||
}
|
||||
.pdf-page :global(.textLayer br::selection) {
|
||||
background: transparent;
|
||||
}
|
||||
.pdf-page :global(.textLayer .endOfContent) {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 100% 0 0;
|
||||
z-index: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
89
src/lib/books/reader/types.ts
Normal file
89
src/lib/books/reader/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Contract between the reader shell (the /read page) and the EPUB/PDF engines.
|
||||
import type { Highlight } from '#lib/books/shared.js';
|
||||
|
||||
export type TocItem = { label: string; target: string | number; children: TocItem[] };
|
||||
|
||||
export type Relocation = {
|
||||
percent: number; // 0-100
|
||||
locator: string; // EPUB CFI or `page:N`
|
||||
page: number | null; // PDF page (1-based), or null for reflowable books
|
||||
pageCount: number | null;
|
||||
label: string | null; // current chapter
|
||||
};
|
||||
|
||||
export type TextSelection = { text: string; locator: string; page: number | null };
|
||||
|
||||
export type ReaderTheme = 'light' | 'sepia' | 'dark';
|
||||
export type ReaderSettings = {
|
||||
theme: 'auto' | ReaderTheme;
|
||||
fontSize: number; // percent
|
||||
lineHeight: number;
|
||||
font: 'publisher' | 'serif' | 'sans';
|
||||
flow: 'paginated' | 'scrolled';
|
||||
pdfZoom: number; // 1 = fit width
|
||||
};
|
||||
export const defaultSettings: ReaderSettings = {
|
||||
theme: 'auto',
|
||||
fontSize: 100,
|
||||
lineHeight: 1.5,
|
||||
font: 'publisher',
|
||||
flow: 'paginated',
|
||||
pdfZoom: 1
|
||||
};
|
||||
|
||||
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
|
||||
light: { bg: '#fdfdfb', fg: '#1c1c1c', link: '#2563eb' },
|
||||
sepia: { bg: '#f4ecd8', fg: '#433422', link: '#8a4b14' },
|
||||
dark: { bg: '#121214', fg: '#d8d8d8', link: '#7aa8ff' }
|
||||
};
|
||||
|
||||
/** What both engines expose to the shell through bind:this. */
|
||||
export interface ReaderEngine {
|
||||
goTo(target: string | number): Promise<void>;
|
||||
goToPercent(percent: number): Promise<void>;
|
||||
next(): void;
|
||||
prev(): void;
|
||||
addHighlight(h: Highlight): void;
|
||||
removeHighlight(h: Highlight): void;
|
||||
clearSelection(): void;
|
||||
}
|
||||
|
||||
export type EngineProps = {
|
||||
url: string;
|
||||
name: string;
|
||||
start: string | null;
|
||||
startPercent: number;
|
||||
highlights: Highlight[];
|
||||
settings: ReaderSettings;
|
||||
theme: ReaderTheme;
|
||||
onready: (toc: TocItem[]) => void;
|
||||
onrelocate: (r: Relocation) => void;
|
||||
onselection: (s: TextSelection | null) => void;
|
||||
onhighlightclick: (id: number) => void;
|
||||
onkey: (e: KeyboardEvent) => void;
|
||||
onloading: (fraction: number | null) => void;
|
||||
onerror: (message: string) => void;
|
||||
};
|
||||
|
||||
/** Downloads a file with progress, so big books show a loading bar instead of a blank page. */
|
||||
export async function fetchWithProgress(
|
||||
url: string,
|
||||
onprogress: (fraction: number | null) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(url, { signal, credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'File not found' : `HTTP ${res.status}`);
|
||||
const total = Number(res.headers.get('content-length')) || 0;
|
||||
if (!res.body || !total) return res.blob();
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
let loaded = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
loaded += value.byteLength;
|
||||
onprogress(Math.min(1, loaded / total));
|
||||
}
|
||||
return new Blob(chunks, { type: res.headers.get('content-type') ?? '' });
|
||||
}
|
||||
104
src/lib/books/shared.ts
Normal file
104
src/lib/books/shared.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Shared (client + server) types and helpers for the book tracker.
|
||||
export const readingStatuses = ['want_to_read', 'reading', 'finished', 'abandoned'] as const;
|
||||
export type ReadingStatus = (typeof readingStatuses)[number];
|
||||
|
||||
export const statusLabels: Record<ReadingStatus, string> = {
|
||||
want_to_read: 'want to read',
|
||||
reading: 'reading',
|
||||
finished: 'finished',
|
||||
abandoned: 'abandoned'
|
||||
};
|
||||
|
||||
export function isReadingStatus(value: unknown): value is ReadingStatus {
|
||||
return typeof value === 'string' && (readingStatuses as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Formats the in-browser reader can open. MOBI/AZW3/FB2/CBZ go through foliate-js too. */
|
||||
export const readableFormats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz'] as const;
|
||||
export function isReadable(format: string): boolean {
|
||||
return (readableFormats as readonly string[]).includes(format.toLowerCase());
|
||||
}
|
||||
/** Preferred file to open when a work has several formats. */
|
||||
export const formatPreference = ['epub', 'azw3', 'mobi', 'azw', 'fb2', 'pdf', 'cbz'];
|
||||
|
||||
/** One card in the library. Public fields only, unless noted. */
|
||||
export type BookCard = {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
authors: string[];
|
||||
year: number | null;
|
||||
tags: string[];
|
||||
pageCount: number | null;
|
||||
cover: string | null; // URL (our cover endpoint or a remote cover), or null for a generated one
|
||||
status: ReadingStatus | null; // null: never touched
|
||||
percent: number;
|
||||
rating: number | null;
|
||||
lastReadAt: string | null;
|
||||
finishedAt: string | null;
|
||||
addedAt: string;
|
||||
// Owner only (empty / false for everyone else):
|
||||
formats: string[];
|
||||
hasFile: boolean;
|
||||
};
|
||||
|
||||
export type LibraryStats = {
|
||||
total: number;
|
||||
finishedThisYear: number;
|
||||
pagesThisYear: number;
|
||||
reading: number;
|
||||
year: number;
|
||||
};
|
||||
|
||||
export type Highlight = {
|
||||
id: number;
|
||||
fileId: number | null;
|
||||
locator: string | null;
|
||||
page: number | null;
|
||||
highlight: string | null;
|
||||
note: string | null;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export const highlightColors = ['yellow', 'green', 'blue', 'pink', 'purple'] as const;
|
||||
export type HighlightColor = (typeof highlightColors)[number];
|
||||
export const highlightCss: Record<HighlightColor, string> = {
|
||||
yellow: '#facc15',
|
||||
green: '#4ade80',
|
||||
blue: '#60a5fa',
|
||||
pink: '#f472b6',
|
||||
purple: '#c084fc'
|
||||
};
|
||||
|
||||
/** Deterministic hue for generated covers, so a book always gets the same colour. */
|
||||
export function coverHue(title: string): number {
|
||||
let hash = 0;
|
||||
for (const char of title) hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
|
||||
return hash % 360;
|
||||
}
|
||||
|
||||
export function authorLine(authors: readonly string[]): string {
|
||||
if (authors.length <= 2) return authors.join(' & ');
|
||||
return `${authors[0]} et al.`;
|
||||
}
|
||||
|
||||
/** PDF highlight locators: `page:12` or `page:12#r=x,y,w,h;x,y,w,h` (rects as page fractions). */
|
||||
export type Rect = { x: number; y: number; w: number; h: number };
|
||||
export function pdfLocator(page: number, rects: Rect[] = []): string {
|
||||
const r = rects
|
||||
.map((rect) => [rect.x, rect.y, rect.w, rect.h].map((n) => +n.toFixed(4)).join(','))
|
||||
.join(';');
|
||||
return r ? `page:${page}#r=${r}` : `page:${page}`;
|
||||
}
|
||||
export function parsePdfLocator(locator: string | null): { page: number; rects: Rect[] } | null {
|
||||
const match = /^page:(\d+)(?:#r=([\d.,;-]+))?$/.exec(locator ?? '');
|
||||
if (!match) return null;
|
||||
const rects = (match[2] ?? '')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.split(',').map(Number))
|
||||
.filter((n) => n.length === 4 && n.every(Number.isFinite))
|
||||
.map(([x, y, w, h]) => ({ x, y, w, h }));
|
||||
return { page: Number(match[1]), rects };
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import CandlestickChart from 'lucide-svelte/icons/candlestick-chart';
|
||||
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
|
||||
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
|
||||
@@ -36,7 +37,8 @@
|
||||
'cs prices': CandlestickChart,
|
||||
'house tracker': House,
|
||||
lists: ListChecks,
|
||||
health: HeartPulse
|
||||
health: HeartPulse,
|
||||
books: BookOpen
|
||||
};
|
||||
|
||||
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);
|
||||
|
||||
@@ -70,6 +70,12 @@ export const trackers = [
|
||||
href: '/projects/animaldex',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'books',
|
||||
subtitle: 'my library: what I am reading, have read and want to read',
|
||||
href: '/trackers/books',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'lists',
|
||||
subtitle: 'books, games, places and other things worth keeping',
|
||||
|
||||
@@ -38,7 +38,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
||||
'/trackers': {
|
||||
title: 'Trackers',
|
||||
description:
|
||||
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Dutch energy, Animaldex and lists.'
|
||||
'Stan Runge’s trackers: games, money, houses, books, Counter-Strike prices, Dutch energy, Animaldex and lists.'
|
||||
},
|
||||
'/trackers/books': {
|
||||
title: 'Books',
|
||||
description: 'Stan Runge’s library: books being read, finished and on the to-read pile.'
|
||||
},
|
||||
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
||||
'/projects/game-tracker': {
|
||||
|
||||
77
src/lib/server/books/files.test.ts
Normal file
77
src/lib/server/books/files.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseRange, safeResolve, streamFile } from './files';
|
||||
|
||||
let root: string;
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'books-'));
|
||||
await mkdir(path.join(root, 'library', 'a'), { recursive: true });
|
||||
await writeFile(path.join(root, 'library', 'a', 'book.epub'), '0123456789');
|
||||
await writeFile(path.join(root, 'secret.txt'), 'nope');
|
||||
await symlink(path.join(root, 'secret.txt'), path.join(root, 'library', 'escape.epub'));
|
||||
});
|
||||
afterAll(() => rm(root, { recursive: true, force: true }));
|
||||
|
||||
describe('safeResolve', () => {
|
||||
it('finds files inside the library', async () => {
|
||||
expect(await safeResolve('library', 'a/book.epub', root)).toBe(
|
||||
path.join(root, 'library', 'a', 'book.epub')
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
'../secret.txt',
|
||||
'a/../../secret.txt',
|
||||
'/etc/passwd',
|
||||
'escape.epub',
|
||||
'a',
|
||||
'',
|
||||
'missing.pdf'
|
||||
])('refuses %s', async (relative) => {
|
||||
expect(await safeResolve('library', relative, root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRange', () => {
|
||||
it('parses single ranges', () => {
|
||||
expect(parseRange('bytes=0-3', 10)).toEqual({ start: 0, end: 3 });
|
||||
expect(parseRange('bytes=5-', 10)).toEqual({ start: 5, end: 9 });
|
||||
expect(parseRange('bytes=-4', 10)).toEqual({ start: 6, end: 9 });
|
||||
expect(parseRange('bytes=8-100', 10)).toEqual({ start: 8, end: 9 });
|
||||
});
|
||||
it('ignores or rejects bad ranges', () => {
|
||||
expect(parseRange(null, 10)).toBeNull();
|
||||
expect(parseRange('bytes=0-1,4-5', 10)).toBeNull();
|
||||
expect(parseRange('bytes=10-', 10)).toBe('invalid');
|
||||
expect(parseRange('bytes=5-2', 10)).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamFile', () => {
|
||||
const file = () => path.join(root, 'library', 'a', 'book.epub');
|
||||
it('serves the whole file', async () => {
|
||||
const res = await streamFile(file(), new Request('http://x/'), {});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/epub+zip');
|
||||
expect(await res.text()).toBe('0123456789');
|
||||
});
|
||||
it('serves partial content', async () => {
|
||||
const res = await streamFile(
|
||||
file(),
|
||||
new Request('http://x/', { headers: { range: 'bytes=2-4' } }),
|
||||
{}
|
||||
);
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get('content-range')).toBe('bytes 2-4/10');
|
||||
expect(await res.text()).toBe('234');
|
||||
});
|
||||
it('answers 416 for unsatisfiable ranges', async () => {
|
||||
const res = await streamFile(
|
||||
file(),
|
||||
new Request('http://x/', { headers: { range: 'bytes=20-' } }),
|
||||
{}
|
||||
);
|
||||
expect(res.status).toBe(416);
|
||||
});
|
||||
});
|
||||
115
src/lib/server/books/files.ts
Normal file
115
src/lib/server/books/files.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Book files and covers on disk. Files are owner-only and only ever streamed through an
|
||||
// authenticated endpoint; nothing here is under static/.
|
||||
//
|
||||
// BOOKS_ROOT holds library/ (book files, books.files.path is relative to it) and covers/
|
||||
// (books.works.cover_path is relative to it). In prod the container gets the host's books
|
||||
// directory as a read-only bind mount at /books (see scripts/deploy-remote.sh).
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export function booksRoot(): string {
|
||||
if (process.env.BOOKS_ROOT) return process.env.BOOKS_ROOT;
|
||||
return existsSync('/books/library') ? '/books' : path.join(homedir(), 'books');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `relative` inside `<root>/<area>`, following symlinks, and refuses anything that
|
||||
* escapes it (`..`, absolute paths, symlinks out). Returns null when missing or unsafe.
|
||||
*/
|
||||
export async function safeResolve(
|
||||
area: 'library' | 'covers',
|
||||
relative: string,
|
||||
root = booksRoot()
|
||||
): Promise<string | null> {
|
||||
if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null;
|
||||
const base = path.join(root, area);
|
||||
try {
|
||||
const realBase = await realpath(base);
|
||||
const real = await realpath(path.join(base, relative));
|
||||
if (real !== realBase && !real.startsWith(realBase + path.sep)) return null;
|
||||
return (await stat(real)).isFile() ? real : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
epub: 'application/epub+zip',
|
||||
pdf: 'application/pdf',
|
||||
mobi: 'application/x-mobipocket-ebook',
|
||||
azw: 'application/vnd.amazon.ebook',
|
||||
azw3: 'application/vnd.amazon.ebook',
|
||||
fb2: 'application/x-fictionbook+xml',
|
||||
cbz: 'application/vnd.comicbook+zip',
|
||||
djvu: 'image/vnd.djvu',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
avif: 'image/avif'
|
||||
};
|
||||
export function contentType(file: string): string {
|
||||
return contentTypes[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** Parses a single `bytes=a-b` range. Multi-range requests get the whole file. */
|
||||
export function parseRange(
|
||||
header: string | null,
|
||||
size: number
|
||||
): { start: number; end: number } | 'invalid' | null {
|
||||
if (!header) return null;
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!match || (!match[1] && !match[2])) return null;
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (!match[1]) {
|
||||
const suffix = Number(match[2]);
|
||||
if (suffix === 0) return 'invalid';
|
||||
start = Math.max(0, size - suffix);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(match[1]);
|
||||
end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
||||
}
|
||||
if (start > end || start >= size) return 'invalid';
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Streams a file with Range support (pdf.js fetches large PDFs in chunks). */
|
||||
export async function streamFile(
|
||||
file: string,
|
||||
request: Request,
|
||||
headers: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const { size, mtime } = await stat(file);
|
||||
const base = {
|
||||
'content-type': contentType(file),
|
||||
'accept-ranges': 'bytes',
|
||||
'last-modified': mtime.toUTCString(),
|
||||
...headers
|
||||
};
|
||||
const range = parseRange(request.headers.get('range'), size);
|
||||
if (range === 'invalid')
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { ...base, 'content-range': `bytes */${size}` }
|
||||
});
|
||||
const { start, end } = range ?? { start: 0, end: size - 1 };
|
||||
const length = size === 0 ? 0 : end - start + 1;
|
||||
const body =
|
||||
request.method === 'HEAD' || size === 0
|
||||
? null
|
||||
: (Readable.toWeb(createReadStream(file, { start, end })) as unknown as ReadableStream);
|
||||
return new Response(body, {
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
...base,
|
||||
'content-length': String(length),
|
||||
...(range ? { 'content-range': `bytes ${start}-${end}/${size}` } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
373
src/lib/server/books/library.ts
Normal file
373
src/lib/server/books/library.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
// Book tracker queries. Public callers get catalogue fields only; owner-only data (files,
|
||||
// notes, highlights, sessions) is loaded separately and only after requireOwner().
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
bookFiles,
|
||||
bookNotes,
|
||||
bookProgress,
|
||||
bookSessions,
|
||||
bookWorks
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { and, asc, desc, eq, gte, sql } from 'drizzle-orm';
|
||||
import {
|
||||
formatPreference,
|
||||
isReadable,
|
||||
type BookCard,
|
||||
type Highlight,
|
||||
type LibraryStats,
|
||||
type ReadingStatus
|
||||
} from '#lib/books/shared.js';
|
||||
import { pagesRead, SESSION_GAP_MS, type ProgressPayload } from '#lib/books/progress.js';
|
||||
|
||||
const TIME_ZONE = 'Europe/Amsterdam';
|
||||
export const currentYear = () =>
|
||||
Number(
|
||||
new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE, year: 'numeric' }).format(new Date())
|
||||
);
|
||||
|
||||
export function coverSrc(work: {
|
||||
id: number;
|
||||
coverPath: string | null;
|
||||
coverUrl: string | null;
|
||||
updatedAt?: Date | null;
|
||||
}) {
|
||||
if (work.coverPath)
|
||||
return `/trackers/books/covers/${work.id}${work.updatedAt ? `?v=${work.updatedAt.getTime().toString(36)}` : ''}`;
|
||||
if (work.coverUrl?.startsWith('https://')) return work.coverUrl;
|
||||
return null;
|
||||
}
|
||||
|
||||
const iso = (d: Date | null | undefined) => (d ? d.toISOString() : null);
|
||||
|
||||
export async function libraryCards(owner: boolean): Promise<BookCard[]> {
|
||||
const formats = db
|
||||
.select({
|
||||
workId: bookFiles.workId,
|
||||
formats: sql<string[]>`array_agg(distinct lower(${bookFiles.format}))`.as('formats')
|
||||
})
|
||||
.from(bookFiles)
|
||||
.groupBy(bookFiles.workId)
|
||||
.as('f');
|
||||
const rows = await db
|
||||
.select({
|
||||
id: bookWorks.id,
|
||||
title: bookWorks.title,
|
||||
subtitle: bookWorks.subtitle,
|
||||
authors: bookWorks.authors,
|
||||
year: bookWorks.year,
|
||||
tags: bookWorks.tags,
|
||||
pageCount: bookWorks.pageCount,
|
||||
coverPath: bookWorks.coverPath,
|
||||
coverUrl: bookWorks.coverUrl,
|
||||
createdAt: bookWorks.createdAt,
|
||||
updatedAt: bookWorks.updatedAt,
|
||||
status: bookProgress.status,
|
||||
percent: bookProgress.percent,
|
||||
rating: bookProgress.rating,
|
||||
lastReadAt: bookProgress.lastReadAt,
|
||||
finishedAt: bookProgress.finishedAt,
|
||||
formats: formats.formats
|
||||
})
|
||||
.from(bookWorks)
|
||||
.leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id))
|
||||
.leftJoin(formats, eq(formats.workId, bookWorks.id))
|
||||
.orderBy(asc(bookWorks.title));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle,
|
||||
authors: r.authors,
|
||||
year: r.year,
|
||||
tags: r.tags,
|
||||
pageCount: r.pageCount,
|
||||
cover: coverSrc(r),
|
||||
status: r.status ?? null,
|
||||
percent: r.percent ?? 0,
|
||||
rating: r.rating ?? null,
|
||||
lastReadAt: iso(r.lastReadAt),
|
||||
finishedAt: iso(r.finishedAt),
|
||||
addedAt: r.createdAt.toISOString(),
|
||||
formats: owner ? (r.formats ?? []) : [],
|
||||
hasFile: owner ? (r.formats?.length ?? 0) > 0 : false
|
||||
}));
|
||||
}
|
||||
|
||||
export async function libraryStats(): Promise<LibraryStats> {
|
||||
const year = currentYear();
|
||||
const yearStart = sql`make_timestamptz(${year}, 1, 1, 0, 0, 0, ${TIME_ZONE})`;
|
||||
const [[counts], [pages]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: sql<number>`(select count(*)::int from ${bookWorks})`,
|
||||
finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
|
||||
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::int`
|
||||
})
|
||||
.from(bookProgress),
|
||||
db
|
||||
.select({ pages: sql<number>`coalesce(sum(${bookSessions.pagesRead}), 0)::int` })
|
||||
.from(bookSessions)
|
||||
.where(gte(bookSessions.startedAt, yearStart))
|
||||
]);
|
||||
return {
|
||||
total: counts?.total ?? 0,
|
||||
finishedThisYear: counts?.finished ?? 0,
|
||||
pagesThisYear: pages?.pages ?? 0,
|
||||
reading: counts?.reading ?? 0,
|
||||
year
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWork(id: number) {
|
||||
const [work] = await db.select().from(bookWorks).where(eq(bookWorks.id, id)).limit(1);
|
||||
if (!work) return null;
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(bookProgress)
|
||||
.where(eq(bookProgress.workId, id))
|
||||
.limit(1);
|
||||
return { work, progress: progress ?? null };
|
||||
}
|
||||
|
||||
export async function workFiles(workId: number) {
|
||||
const files = await db
|
||||
.select({
|
||||
id: bookFiles.id,
|
||||
path: bookFiles.path,
|
||||
format: bookFiles.format,
|
||||
sizeBytes: bookFiles.sizeBytes
|
||||
})
|
||||
.from(bookFiles)
|
||||
.where(eq(bookFiles.workId, workId));
|
||||
const rank = (f: string) => {
|
||||
const i = formatPreference.indexOf(f.toLowerCase());
|
||||
return i === -1 ? 99 : i;
|
||||
};
|
||||
return files
|
||||
.map((f) => ({ ...f, format: f.format.toLowerCase(), readable: isReadable(f.format) }))
|
||||
.sort((a, b) => rank(a.format) - rank(b.format));
|
||||
}
|
||||
|
||||
export async function workNotes(workId: number): Promise<Highlight[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(bookNotes)
|
||||
.where(eq(bookNotes.workId, workId))
|
||||
.orderBy(desc(bookNotes.createdAt));
|
||||
return rows.map((n) => ({
|
||||
id: n.id,
|
||||
fileId: n.fileId,
|
||||
locator: n.locator,
|
||||
page: n.page,
|
||||
highlight: n.highlight,
|
||||
note: n.note,
|
||||
color: n.color,
|
||||
createdAt: n.createdAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
export async function workSessions(workId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(bookSessions)
|
||||
.where(eq(bookSessions.workId, workId))
|
||||
.orderBy(desc(bookSessions.startedAt))
|
||||
.limit(200);
|
||||
return rows.map((s) => ({
|
||||
id: s.id,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
endedAt: iso(s.endedAt),
|
||||
startPercent: s.startPercent,
|
||||
endPercent: s.endPercent,
|
||||
startPage: s.startPage,
|
||||
endPage: s.endPage,
|
||||
pagesRead: s.pagesRead,
|
||||
source: s.source,
|
||||
note: s.note
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sets the reading status, keeping the start/finish dates and percent consistent. */
|
||||
export async function setStatus(workId: number, status: ReadingStatus) {
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(bookProgress)
|
||||
.values({
|
||||
workId,
|
||||
status,
|
||||
startedAt: status === 'want_to_read' ? null : now,
|
||||
finishedAt: status === 'finished' ? now : null,
|
||||
percent: status === 'finished' ? 100 : 0
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: bookProgress.workId,
|
||||
set: {
|
||||
status,
|
||||
startedAt:
|
||||
status === 'want_to_read'
|
||||
? sql`${bookProgress.startedAt}`
|
||||
: sql`coalesce(${bookProgress.startedAt}, ${now.toISOString()}::timestamptz)`,
|
||||
finishedAt:
|
||||
status === 'finished'
|
||||
? sql`coalesce(${bookProgress.finishedAt}, ${now.toISOString()}::timestamptz)`
|
||||
: null,
|
||||
percent: status === 'finished' ? 100 : sql`${bookProgress.percent}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function setRating(workId: number, rating: number | null) {
|
||||
await db
|
||||
.insert(bookProgress)
|
||||
.values({ workId, rating })
|
||||
.onConflictDoUpdate({ target: bookProgress.workId, set: { rating } });
|
||||
}
|
||||
|
||||
/** Manual progress from the detail page (percent and/or page). */
|
||||
export async function setManualProgress(workId: number, percent: number, page: number | null) {
|
||||
await db
|
||||
.insert(bookProgress)
|
||||
.values({
|
||||
workId,
|
||||
percent,
|
||||
page,
|
||||
status: percent > 0 ? 'reading' : 'want_to_read',
|
||||
startedAt: percent > 0 ? new Date() : null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: bookProgress.workId,
|
||||
set: {
|
||||
percent,
|
||||
page,
|
||||
status: sql`case when ${bookProgress.status} = 'want_to_read' and ${percent} > 0 then 'reading'::books.reading_status else ${bookProgress.status} end`,
|
||||
startedAt: sql`coalesce(${bookProgress.startedAt}, case when ${percent} > 0 then now() end)`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the reader position and keeps a reading session going. Returns the session id the
|
||||
* reader should send back with its next save. A save more than SESSION_GAP_MS after the
|
||||
* last one starts a new session.
|
||||
*/
|
||||
export async function saveProgress(workId: number, input: ProgressPayload) {
|
||||
return db.transaction(async (tx) => {
|
||||
const [work] = await tx
|
||||
.select({ pageCount: bookWorks.pageCount })
|
||||
.from(bookWorks)
|
||||
.where(eq(bookWorks.id, workId))
|
||||
.limit(1);
|
||||
if (!work) return null;
|
||||
if (input.fileId) {
|
||||
const [file] = await tx
|
||||
.select({ id: bookFiles.id })
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
|
||||
.limit(1);
|
||||
if (!file) return null;
|
||||
}
|
||||
const [before] = await tx
|
||||
.select()
|
||||
.from(bookProgress)
|
||||
.where(eq(bookProgress.workId, workId))
|
||||
.limit(1);
|
||||
const now = new Date();
|
||||
await tx
|
||||
.insert(bookProgress)
|
||||
.values({
|
||||
workId,
|
||||
fileId: input.fileId ?? null,
|
||||
locator: input.locator ?? null,
|
||||
page: input.page ?? null,
|
||||
percent: input.percent,
|
||||
status: 'reading',
|
||||
startedAt: now,
|
||||
lastReadAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: bookProgress.workId,
|
||||
set: {
|
||||
fileId: input.fileId ?? null,
|
||||
locator: input.locator ?? null,
|
||||
page: input.page ?? null,
|
||||
percent: input.percent,
|
||||
lastReadAt: now,
|
||||
status: sql`case when ${bookProgress.status} = 'want_to_read' then 'reading'::books.reading_status else ${bookProgress.status} end`,
|
||||
startedAt: sql`coalesce(${bookProgress.startedAt}, now())`
|
||||
}
|
||||
});
|
||||
|
||||
const pageCount = input.pageCount ?? work.pageCount;
|
||||
let session = input.sessionId
|
||||
? (
|
||||
await tx
|
||||
.select()
|
||||
.from(bookSessions)
|
||||
.where(and(eq(bookSessions.id, input.sessionId), eq(bookSessions.workId, workId)))
|
||||
.limit(1)
|
||||
)[0]
|
||||
: undefined;
|
||||
const last = session?.endedAt ?? session?.startedAt;
|
||||
if (session && last && now.getTime() - last.getTime() > SESSION_GAP_MS) session = undefined;
|
||||
if (!session) {
|
||||
[session] = await tx
|
||||
.insert(bookSessions)
|
||||
.values({
|
||||
workId,
|
||||
fileId: input.fileId ?? null,
|
||||
startedAt: now,
|
||||
endedAt: now,
|
||||
startPercent: before?.percent ?? input.percent,
|
||||
endPercent: input.percent,
|
||||
startPage: before?.page ?? input.page ?? null,
|
||||
endPage: input.page ?? null,
|
||||
pagesRead: 0,
|
||||
source: 'reader'
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
const startPage = session.startPage ?? input.page ?? null;
|
||||
await tx
|
||||
.update(bookSessions)
|
||||
.set({
|
||||
endedAt: now,
|
||||
endPercent: input.percent,
|
||||
endPage: input.page ?? null,
|
||||
startPage,
|
||||
pagesRead: pagesRead({
|
||||
startPage: input.page != null ? startPage : null,
|
||||
endPage: input.page,
|
||||
startPercent: session.startPercent,
|
||||
endPercent: input.percent,
|
||||
pageCount
|
||||
})
|
||||
})
|
||||
.where(eq(bookSessions.id, session.id));
|
||||
}
|
||||
return { sessionId: session.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function logManualSession(
|
||||
workId: number,
|
||||
input: {
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
startPage: number | null;
|
||||
endPage: number | null;
|
||||
startPercent: number | null;
|
||||
endPercent: number | null;
|
||||
note: string | null;
|
||||
}
|
||||
) {
|
||||
const [work] = await db
|
||||
.select({ pageCount: bookWorks.pageCount })
|
||||
.from(bookWorks)
|
||||
.where(eq(bookWorks.id, workId))
|
||||
.limit(1);
|
||||
await db.insert(bookSessions).values({
|
||||
workId,
|
||||
...input,
|
||||
pagesRead: pagesRead({ ...input, pageCount: work?.pageCount }),
|
||||
source: 'manual'
|
||||
});
|
||||
}
|
||||
84
src/lib/server/books/notes.ts
Normal file
84
src/lib/server/books/notes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Highlights and notes (books.notes). A row with `highlight` is a highlight from the reader
|
||||
// (optionally with a note); a row with only `note` is a free-form note on the book.
|
||||
import { z } from 'zod';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { bookFiles, bookNotes, bookWorks } from '#lib/server/db/schema/index.js';
|
||||
import { highlightColors, type Highlight } from '#lib/books/shared.js';
|
||||
|
||||
export const notePayload = z
|
||||
.object({
|
||||
fileId: z.number().int().positive().nullish(),
|
||||
locator: z.string().max(4000).nullish(),
|
||||
page: z.number().int().min(0).max(100000).nullish(),
|
||||
highlight: z.string().trim().max(20000).nullish(),
|
||||
note: z.string().trim().max(20000).nullish(),
|
||||
color: z.enum(highlightColors).nullish()
|
||||
})
|
||||
.refine((n) => !!(n.highlight || n.note), { message: 'Empty note' });
|
||||
export const noteUpdate = z.object({
|
||||
note: z.string().trim().max(20000).nullable().optional(),
|
||||
color: z.enum(highlightColors).nullable().optional()
|
||||
});
|
||||
|
||||
const toHighlight = (n: typeof bookNotes.$inferSelect): Highlight => ({
|
||||
id: n.id,
|
||||
fileId: n.fileId,
|
||||
locator: n.locator,
|
||||
page: n.page,
|
||||
highlight: n.highlight,
|
||||
note: n.note,
|
||||
color: n.color,
|
||||
createdAt: n.createdAt.toISOString()
|
||||
});
|
||||
|
||||
export async function createNote(workId: number, input: z.infer<typeof notePayload>) {
|
||||
const [work] = await db
|
||||
.select({ id: bookWorks.id })
|
||||
.from(bookWorks)
|
||||
.where(eq(bookWorks.id, workId))
|
||||
.limit(1);
|
||||
if (!work) return null;
|
||||
if (input.fileId) {
|
||||
const [file] = await db
|
||||
.select({ id: bookFiles.id })
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
|
||||
.limit(1);
|
||||
if (!file) return null;
|
||||
}
|
||||
const [row] = await db
|
||||
.insert(bookNotes)
|
||||
.values({
|
||||
workId,
|
||||
fileId: input.fileId ?? null,
|
||||
locator: input.locator ?? null,
|
||||
page: input.page ?? null,
|
||||
highlight: input.highlight || null,
|
||||
note: input.note || null,
|
||||
color: input.highlight ? (input.color ?? 'yellow') : null
|
||||
})
|
||||
.returning();
|
||||
return toHighlight(row);
|
||||
}
|
||||
|
||||
export async function updateNote(workId: number, id: number, input: z.infer<typeof noteUpdate>) {
|
||||
const set: Partial<typeof bookNotes.$inferInsert> = {};
|
||||
if (input.note !== undefined) set.note = input.note || null;
|
||||
if (input.color !== undefined) set.color = input.color;
|
||||
if (!Object.keys(set).length) return null;
|
||||
const [row] = await db
|
||||
.update(bookNotes)
|
||||
.set(set)
|
||||
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
|
||||
.returning();
|
||||
return row ? toHighlight(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteNote(workId: number, id: number) {
|
||||
const rows = await db
|
||||
.delete(bookNotes)
|
||||
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
|
||||
.returning({ id: bookNotes.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
jsonb,
|
||||
primaryKey,
|
||||
real,
|
||||
smallint,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
@@ -25,6 +26,14 @@ export const bookMetadataStatus = booksSchema.enum('metadata_status', [
|
||||
'manual' // edited by hand; enrichment never overwrites it
|
||||
]);
|
||||
|
||||
// Reading state per work (on books.progress), shown publicly on /trackers/books.
|
||||
export const bookReadingStatus = booksSchema.enum('reading_status', [
|
||||
'want_to_read',
|
||||
'reading',
|
||||
'finished',
|
||||
'abandoned'
|
||||
]);
|
||||
|
||||
export const bookWorks = booksSchema.table(
|
||||
'works',
|
||||
{
|
||||
@@ -128,11 +137,16 @@ export const bookProgress = booksSchema.table(
|
||||
percent: real('percent').notNull().default(0),
|
||||
startedAt: timestamptz('started_at'),
|
||||
finishedAt: timestamptz('finished_at'),
|
||||
status: bookReadingStatus('status').notNull().default('want_to_read'),
|
||||
rating: smallint('rating'), // 1-5 stars
|
||||
lastReadAt: timestamptz('last_read_at'), // last time the reader saved a position
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('progress_work_id_key').on(t.workId),
|
||||
check('progress_percent_check', sql`${t.percent} between 0 and 100`)
|
||||
check('progress_percent_check', sql`${t.percent} between 0 and 100`),
|
||||
check('progress_rating_check', sql`${t.rating} is null or ${t.rating} between 1 and 5`),
|
||||
index('progress_status_idx').on(t.status)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -154,9 +168,42 @@ export const bookNotes = booksSchema.table(
|
||||
(t) => [index('notes_work_id_idx').on(t.workId)]
|
||||
);
|
||||
|
||||
// One reading session: opened in the reader (source 'reader') or logged by hand ('manual').
|
||||
export const bookSessions = booksSchema.table(
|
||||
'sessions',
|
||||
{
|
||||
id: id(),
|
||||
workId: integer('work_id')
|
||||
.notNull()
|
||||
.references(() => bookWorks.id, { onDelete: 'cascade' }),
|
||||
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
|
||||
startedAt: timestamptz('started_at').notNull().defaultNow(),
|
||||
endedAt: timestamptz('ended_at'),
|
||||
startPercent: real('start_percent'),
|
||||
endPercent: real('end_percent'),
|
||||
startPage: integer('start_page'),
|
||||
endPage: integer('end_page'),
|
||||
pagesRead: integer('pages_read'), // derived on save: page delta, or percent delta x page_count
|
||||
source: text('source').notNull().default('reader'),
|
||||
note: text('note'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('sessions_work_id_idx').on(t.workId),
|
||||
index('sessions_started_at_idx').on(t.startedAt),
|
||||
check(
|
||||
'sessions_percent_check',
|
||||
sql`(${t.startPercent} is null or ${t.startPercent} between 0 and 100) and (${t.endPercent} is null or ${t.endPercent} between 0 and 100)`
|
||||
),
|
||||
check('sessions_end_check', sql`${t.endedAt} is null or ${t.endedAt} >= ${t.startedAt}`)
|
||||
]
|
||||
);
|
||||
|
||||
export type BookWork = typeof bookWorks.$inferSelect;
|
||||
export type NewBookWork = typeof bookWorks.$inferInsert;
|
||||
export type BookFile = typeof bookFiles.$inferSelect;
|
||||
export type BookShelf = typeof bookShelves.$inferSelect;
|
||||
export type BookProgress = typeof bookProgress.$inferSelect;
|
||||
export type BookNote = typeof bookNotes.$inferSelect;
|
||||
export type BookSession = typeof bookSessions.$inferSelect;
|
||||
export type BookReadingStatus = (typeof bookReadingStatus.enumValues)[number];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOwner, isOwnerOnlyPath } from './owner';
|
||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
|
||||
|
||||
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
|
||||
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
|
||||
@@ -17,4 +17,22 @@ describe('owner gate', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers')).toBe(false);
|
||||
});
|
||||
|
||||
it('gates the book reader, files and APIs but not the catalogue', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/books/files/12')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/read')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/progress')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/notes/3')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/covers/12')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/reader-notes-public')).toBe(false);
|
||||
});
|
||||
it('only lets the owner write in owner areas', () => {
|
||||
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/books')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
||||
expect(isOwnerWritePath('/lists/books')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,32 @@ export function requireOwner(event: OwnerEvent) {
|
||||
}
|
||||
|
||||
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
|
||||
export const ownerOnlyPrefixes = ['/trackers/health'] as const;
|
||||
export const ownerOnlyPrefixes = ['/trackers/health', '/trackers/books/files'] as const;
|
||||
|
||||
/**
|
||||
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
|
||||
* APIs. /trackers/books and /trackers/books/<id> themselves are public catalogue pages.
|
||||
*/
|
||||
export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes)(\/|$)/] as const;
|
||||
|
||||
/**
|
||||
* Areas where only the owner may write: any non-GET request from anyone else is refused
|
||||
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
|
||||
*/
|
||||
export const ownerWritePrefixes = ['/trackers/health', '/trackers/books'] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);
|
||||
|
||||
export function isOwnerOnlyPath(pathname: string): boolean {
|
||||
const path = pathname.toLowerCase().replace(/\/+$/, '');
|
||||
return ownerOnlyPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
const path = normalise(pathname);
|
||||
return (
|
||||
ownerOnlyPrefixes.some((prefix) => under(path, prefix)) ||
|
||||
ownerOnlyPatterns.some((pattern) => pattern.test(path))
|
||||
);
|
||||
}
|
||||
|
||||
export function isOwnerWritePath(pathname: string): boolean {
|
||||
const path = normalise(pathname);
|
||||
return ownerWritePrefixes.some((prefix) => under(path, prefix));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
||||
'/': 'Home',
|
||||
'/projects': 'Projects',
|
||||
'/trackers': 'Trackers',
|
||||
'/trackers/books': 'Books',
|
||||
'/blog': 'Blog',
|
||||
'/lists': 'Lists',
|
||||
'/login': 'Sign in',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { listSummaries } from '#lib/server/lists.js';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { blogArticles } from '#lib/server/db/schema/index.js';
|
||||
import { blogArticles, bookWorks } from '#lib/server/db/schema/index.js';
|
||||
import { SITE_ORIGIN } from '#lib/seo.js';
|
||||
import { races } from '#lib/derby/races.js';
|
||||
import { COURSES } from '#lib/language-learner/courses/index.js';
|
||||
@@ -13,6 +13,7 @@ const staticPaths = [
|
||||
'/trackers',
|
||||
'/blog',
|
||||
'/lists',
|
||||
'/trackers/books',
|
||||
'/projects/game-tracker',
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
@@ -40,12 +41,13 @@ function escapeXml(value: string): string {
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const [articles, lists] = await Promise.all([
|
||||
const [articles, lists, books] = await Promise.all([
|
||||
db
|
||||
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
|
||||
.from(blogArticles)
|
||||
.where(eq(blogArticles.status, 'published')),
|
||||
listSummaries()
|
||||
listSummaries(),
|
||||
db.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt }).from(bookWorks)
|
||||
]);
|
||||
const entries: { path: string; lastmod?: string }[] = [
|
||||
...staticPaths.map((path) => ({ path })),
|
||||
@@ -54,7 +56,11 @@ export const GET: RequestHandler = async () => {
|
||||
path: `/blog/${encodeURIComponent(article.slug)}`,
|
||||
lastmod: article.updatedAt.toISOString()
|
||||
})),
|
||||
...lists.map((list) => ({ path: `/lists/${encodeURIComponent(list.slug)}` }))
|
||||
...lists.map((list) => ({ path: `/lists/${encodeURIComponent(list.slug)}` })),
|
||||
...books.map((book) => ({
|
||||
path: `/trackers/books/${book.id}`,
|
||||
lastmod: book.updatedAt.toISOString()
|
||||
}))
|
||||
];
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries
|
||||
.map(
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
src/routes/trackers/books/+page.server.ts
Normal file
12
src/routes/trackers/books/+page.server.ts
Normal 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 };
|
||||
};
|
||||
670
src/routes/trackers/books/+page.svelte
Normal file
670
src/routes/trackers/books/+page.svelte
Normal 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>
|
||||
182
src/routes/trackers/books/[id=integer]/+page.server.ts
Normal file
182
src/routes/trackers/books/[id=integer]/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
683
src/routes/trackers/books/[id=integer]/+page.svelte
Normal file
683
src/routes/trackers/books/[id=integer]/+page.svelte
Normal 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>
|
||||
14
src/routes/trackers/books/[id=integer]/notes/+server.ts
Normal file
14
src/routes/trackers/books/[id=integer]/notes/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
21
src/routes/trackers/books/[id=integer]/progress/+server.ts
Normal file
21
src/routes/trackers/books/[id=integer]/progress/+server.ts
Normal 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' } });
|
||||
};
|
||||
52
src/routes/trackers/books/[id=integer]/read/+page.server.ts
Normal file
52
src/routes/trackers/books/[id=integer]/read/+page.server.ts
Normal 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 }
|
||||
};
|
||||
};
|
||||
999
src/routes/trackers/books/[id=integer]/read/+page.svelte
Normal file
999
src/routes/trackers/books/[id=integer]/read/+page.svelte
Normal 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>
|
||||
157
src/routes/trackers/books/auth.test.ts
Normal file
157
src/routes/trackers/books/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
src/routes/trackers/books/covers/[id=integer]/+server.ts
Normal file
23
src/routes/trackers/books/covers/[id=integer]/+server.ts
Normal 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;
|
||||
};
|
||||
30
src/routes/trackers/books/files/[id=integer]/+server.ts
Normal file
30
src/routes/trackers/books/files/[id=integer]/+server.ts
Normal 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;
|
||||
Reference in New Issue
Block a user