reading: split the book tracker into books, papers and articles (kind column + migration classifying 20 papers), segmented tabs with counts, article cards that open the link, owner + add link with server-side unfurl, kind switch on detail pages, /trackers/reading alias
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
This commit is contained in:
@@ -4,7 +4,7 @@ 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';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, ne } from 'drizzle-orm';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const staticPaths = [
|
||||
@@ -60,7 +60,11 @@ export const GET: RequestHandler = async () => {
|
||||
.from(blogArticles)
|
||||
.where(eq(blogArticles.status, 'published')),
|
||||
listSummaries(),
|
||||
db.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt }).from(bookWorks)
|
||||
db
|
||||
.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt })
|
||||
.from(bookWorks)
|
||||
// Articles are links to other sites; their pages here are thin.
|
||||
.where(ne(bookWorks.kind, 'article'))
|
||||
]);
|
||||
const entries: { path: string; lastmod?: string }[] = [
|
||||
...staticPaths.map((path) => ({ path })),
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { isOwner, requireOwner } from '#lib/server/owner.js';
|
||||
import { libraryCards, libraryStats } from '#lib/server/books/library.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { addLink } from '#lib/server/books/links.js';
|
||||
import { cleanUrl } from '#lib/server/books/unfurl.js';
|
||||
import { isReadingStatus } from '#lib/books/shared.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
// Public catalogue: covers, titles, status, progress and rating. Files, formats and
|
||||
// placeholders (no file yet) are only included for the owner.
|
||||
// Public catalogue of books, papers and articles: covers, titles, links, 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 };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
// Owner: "+ add link". Fetches the page server-side for its title, description and image.
|
||||
addLink: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const url = cleanUrl(String(form.get('url') ?? ''));
|
||||
if (!url) return fail(400, { linkError: 'Paste an http(s) link' });
|
||||
const status = form.get('status');
|
||||
try {
|
||||
const { id, created } = await addLink({
|
||||
url,
|
||||
title: String(form.get('title') ?? '') || null,
|
||||
status: isReadingStatus(status) ? status : 'want_to_read'
|
||||
});
|
||||
return { added: id, created };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error && err.message === 'private host'
|
||||
? 'That host is not reachable from here'
|
||||
: 'Could not add that link';
|
||||
console.error(`[books] addLink failed: ${err instanceof Error ? err.message : 'unknown'}`);
|
||||
return fail(400, { linkError: message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import BookCover from '#lib/books/BookCover.svelte';
|
||||
import Stars from '#lib/books/Stars.svelte';
|
||||
import {
|
||||
authorLine,
|
||||
bookKinds,
|
||||
coverHue,
|
||||
kindFromParam,
|
||||
kindTabs,
|
||||
readingStatuses,
|
||||
statusLabels,
|
||||
type BookCard,
|
||||
type BookKind,
|
||||
type ReadingStatus
|
||||
} from '#lib/books/shared.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
@@ -18,9 +26,54 @@
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import FileQuestion from 'lucide-svelte/icons/file-question';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import FlaskConical from 'lucide-svelte/icons/flask-conical';
|
||||
import Newspaper from 'lucide-svelte/icons/newspaper';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
|
||||
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
|
||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
// Books · Papers · Articles. The tab lives in ?kind= so it can be linked and survives reloads.
|
||||
const initialKind = kindFromParam(page.url.searchParams.get('kind'));
|
||||
let kind = $state<BookKind>(initialKind);
|
||||
const kindIcons = { book: BookOpen, paper: FlaskConical, article: Newspaper };
|
||||
const kindCounts = $derived.by(() => {
|
||||
const c: Record<BookKind, number> = { book: 0, paper: 0, article: 0 };
|
||||
for (const b of data.books) c[b.kind]++;
|
||||
return c;
|
||||
});
|
||||
const pool = $derived(data.books.filter((b) => b.kind === kind));
|
||||
function setKind(next: BookKind) {
|
||||
if (next === kind) return;
|
||||
kind = next;
|
||||
status = 'all';
|
||||
subject = '';
|
||||
format = '';
|
||||
sort = next === 'article' ? 'added' : 'recent';
|
||||
const url = new URL(page.url);
|
||||
if (next === 'book') url.searchParams.delete('kind');
|
||||
else url.searchParams.set('kind', kindTabs[next].param);
|
||||
replaceState(url, {});
|
||||
}
|
||||
const articleStatus: Partial<Record<ReadingStatus, string>> = {
|
||||
want_to_read: 'to read',
|
||||
finished: 'read'
|
||||
};
|
||||
const statusText = (s: ReadingStatus) =>
|
||||
(kind === 'article' && articleStatus[s]) || statusLabels[s];
|
||||
|
||||
let adding = $state(false);
|
||||
const addLink: SubmitFunction = () => {
|
||||
adding = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
adding = false;
|
||||
};
|
||||
};
|
||||
|
||||
type Sort = 'recent' | 'title' | 'author' | 'progress' | 'rating' | 'added';
|
||||
const sorts: Record<Sort, string> = {
|
||||
@@ -36,7 +89,7 @@
|
||||
let status = $state<ReadingStatus | 'all' | 'none'>('all');
|
||||
let subject = $state('');
|
||||
let format = $state('');
|
||||
let sort = $state<Sort>('recent');
|
||||
let sort = $state<Sort>(initialKind === 'article' ? 'added' : 'recent');
|
||||
let view = $state<'grid' | 'list'>('grid');
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('en-GB');
|
||||
@@ -48,25 +101,25 @@
|
||||
});
|
||||
|
||||
const reading = $derived(
|
||||
data.books
|
||||
pool
|
||||
.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 };
|
||||
const c: Record<string, number> = { all: pool.length, none: 0 };
|
||||
for (const s of readingStatuses) c[s] = 0;
|
||||
for (const b of data.books) c[b.status ?? 'none']++;
|
||||
for (const b of pool) 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;
|
||||
for (const b of pool) 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 formats = $derived([...new Set(pool.flatMap((b) => b.formats))].sort());
|
||||
const placeholders = $derived(data.owner ? pool.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> = {
|
||||
@@ -82,14 +135,14 @@
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return data.books
|
||||
return pool
|
||||
.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]
|
||||
const haystack = [b.title, b.subtitle, ...b.authors, ...b.tags, b.year, b.site, b.excerpt]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return words.every((w) => haystack.includes(w));
|
||||
@@ -149,13 +202,82 @@
|
||||
|
||||
<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>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">reading</h1>
|
||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||
What I'm reading, what I've read and what's next.
|
||||
Books, scientific papers and articles: what I'm reading, what I've read and what's next.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<dl class="stats mb-10">
|
||||
<div class="kinds" role="tablist" aria-label="Kind">
|
||||
{#each bookKinds as k (k)}
|
||||
{@const Icon = kindIcons[k]}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={kind === k}
|
||||
class={['kind', `k-${k}`, kind === k && 'active']}
|
||||
onclick={() => setKind(k)}
|
||||
><span class="kind-icon"><Icon size={15} aria-hidden="true" /></span>
|
||||
<span class="kind-label">{kindTabs[k].label}</span>
|
||||
<span class="kind-count">{numberFormat.format(kindCounts[k])}</span></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if kind !== 'book'}
|
||||
<dl class="stats mb-10">
|
||||
<div>
|
||||
<dt>{kind === 'article' ? 'to read' : 'want to read'}</dt>
|
||||
<dd>{counts.want_to_read}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>reading</dt>
|
||||
<dd>{counts.reading}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{kind === 'article' ? 'read' : 'finished'}</dt>
|
||||
<dd>{counts.finished}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{kind === 'article' ? 'on the list' : 'papers'}</dt>
|
||||
<dd>{numberFormat.format(pool.length)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{#if data.owner && kind !== 'book'}
|
||||
<form method="POST" action="?/addLink" use:enhance={addLink} class="add-link">
|
||||
<span class="add-icon"><Plus size={16} aria-hidden="true" /></span>
|
||||
<input
|
||||
name="url"
|
||||
type="url"
|
||||
required
|
||||
placeholder={kind === 'paper'
|
||||
? 'Paste an arXiv / DOI link to add a paper'
|
||||
: 'Paste a link to add it to the reading list'}
|
||||
aria-label="Link to add"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit" disabled={adding}
|
||||
>{#if adding}<LoaderCircle size={14} class="animate-spin" aria-hidden="true" /> fetching…{:else}add
|
||||
link{/if}</button
|
||||
>
|
||||
</form>
|
||||
{#if form && 'linkError' in form && form.linkError}
|
||||
<p class="-mt-6 mb-8 text-sm text-red-500" role="alert">{form.linkError}</p>
|
||||
{:else if form && 'added' in form && form.added}
|
||||
<p class="-mt-6 mb-8 text-sm text-[var(--site-muted)]" role="status">
|
||||
{form.created ? 'Added' : 'Already on the list'}:
|
||||
<a
|
||||
class="underline underline-offset-2"
|
||||
href={resolve('/trackers/books/[id=integer]', { id: String(form.added) })}
|
||||
>{data.books.find((b) => b.id === form.added)?.title ?? 'open'}</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<dl class={['stats mb-10', kind !== 'book' && 'hidden']}>
|
||||
<div>
|
||||
<dt>finished in {data.stats.year}</dt>
|
||||
<dd>{numberFormat.format(data.stats.finishedThisYear)}</dd>
|
||||
@@ -214,8 +336,10 @@
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search title, author or subject"
|
||||
aria-label="Search books"
|
||||
placeholder={kind === 'article'
|
||||
? 'Search title, site or author'
|
||||
: 'Search title, author or subject'}
|
||||
aria-label="Search {kindTabs[kind].label}"
|
||||
bind:value={query}
|
||||
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
@@ -239,7 +363,7 @@
|
||||
{#each Object.entries(sorts) as [key, label] (key)}<option value={key}>{label}</option
|
||||
>{/each}
|
||||
</NativeSelect>
|
||||
<div class="toggle" role="group" aria-label="View">
|
||||
<div class={['toggle', kind === 'article' && 'hidden']} role="group" aria-label="View">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={view === 'grid'}
|
||||
@@ -264,7 +388,7 @@
|
||||
aria-selected={status === s}
|
||||
onclick={() => (status = s)}
|
||||
class={[status === s && 'active']}
|
||||
>{s === 'all' ? 'all' : statusLabels[s]} <span>{counts[s]}</span></button
|
||||
>{s === 'all' ? 'all' : statusText(s)} <span>{counts[s]}</span></button
|
||||
>
|
||||
{/each}
|
||||
{#if counts.none}
|
||||
@@ -280,18 +404,79 @@
|
||||
|
||||
{#if filtering}
|
||||
<p class="mb-4 flex items-center gap-2 text-sm text-[var(--site-muted)]">
|
||||
{filtered.length} of {data.books.length} books
|
||||
{filtered.length} of {pool.length}
|
||||
{kindTabs[kind].label}
|
||||
<button type="button" class="clear" onclick={clearFilters}><X size={12} /> clear</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !data.books.length}
|
||||
{#if !pool.length}
|
||||
<div class="empty">
|
||||
<BookOpen size={28} aria-hidden="true" />
|
||||
<p>The library is still being catalogued. Check back soon.</p>
|
||||
{#if kind === 'article'}
|
||||
<Newspaper size={28} aria-hidden="true" />
|
||||
<p>No articles on the reading list yet.</p>
|
||||
{:else if kind === 'paper'}
|
||||
<FlaskConical size={28} aria-hidden="true" />
|
||||
<p>No papers yet.</p>
|
||||
{:else}
|
||||
<BookOpen size={28} aria-hidden="true" />
|
||||
<p>The library is still being catalogued. Check back soon.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !filtered.length}
|
||||
<div class="empty"><p>No books match these filters.</p></div>
|
||||
<div class="empty"><p>Nothing matches these filters.</p></div>
|
||||
{:else if kind === 'article'}
|
||||
<ul class="articles">
|
||||
{#each filtered as b (b.id)}
|
||||
<li
|
||||
class={['article', b.status === 'finished' && 'is-read']}
|
||||
style:--hue={coverHue(b.site ?? b.title)}
|
||||
>
|
||||
<div class="thumb" aria-hidden="true">
|
||||
{#if b.cover}
|
||||
<img
|
||||
src={b.cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
|
||||
/>
|
||||
{/if}
|
||||
<span class="thumb-letter"
|
||||
>{(b.site ?? b.title)
|
||||
.replace(/^www\./, '')
|
||||
.charAt(0)
|
||||
.toUpperCase()}</span
|
||||
>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="article-meta">
|
||||
<span class="truncate">{b.site}</span>
|
||||
{#if b.publishedAt}<span aria-hidden="true">·</span><time datetime={b.publishedAt}
|
||||
>{dateFormat.format(new Date(b.publishedAt))}</time
|
||||
>{/if}
|
||||
</p>
|
||||
<a
|
||||
href={b.url ?? bookHref(b)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="article-link stretched line-clamp-2"
|
||||
>{b.title}<ArrowUpRight size={14} aria-hidden="true" class="article-arrow" /></a
|
||||
>
|
||||
{#if b.excerpt}<p class="article-excerpt line-clamp-2">{b.excerpt}</p>{/if}
|
||||
<div class="article-foot">
|
||||
{#if b.status}<span class={['chip', `s-${b.status}`]}>{statusText(b.status)}</span
|
||||
>{/if}
|
||||
{#if b.authors.length}<span class="truncate">{authorLine(b.authors)}</span>{/if}
|
||||
<a href={bookHref(b)} class="details" title="Status and notes"
|
||||
><NotebookPen size={13} aria-hidden="true" /> {data.owner ? 'notes' : 'details'}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if view === 'grid'}
|
||||
<ul class="grid">
|
||||
{#each filtered as b (b.id)}
|
||||
@@ -370,6 +555,299 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kinds {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.kind {
|
||||
--accent: #0d9488;
|
||||
--accent-from: #2dd4bf;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.85rem 0.45rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--site-muted);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.k-paper {
|
||||
--accent: #7c3aed;
|
||||
--accent-from: #a78bfa;
|
||||
}
|
||||
.k-article {
|
||||
--accent: #ea580c;
|
||||
--accent-from: #fdba74;
|
||||
}
|
||||
.kind:hover {
|
||||
color: var(--site-text);
|
||||
background: var(--site-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.kind.active {
|
||||
color: var(--site-text);
|
||||
background: var(--site-bg);
|
||||
border-color: color-mix(in oklab, var(--foreground) 16%, transparent);
|
||||
box-shadow: 0 6px 16px -10px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.kind:focus-visible {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.kind-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
border-radius: 0.45rem;
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, var(--accent-from), var(--accent));
|
||||
opacity: 0.55;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
.kind:hover .kind-icon,
|
||||
.kind.active .kind-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.kind:hover .kind-icon {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.kind-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.kind-count {
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--accent) 16%, transparent);
|
||||
color: color-mix(in oklab, var(--accent) 70%, var(--site-text));
|
||||
}
|
||||
|
||||
.add-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 0.35rem 0.35rem 0.35rem 0.6rem;
|
||||
border: 1px dashed color-mix(in oklab, var(--foreground) 22%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
transition: border-color 160ms ease;
|
||||
}
|
||||
.add-link:focus-within {
|
||||
border-style: solid;
|
||||
border-color: color-mix(in oklab, var(--foreground) 40%, transparent);
|
||||
}
|
||||
.add-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.45rem;
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, #fdba74, #ea580c);
|
||||
}
|
||||
.add-link input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
.add-link button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
.add-link button:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.add-link button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.articles {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.articles {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.article {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||
border-radius: 0.85rem;
|
||||
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||
transition:
|
||||
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
.article:hover {
|
||||
background: color-mix(in oklab, var(--foreground) 5%, transparent);
|
||||
border-color: color-mix(in oklab, var(--foreground) 24%, transparent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px -16px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
.article.is-read {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 7.5rem;
|
||||
aspect-ratio: 1.6;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0.55rem;
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--hue) 75% 62%),
|
||||
hsl(calc(var(--hue) + 40) 70% 42%)
|
||||
);
|
||||
}
|
||||
.thumb img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
.article:hover .thumb img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.thumb-letter {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: rgb(255 255 255 / 0.92);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.thumb {
|
||||
width: 4.5rem;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
.article-meta {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.article-meta time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.article-link {
|
||||
margin-top: 0.15rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.article-link:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.article:has(.article-link:focus-visible) {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.stretched::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.article :global(.article-arrow) {
|
||||
display: inline;
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: -0.1em;
|
||||
color: var(--site-muted);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
.article:hover :global(.article-arrow) {
|
||||
transform: translate(2px, -2px);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.article-excerpt {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.article-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.details {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
.details:hover {
|
||||
background: var(--site-hover);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.chip.s-want_to_read {
|
||||
color: #ea580c;
|
||||
border-color: color-mix(in srgb, #ea580c 40%, transparent);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kind,
|
||||
.article,
|
||||
.thumb img {
|
||||
transition: none;
|
||||
}
|
||||
.kind:hover,
|
||||
.article:hover,
|
||||
.article:hover .thumb img {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
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 { isBookKind, isReadingStatus } from '#lib/books/shared.js';
|
||||
import { setKind } from '#lib/server/books/links.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
@@ -26,6 +27,10 @@ export const load: PageServerLoad = async (event) => {
|
||||
const { work, progress } = found;
|
||||
const book = {
|
||||
id: work.id,
|
||||
kind: work.kind,
|
||||
url: work.url,
|
||||
site: work.siteName ?? (work.url ? new URL(work.url).hostname.replace(/^www\./, '') : null),
|
||||
publishedAt: work.publishedAt?.toISOString() ?? null,
|
||||
title: work.title,
|
||||
subtitle: work.subtitle,
|
||||
authors: work.authors,
|
||||
@@ -53,7 +58,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
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, ' ')})` : ''}.`
|
||||
`${work.title} on Stan Runge's ${work.kind === 'article' ? 'reading list' : 'bookshelf'}${reading.status ? ` (${reading.status.replace(/_/g, ' ')})` : ''}.`
|
||||
};
|
||||
if (!owner) return { book, reading, owner: false as const, seo };
|
||||
|
||||
@@ -92,6 +97,13 @@ const date = (value: FormDataEntryValue | null) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
kind: async (event) => {
|
||||
requireOwner(event);
|
||||
const kind = (await event.request.formData()).get('kind');
|
||||
if (!isBookKind(kind)) return fail(400, { message: 'Unknown kind' });
|
||||
await setKind(Number(event.params.id), kind);
|
||||
return { ok: true };
|
||||
},
|
||||
status: async (event) => {
|
||||
requireOwner(event);
|
||||
const status = (await event.request.formData()).get('status');
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
import BookCover from '#lib/books/BookCover.svelte';
|
||||
import Stars from '#lib/books/Stars.svelte';
|
||||
import {
|
||||
bookKinds,
|
||||
coverHue,
|
||||
highlightCss,
|
||||
kindTabs,
|
||||
readingStatuses,
|
||||
statusLabels,
|
||||
type HighlightColor
|
||||
@@ -21,6 +24,7 @@
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import FileQuestion from 'lucide-svelte/icons/file-question';
|
||||
import Lock from 'lucide-svelte/icons/lock';
|
||||
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -65,9 +69,29 @@
|
||||
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 isArticle = $derived(book.kind === 'article');
|
||||
const backHref = $derived(
|
||||
book.kind === 'book'
|
||||
? resolve('/trackers/books')
|
||||
: `${resolve('/trackers/books')}?kind=${kindTabs[book.kind].param}`
|
||||
);
|
||||
const label = (s: (typeof readingStatuses)[number]) =>
|
||||
isArticle && s === 'want_to_read'
|
||||
? 'to read'
|
||||
: isArticle && s === 'finished'
|
||||
? 'read'
|
||||
: statusLabels[s];
|
||||
const meta = $derived(
|
||||
[
|
||||
['published', book.year],
|
||||
['site', book.site],
|
||||
[
|
||||
'published',
|
||||
book.publishedAt
|
||||
? dateFormat.format(new Date(book.publishedAt))
|
||||
: isArticle
|
||||
? null
|
||||
: book.year
|
||||
],
|
||||
['publisher', book.publisher],
|
||||
['pages', book.pageCount],
|
||||
['language', book.language?.toUpperCase()],
|
||||
@@ -93,22 +117,40 @@
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/trackers/books')}
|
||||
href={backHref}
|
||||
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
|
||||
><ArrowLeft size={14} /> all {kindTabs[book.kind].label}</a
|
||||
>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="side">
|
||||
<div class="cover-box">
|
||||
<BookCover
|
||||
src={book.cover}
|
||||
title={book.title}
|
||||
authors={book.authors}
|
||||
{#if isArticle}
|
||||
<div class="article-image" style:--hue={coverHue(book.site ?? book.title)}>
|
||||
{#if book.cover}<img src={book.cover} alt="" referrerpolicy="no-referrer" />{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cover-box">
|
||||
<BookCover
|
||||
src={book.cover}
|
||||
title={book.title}
|
||||
authors={book.authors}
|
||||
size="lg"
|
||||
placeholder={data.owner && !data.files.length && !book.url}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if book.url}
|
||||
<Button
|
||||
href={book.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-5 w-full gap-2"
|
||||
size="lg"
|
||||
placeholder={data.owner && !data.files.length}
|
||||
/>
|
||||
</div>
|
||||
variant={readable ? 'outline' : 'default'}
|
||||
>open {isArticle ? 'article' : 'link'} <ArrowUpRight size={16} /></Button
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if data.owner}
|
||||
{#if readable}
|
||||
@@ -123,7 +165,7 @@
|
||||
{data.files.map((f) => f.format.toUpperCase()).join(', ')} can't be opened in the browser
|
||||
reader yet.
|
||||
</p>
|
||||
{:else}
|
||||
{:else if !book.url}
|
||||
<p class="placeholder-note">
|
||||
<FileQuestion size={16} /> No file yet. This is a placeholder until a copy lands in the library.
|
||||
</p>
|
||||
@@ -135,7 +177,17 @@
|
||||
name="status"
|
||||
value={s}
|
||||
class={['seg', reading.status === s && 'active']}
|
||||
aria-pressed={reading.status === s}>{statusLabels[s]}</button
|
||||
aria-pressed={reading.status === s}>{label(s)}</button
|
||||
>
|
||||
{/each}
|
||||
</form>
|
||||
<form method="POST" action="?/kind" use:enhance={keep} class="kind-group" aria-label="Kind">
|
||||
{#each bookKinds as k (k)}
|
||||
<button
|
||||
name="kind"
|
||||
value={k}
|
||||
class={['seg', book.kind === k && 'active']}
|
||||
aria-pressed={book.kind === k}>{k}</button
|
||||
>
|
||||
{/each}
|
||||
</form>
|
||||
@@ -145,7 +197,7 @@
|
||||
{: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
|
||||
>{label(reading.status)}</span
|
||||
>{/if}
|
||||
<Stars value={reading.rating} size={18} />
|
||||
</div>
|
||||
@@ -188,63 +240,65 @@
|
||||
</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 !isArticle}
|
||||
<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"
|
||||
>
|
||||
{#if book.pageCount}
|
||||
<label class="field"
|
||||
><span>or page</span><Input
|
||||
name="page"
|
||||
><span>percent</span><Input
|
||||
name="percent"
|
||||
type="number"
|
||||
min="0"
|
||||
max={book.pageCount}
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="w-24"
|
||||
value={reading.page ?? ''}
|
||||
value={reading.percent ? Math.round(reading.percent * 10) / 10 : ''}
|
||||
/></label
|
||||
>
|
||||
<input type="hidden" name="pageCount" value={book.pageCount} />
|
||||
{/if}
|
||||
<Button type="submit" variant="outline" size="sm">update</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
{#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}
|
||||
|
||||
{#if data.owner}
|
||||
{#if form?.message}<p class="mt-4 text-sm text-red-500" role="alert">{form.message}</p>{/if}
|
||||
@@ -307,155 +361,167 @@
|
||||
{/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
|
||||
{#if !isArticle}
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
{: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>
|
||||
{/if}
|
||||
|
||||
<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
|
||||
{#if !isArticle}
|
||||
<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
|
||||
>
|
||||
<label class="field"
|
||||
><span>end</span><Input
|
||||
name="endedAt"
|
||||
type="datetime-local"
|
||||
value={localInput(new Date())}
|
||||
/></label
|
||||
</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>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}
|
||||
<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>
|
||||
</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 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}
|
||||
|
||||
{#if data.files.length}
|
||||
<section class="mt-10">
|
||||
@@ -513,6 +579,29 @@
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.article-image {
|
||||
position: relative;
|
||||
aspect-ratio: 1.6;
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--hue) 75% 62%),
|
||||
hsl(calc(var(--hue) + 40) 70% 42%)
|
||||
);
|
||||
box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.article-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.kind-group {
|
||||
margin-top: 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.status-group {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
|
||||
7
src/routes/trackers/reading/+server.ts
Normal file
7
src/routes/trackers/reading/+server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// /trackers/reading is an alias of the reading tracker, which keeps its original URL.
|
||||
export const GET: RequestHandler = ({ url }) => {
|
||||
redirect(308, `/trackers/books${url.search}`);
|
||||
};
|
||||
Reference in New Issue
Block a user