Files
website/src/routes/trackers/books/[id=integer]/+page.svelte

684 lines
19 KiB
Svelte

<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>