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

This commit is contained in:
2026-10-25 13:42:51 +00:00
parent 7b5faf6e74
commit e31e1be73f
20 changed files with 27592 additions and 247 deletions

View File

@@ -0,0 +1,14 @@
CREATE TYPE "books"."kind" AS ENUM('book', 'paper', 'article');--> statement-breakpoint
ALTER TABLE "books"."works" ADD COLUMN "kind" "books"."kind" DEFAULT 'book'::"books"."kind" NOT NULL;--> statement-breakpoint
ALTER TABLE "books"."works" ADD COLUMN "url" text;--> statement-breakpoint
ALTER TABLE "books"."works" ADD COLUMN "site_name" text;--> statement-breakpoint
ALTER TABLE "books"."works" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint
CREATE UNIQUE INDEX "works_url_key" ON "books"."works" ("url") WHERE "url" is not null;--> statement-breakpoint
CREATE INDEX "works_kind_idx" ON "books"."works" ("kind");--> statement-breakpoint
-- Classify existing papers: anything with an arXiv id, or a short ISBN-less PDF from the Drive
-- "Papers/" folder (Ilya Sutskever's list). Longer works in that folder (Kolmogorov
-- Complexity, Machine Super Intelligence) stay books.
UPDATE "books"."works" SET "kind" = 'paper'
WHERE "source_refs" ? 'arxiv'
OR ("isbn13" IS NULL AND coalesce("page_count", 0) BETWEEN 1 AND 99
AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof("source_refs"->'drive') = 'array' THEN "source_refs"->'drive' ELSE '[]'::jsonb END) d WHERE d LIKE 'Papers/%'));

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,18 @@
// Shared (client + server) types and helpers for the book tracker.
export const bookKinds = ['book', 'paper', 'article'] as const;
export type BookKind = (typeof bookKinds)[number];
export const isBookKind = (v: unknown): v is BookKind =>
typeof v === 'string' && (bookKinds as readonly string[]).includes(v);
/** Tab labels and the `?kind=` value for each (plural, as in the URL). */
export const kindTabs: Record<BookKind, { label: string; param: string }> = {
book: { label: 'books', param: 'books' },
paper: { label: 'papers', param: 'papers' },
article: { label: 'articles', param: 'articles' }
};
export function kindFromParam(param: string | null): BookKind {
const hit = bookKinds.find((k) => kindTabs[k].param === param || k === param);
return hit ?? 'book';
}
export const readingStatuses = ['want_to_read', 'reading', 'finished', 'abandoned'] as const;
export type ReadingStatus = (typeof readingStatuses)[number];
@@ -24,6 +38,11 @@ export const formatPreference = ['epub', 'azw3', 'mobi', 'azw', 'fb2', 'pdf', 'c
/** One card in the library. Public fields only, unless noted. */
export type BookCard = {
id: number;
kind: BookKind;
url: string | null; // articles/papers on the web
site: string | null;
publishedAt: string | null;
excerpt: string | null; // articles only (short description)
title: string;
subtitle: string | null;
authors: string[];

View File

@@ -2,18 +2,18 @@ import { describe, expect, it } from 'vitest';
import { visibleTrackers } from './projects';
describe('trackers', () => {
it('lists tasks, money, books first for the owner', () => {
it('lists tasks, money, reading first for the owner', () => {
expect(
visibleTrackers(true)
.map((t) => t.name)
.slice(0, 4)
).toEqual(['tasks', 'money', 'books', 'spotting']);
).toEqual(['tasks', 'money', 'reading', 'spotting']);
});
it('hides private trackers when logged out', () => {
const names = visibleTrackers(false).map((t) => t.name);
expect(names).not.toContain('tasks');
expect(names).not.toContain('health');
expect(names.slice(0, 3)).toEqual(['money', 'books', 'spotting']);
expect(names.slice(0, 3)).toEqual(['money', 'reading', 'spotting']);
});
it('uses short single-word names', () => {
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);

View File

@@ -92,8 +92,8 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
private: false
},
{
name: 'books',
subtitle: 'my library: what I am reading, have read and want to read',
name: 'reading',
subtitle: 'books, scientific papers and articles I am reading, have read and want to read',
href: '/trackers/books',
external: false,
private: false

View File

@@ -42,7 +42,8 @@ const publicPages: Record<string, { title: string; description: string }> = {
},
'/trackers/books': {
title: 'Books',
description: 'Stan Runge’s library: books being read, finished and on the to-read pile.'
description:
'Stan Runge’s reading: books, scientific papers and articles being read, finished and on the to-read pile.'
},
'/trackers/spotting': {
title: 'Spotting',

View File

@@ -51,6 +51,13 @@ export async function libraryCards(owner: boolean): Promise<BookCard[]> {
const rows = await db
.select({
id: bookWorks.id,
kind: bookWorks.kind,
url: bookWorks.url,
siteName: bookWorks.siteName,
publishedAt: bookWorks.publishedAt,
excerpt: sql<
string | null
>`case when ${bookWorks.kind} = 'article' then left(${bookWorks.description}, 320) end`,
title: bookWorks.title,
subtitle: bookWorks.subtitle,
authors: bookWorks.authors,
@@ -74,6 +81,11 @@ export async function libraryCards(owner: boolean): Promise<BookCard[]> {
.orderBy(asc(bookWorks.title));
return rows.map((r) => ({
id: r.id,
kind: r.kind,
url: r.url,
site: r.siteName ?? (r.url ? new URL(r.url).hostname.replace(/^www\./, '') : null),
publishedAt: iso(r.publishedAt),
excerpt: r.excerpt,
title: r.title,
subtitle: r.subtitle,
authors: r.authors,
@@ -98,7 +110,7 @@ export async function libraryStats(): Promise<LibraryStats> {
const [[counts], [pages]] = await Promise.all([
db
.select({
total: sql<number>`(select count(*)::int from ${bookWorks})`,
total: sql<number>`(select count(*)::int from ${bookWorks} where ${bookWorks.kind} <> 'article')`,
finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::int`
})

View File

@@ -0,0 +1,83 @@
// Reading-list links (articles, and papers added by URL). A link is a books.works row with
// kind 'article' or 'paper', its URL in `url`, the og:image in `cover_url` and the page
// description in `description`; status and notes work as for books.
import { eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookProgress, bookWorks } from '#lib/server/db/schema/index.js';
import type { ReadingStatus } from '#lib/books/shared.js';
import { cleanUrl, fetchLinkMeta, kindForUrl, siteFromUrl, type LinkMeta } from './unfurl.js';
export type NewLink = {
url: string;
title?: string | null;
status?: ReadingStatus;
addedAt?: Date;
tags?: string[];
source?: string;
};
/** Builds the row for a link from its fetched preview. Pure (exported for tests). */
export function linkRow(meta: Omit<LinkMeta, 'url'> & { url: string }, input: NewLink) {
const kind = kindForUrl(meta.url);
const title = input.title?.trim() || meta.title || siteFromUrl(meta.url);
return {
kind,
title: title.slice(0, 300),
url: meta.url,
siteName: meta.siteName ?? siteFromUrl(meta.url),
authors: meta.author ? [meta.author] : [],
description: meta.description,
coverUrl: meta.image?.startsWith('https://') ? meta.image : null,
publishedAt: meta.publishedAt ? new Date(meta.publishedAt) : null,
year: meta.publishedAt ? new Date(meta.publishedAt).getUTCFullYear() : null,
tags: input.tags ?? [],
metadataStatus: (meta.title ? 'matched' : 'not_found') as 'matched' | 'not_found',
metadataSource: 'web',
metadataFetchedAt: new Date(),
sourceRefs: { origins: [input.source ?? 'link'] }
};
}
/**
* Adds a link to the reading list (fetching its title, description and image), or returns the
* existing work when the URL is already there. `created` tells the two apart.
*/
export async function addLink(input: NewLink): Promise<{ id: number; created: boolean }> {
const url = cleanUrl(input.url);
if (!url) throw new Error('Not an http(s) URL');
const existing = await findByUrl(url);
if (existing) return { id: existing, created: false };
const meta = await fetchLinkMeta(url);
const again = meta.url !== url ? await findByUrl(meta.url) : null;
if (again) return { id: again, created: false };
const row = linkRow(meta, input);
return db.transaction(async (tx) => {
const [work] = await tx
.insert(bookWorks)
.values({ ...row, ...(input.addedAt ? { createdAt: input.addedAt } : {}) })
.onConflictDoNothing()
.returning({ id: bookWorks.id });
if (!work) return { id: (await findByUrl(row.url))!, created: false };
await tx.insert(bookProgress).values({
workId: work.id,
status: input.status ?? 'want_to_read',
startedAt: input.status === 'reading' || input.status === 'finished' ? new Date() : null,
finishedAt: input.status === 'finished' ? new Date() : null,
percent: input.status === 'finished' ? 100 : 0
});
return { id: work.id, created: true };
});
}
async function findByUrl(url: string) {
const [row] = await db
.select({ id: bookWorks.id })
.from(bookWorks)
.where(eq(bookWorks.url, url))
.limit(1);
return row?.id ?? null;
}
export async function setKind(workId: number, kind: 'book' | 'paper' | 'article') {
await db.update(bookWorks).set({ kind }).where(eq(bookWorks.id, workId));
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { cleanUrl, decodeEntities, isPrivateAddress, kindForUrl, parseLinkMeta } from './unfurl';
describe('cleanUrl', () => {
it('drops tracking params and rejects non-http', () => {
expect(cleanUrl('https://github.com/x/y?utm_source=tldrnewsletter')).toBe(
'https://github.com/x/y'
);
expect(cleanUrl('javascript:alert(1)')).toBeNull();
expect(cleanUrl('https://user:pw@example.com/')).toBeNull();
expect(cleanUrl('not a url')).toBeNull();
});
});
describe('kindForUrl', () => {
it('treats scholarly links as papers', () => {
expect(kindForUrl('https://arxiv.org/abs/1706.03762')).toBe('paper');
expect(kindForUrl('https://doi.org/10.1145/1')).toBe('paper');
expect(kindForUrl('https://mll.sh/humiliating-iis-servers-for-fun-and-jail-time/')).toBe(
'article'
);
});
});
describe('isPrivateAddress', () => {
it('blocks loopback, private and link-local ranges', () => {
for (const ip of [
'127.0.0.1',
'10.1.2.3',
'172.20.0.1',
'192.168.1.1',
'169.254.169.254',
'::1',
'fd00::1',
'::ffff:127.0.0.1'
])
expect(isPrivateAddress(ip), ip).toBe(true);
for (const ip of ['1.1.1.1', '37.187.157.191', '2606:4700::1111'])
expect(isPrivateAddress(ip), ip).toBe(false);
});
});
describe('parseLinkMeta', () => {
const html = `<!doctype html><html><head>
<title>Fallback title</title>
<meta property="og:title" content="Humiliating IIS servers &amp; jail time">
<meta name="description" content=" A story
about servers. ">
<meta property="og:image" content="/img/cover.png">
<meta property="og:site_name" content="mll.sh">
<meta property="article:published_time" content="2026-10-01T10:00:00Z">
<link rel="canonical" href="https://mll.sh/post/">
<script type="application/ld+json">{"@type":"BlogPosting","author":{"name":"M L"}}</script>
</head><body></body></html>`;
it('reads og, meta, canonical and JSON-LD', () => {
expect(parseLinkMeta(html, 'https://mll.sh/post/?x=1')).toEqual({
url: 'https://mll.sh/post/',
title: 'Humiliating IIS servers & jail time',
description: 'A story about servers.',
image: 'https://mll.sh/img/cover.png',
siteName: 'mll.sh',
author: 'M L',
publishedAt: '2026-10-01T10:00:00.000Z'
});
});
it('falls back to the <title> and handles pages without metadata', () => {
const meta = parseLinkMeta('<title>Just a title</title>', 'https://example.com/a');
expect(meta.title).toBe('Just a title');
expect(meta.image).toBeNull();
expect(meta.url).toBe('https://example.com/a');
});
it('decodes numeric entities', () => {
expect(decodeEntities('It&#39;s &#x2014; ok')).toBe("It's — ok");
});
});

View File

@@ -0,0 +1,259 @@
// Server-side link preview for the reading list: fetches a public web page and pulls the
// title, description, image, site, author and publish date from its <meta>/JSON-LD tags.
// Only http(s) to public addresses (no localhost / private ranges, checked again on every
// redirect), a short timeout and a size cap, so the owner's "+ add link" can't be pointed
// at internal services.
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
export type LinkMeta = {
url: string;
title: string | null;
description: string | null;
image: string | null;
siteName: string | null;
author: string | null;
publishedAt: string | null;
};
const TIMEOUT_MS = 8000;
const MAX_BYTES = 1_500_000;
const MAX_REDIRECTS = 5;
const UA = 'Mozilla/5.0 (compatible; stanrunge.dev reading list; +https://stanrunge.dev)';
/** Strips tracking parameters and the fragment-less noise, keeps the rest of the URL. */
export function cleanUrl(input: string): string | null {
let url: URL;
try {
url = new URL(input.trim());
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (url.username || url.password) return null;
for (const key of [...url.searchParams.keys()])
if (/^(utm_|fbclid$|gclid$|mc_|ref_src$)/i.test(key)) url.searchParams.delete(key);
return url.toString();
}
/** arXiv, DOI and similar scholarly hosts are papers rather than articles. */
export function kindForUrl(url: string): 'paper' | 'article' {
const host = new URL(url).hostname.replace(/^www\./, '');
return /(^|\.)(arxiv\.org|doi\.org|openreview\.net|aclanthology\.org|proceedings\.neurips\.cc|proceedings\.mlr\.press|semanticscholar\.org|biorxiv\.org|ssrn\.com)$/.test(
host
) || /\.pdf$/i.test(new URL(url).pathname)
? 'paper'
: 'article';
}
export function isPrivateAddress(ip: string): boolean {
if (isIP(ip) === 4) {
const [a, b] = ip.split('.').map(Number);
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 198 && (b === 18 || b === 19)) ||
a >= 224
);
}
const v6 = ip.toLowerCase();
if (v6.startsWith('::ffff:')) return isPrivateAddress(v6.slice(7));
return v6 === '::' || v6 === '::1' || /^(fc|fd|fe[89ab])/.test(v6);
}
async function assertPublic(url: URL) {
const host = url.hostname.replace(/^\[|\]$/g, '');
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal'))
throw new Error('private host');
const addresses = isIP(host) ? [{ address: host }] : await lookup(host, { all: true });
if (!addresses.length || addresses.some((a) => isPrivateAddress(a.address)))
throw new Error('private host');
}
const entities: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
ndash: '–',
mdash: '—',
hellip: '…',
rsquo: '’',
lsquo: '‘',
rdquo: '”',
ldquo: '“'
};
export function decodeEntities(s: string): string {
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => {
if (e[0] === '#') {
const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1));
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
}
return entities[e.toLowerCase()] ?? m;
});
}
const clean = (s: string | null | undefined, max = 500) => {
const v = s ? decodeEntities(s).replace(/\s+/g, ' ').trim() : '';
return v ? (v.length > max ? `${v.slice(0, max - 1).trimEnd()}…` : v) : null;
};
function attrs(tag: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of tag.matchAll(/([a-zA-Z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g))
out[m[1].toLowerCase()] = m[3] ?? m[4] ?? m[5] ?? '';
return out;
}
/** Parses the <head> of an HTML page. Pure; `base` resolves relative image URLs. */
export function parseLinkMeta(html: string, base: string): LinkMeta {
const head = html.slice(0, 400_000);
const meta: Record<string, string> = {};
for (const m of head.matchAll(/<meta\b[^>]*>/gi)) {
const a = attrs(m[0]);
const key = (a.property ?? a.name ?? a.itemprop ?? '').toLowerCase();
if (key && a.content != null && !(key in meta)) meta[key] = a.content;
}
let canonical: string | null = null;
for (const m of head.matchAll(/<link\b[^>]*>/gi)) {
const a = attrs(m[0]);
if (a.rel?.toLowerCase().split(/\s+/).includes('canonical') && a.href) canonical = a.href;
}
// JSON-LD (Article / BlogPosting): author and dates when the meta tags lack them.
let ld: { author?: unknown; datePublished?: string; headline?: string } = {};
for (const m of head.matchAll(
/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
)) {
try {
const parsed = JSON.parse(m[1]);
const nodes = [
parsed,
...(Array.isArray(parsed) ? parsed : []),
...(parsed?.['@graph'] ?? [])
];
const article = nodes.find(
(n) => n && typeof n === 'object' && /Article|BlogPosting|Report/.test(String(n['@type']))
);
if (article) {
ld = article;
break;
}
} catch {
// Broken JSON-LD is common; ignore it.
}
}
const ldAuthor = (() => {
const a = ld.author;
const list = Array.isArray(a) ? a : a ? [a] : [];
const names = list
.map((x) => (typeof x === 'string' ? x : (x as { name?: string })?.name))
.filter(Boolean);
return names.length ? names.join(', ') : null;
})();
const titleTag = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(head)?.[1];
const abs = (u: string | undefined) => {
if (!u) return null;
try {
const url = new URL(decodeEntities(u.trim()), base);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
} catch {
return null;
}
};
const date = (s: string | undefined) => {
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
};
const author =
meta['author'] ?? meta['article:author'] ?? meta['twitter:creator'] ?? ldAuthor ?? undefined;
return {
url: abs(canonical ?? meta['og:url']) ?? base,
title: clean(meta['og:title'] ?? meta['twitter:title'] ?? ld.headline ?? titleTag, 300),
description: clean(
meta['og:description'] ?? meta['description'] ?? meta['twitter:description'],
600
),
image: abs(meta['og:image'] ?? meta['og:image:url'] ?? meta['twitter:image']),
siteName: clean(meta['og:site_name'] ?? meta['application-name'], 120),
author: author && !/^https?:\/\//.test(author) ? clean(author, 200) : null,
publishedAt: date(
meta['article:published_time'] ??
meta['date'] ??
meta['dc.date'] ??
meta['citation_publication_date'] ??
ld.datePublished
)
};
}
/** Fetches `input` and returns its preview; falls back to just the URL when the page is unreachable. */
export async function fetchLinkMeta(input: string): Promise<LinkMeta & { fetched: boolean }> {
const empty = (url: string) => ({
url,
title: null,
description: null,
image: null,
siteName: null,
author: null,
publishedAt: null,
fetched: false
});
const start = cleanUrl(input);
if (!start) throw new Error('Not an http(s) URL');
let url = new URL(start);
const signal = AbortSignal.timeout(TIMEOUT_MS);
try {
for (let i = 0; ; i++) {
await assertPublic(url);
const res = await fetch(url, {
redirect: 'manual',
signal,
headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5' }
});
const location = res.headers.get('location');
if (res.status >= 300 && res.status < 400 && location) {
await res.body?.cancel();
if (i >= MAX_REDIRECTS) return empty(start);
url = new URL(location, url);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return empty(start);
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') ?? '')) {
await res.body?.cancel();
return empty(cleanUrl(url.toString()) ?? start);
}
const reader = res.body!.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
while (size < MAX_BYTES) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
size += value.length;
// The head is all we need.
if (new TextDecoder().decode(value).includes('</head>')) break;
}
await reader.cancel().catch(() => {});
const html = new TextDecoder().decode(Buffer.concat(chunks));
const final = cleanUrl(url.toString()) ?? start;
const meta = parseLinkMeta(html, final);
// Only trust a canonical URL on the same site (some pages point at their homepage).
const canonical = cleanUrl(meta.url);
const sameSite = canonical && new URL(canonical).hostname === new URL(final).hostname;
const keep = sameSite && new URL(canonical).pathname.length > 1 ? canonical : final;
return { ...meta, url: keep, fetched: true };
}
} catch (err) {
if (err instanceof Error && err.message === 'private host') throw err;
return empty(start);
}
}
export const siteFromUrl = (url: string) => new URL(url).hostname.replace(/^www\./, '');

View File

@@ -34,10 +34,15 @@ export const bookReadingStatus = booksSchema.enum('reading_status', [
'abandoned'
]);
// What a work is: a book, a scientific paper, or a web article / link on the reading list.
// Articles have no files; they live at `url` and are opened externally.
export const bookKind = booksSchema.enum('kind', ['book', 'paper', 'article']);
export const bookWorks = booksSchema.table(
'works',
{
id: id(),
kind: bookKind('kind').notNull().default('book'),
title: text('title').notNull(),
subtitle: text('subtitle'),
authors: text('authors').array().notNull().default([]),
@@ -61,9 +66,18 @@ export const bookWorks = booksSchema.table(
metadataSource: text('metadata_source'), // 'openlibrary' | 'googlebooks' | 'libgen' | 'manual'
metadataFetchedAt: timestamptz('metadata_fetched_at'),
notes: text('notes'),
// Articles and papers on the web: canonical link, site name and publish date. The excerpt
// goes in `description` and the og:image in `cover_url`.
url: text('url'),
siteName: text('site_name'),
publishedAt: timestamptz('published_at'),
...timestamps
},
(t) => [
uniqueIndex('works_url_key')
.on(t.url)
.where(sql`${t.url} is not null`),
index('works_kind_idx').on(t.kind),
uniqueIndex('works_isbn13_key')
.on(t.isbn13)
.where(sql`${t.isbn13} is not null`),
@@ -206,4 +220,5 @@ 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 BookKind = (typeof bookKind.enumValues)[number];
export type BookReadingStatus = (typeof bookReadingStatus.enumValues)[number];

View File

@@ -18,7 +18,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/': 'Home',
'/projects': 'Projects',
'/trackers': 'Trackers',
'/trackers/books': 'Books',
'/trackers/books': 'Reading',
'/trackers/spotting': 'Spotting',
'/trackers/cars': 'Cars',
'/trackers/planes': 'Planes',

View File

@@ -10,11 +10,11 @@ describe('tracker icons', () => {
expect(new Set(colours).size).toBe(colours.length);
});
it('keeps tasks, money and books first', () => {
it('keeps tasks, money and reading first', () => {
expect(
visibleTrackers(true)
.slice(0, 3)
.map((t) => t.name)
).toEqual(['tasks', 'money', 'books']);
).toEqual(['tasks', 'money', 'reading']);
});
});

View File

@@ -30,7 +30,7 @@ export const trackerIcons: Record<string, TrackerIcon> = {
ink: WHITE,
paths: [c(12, 12, 9), 'M15.5 8.6a4 4 0 1 0 0 6.8', 'M7 10.8h6M7 13.2h6']
},
books: {
reading: {
from: '#2dd4bf',
to: '#0d9488',
ink: WHITE,

View File

@@ -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 })),

View File

@@ -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 });
}
}
};

View File

@@ -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>
<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">
{#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));

View File

@@ -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');

View File

@@ -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">
{#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}
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"
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,6 +240,7 @@
</div>
{/if}
{#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>
@@ -245,6 +298,7 @@
</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,6 +361,7 @@
{/if}
</section>
{#if !isArticle}
<section class="mt-10">
<h2 class="section-title"><Highlighter size={18} /> highlights</h2>
{#if highlights.length}
@@ -351,7 +406,8 @@
{#if readable && h.locator}
· <a
class="underline-offset-2 hover:underline"
href={`${readHref}?at=${encodeURIComponent(h.locator)}`}>open in reader</a
href={`${readHref}?at=${encodeURIComponent(h.locator)}`}
>open in reader</a
>
{/if}
</span>
@@ -375,15 +431,22 @@
</ul>
{:else}
<p class="text-sm text-[var(--site-muted)]">
Select text in the reader to highlight it{readable ? '' : ' (once there is a file)'}.
Select text in the reader to highlight it{readable
? ''
: ' (once there is a file)'}.
</p>
{/if}
</section>
{/if}
{#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)}
<Button
size="sm"
variant="outline"
onclick={() => (showSessionForm = !showSessionForm)}
>{showSessionForm ? 'cancel' : 'log a session'}</Button
>
</div>
@@ -423,7 +486,9 @@
<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>
<div class="sm:col-span-2">
<Button type="submit" size="sm">save session</Button>
</div>
</form>
{/if}
{#if data.sessions.length}
@@ -456,6 +521,7 @@
</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;

View 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}`);
};