From cd3a9eac708dc3df2cf69f9da692d7ec9d76b818 Mon Sep 17 00:00:00 2001 From: andwati Date: Mon, 31 Aug 2026 08:23:48 +0300 Subject: [PATCH] Redesign bookshelf as a spine shelf + detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interaction/layout pattern is my own implementation, inspired by observing (not copying source from) adammaj.com/reading: a horizontal scrollable shelf of book "spines" above a list, click-through to a per-book detail page where the shelf re-renders with that book's spine expanded into a cover. - src/lib/spine-color.ts: deterministic per-title color + width hash, so every book gets a distinct-looking spine without needing a manually-set color field. - src/lib/cover-exists.ts: checks the referenced cover_image file actually exists under public/ before rendering an — the ISBN/ DOI cover lookup only runs on live Strapi create/update, so hand-seeded content (like our two placeholder books) has a cover_image path with no cached file yet. Falls back to the colored spine block instead of a broken image. - src/components/BookSpines.astro: the shelf itself, reused on both the index and detail pages; a scroll button, hover-lift, and the active book's spine widens into its cover/fallback. - Bookshelf index now renders full reviews inline (was previously just a bare metadata list) with cover thumbnails. - New src/pages/bookshelf/[...slug].astro: first individual page route for bookshelf entries (portfolio still only has a list page). Verified in-browser: shelf renders, hover/click navigation works, active-book expansion on the detail page, and the cover-exists fallback correctly avoids broken tags for our two seed books. --- apps/site/src/components/BookSpines.astro | 146 ++++++++++++++++++ apps/site/src/lib/cover-exists.ts | 16 ++ apps/site/src/lib/spine-color.ts | 28 ++++ apps/site/src/pages/bookshelf/[...slug].astro | 54 +++++++ apps/site/src/pages/bookshelf/index.astro | 100 +++++++++--- 5 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 apps/site/src/components/BookSpines.astro create mode 100644 apps/site/src/lib/cover-exists.ts create mode 100644 apps/site/src/lib/spine-color.ts create mode 100644 apps/site/src/pages/bookshelf/[...slug].astro diff --git a/apps/site/src/components/BookSpines.astro b/apps/site/src/components/BookSpines.astro new file mode 100644 index 0000000..0c162b0 --- /dev/null +++ b/apps/site/src/components/BookSpines.astro @@ -0,0 +1,146 @@ +--- +import { coverExists } from "../lib/cover-exists"; +import { spineStyle } from "../lib/spine-color"; + +interface Item { + slug: string; + title: string; + coverImage?: string; +} + +interface Props { + items: Item[]; + activeSlug?: string; +} + +const { items, activeSlug } = Astro.props; +--- + +
+
+ { + items.map((item) => { + const active = item.slug === activeSlug; + const style = spineStyle(item.title); + return ( + + {active && coverExists(item.coverImage) ? ( + {item.title} + ) : active ? ( + + {item.title} + + ) : ( + {item.title} + )} + + ); + }) + } +
+ +
+ + + + diff --git a/apps/site/src/lib/cover-exists.ts b/apps/site/src/lib/cover-exists.ts new file mode 100644 index 0000000..14b02e7 --- /dev/null +++ b/apps/site/src/lib/cover-exists.ts @@ -0,0 +1,16 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const publicDir = fileURLToPath(new URL("../../public/", import.meta.url)); + +/** Books/papers reference a cover_image path that may not have been fetched + * yet (the ISBN/DOI lookup only runs on live Strapi create/update, not for + * hand-seeded content) — check the file is actually there before rendering + * an that would otherwise 404. */ +export function coverExists( + coverImage: string | undefined, +): coverImage is string { + if (!coverImage) return false; + return existsSync(join(publicDir, coverImage.replace(/^\//, ""))); +} diff --git a/apps/site/src/lib/spine-color.ts b/apps/site/src/lib/spine-color.ts new file mode 100644 index 0000000..c0aacdc --- /dev/null +++ b/apps/site/src/lib/spine-color.ts @@ -0,0 +1,28 @@ +/** Deterministic per-title color/width, so a spine looks the same every + * build without needing a manually-set color field or real cover art. */ +function hashString(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +export interface SpineStyle { + background: string; + color: string; + width: number; +} + +export function spineStyle(title: string): SpineStyle { + const hash = hashString(title); + const hue = hash % 360; + const saturation = 30 + (hash % 25); + const lightness = 28 + ((hash >> 4) % 30); + const width = 42 + (hash % 6) * 7; + return { + background: `hsl(${hue} ${saturation}% ${lightness}%)`, + color: lightness > 52 ? "#211d16" : "#f2ede1", + width, + }; +} diff --git a/apps/site/src/pages/bookshelf/[...slug].astro b/apps/site/src/pages/bookshelf/[...slug].astro new file mode 100644 index 0000000..fc6e736 --- /dev/null +++ b/apps/site/src/pages/bookshelf/[...slug].astro @@ -0,0 +1,54 @@ +--- +import { getCollection, render } from "astro:content"; +import BookSpines from "../../components/BookSpines.astro"; +import Base from "../../layouts/Base.astro"; + +export async function getStaticPaths() { + const items = await getCollection("bookshelf"); + return items.map((entry) => ({ + params: { slug: entry.id }, + props: { entry }, + })); +} + +const { entry } = Astro.props; +const { Content } = await render(entry); + +const items = (await getCollection("bookshelf")).sort( + (a, b) => + (b.data.date_finished?.valueOf() ?? 0) - + (a.data.date_finished?.valueOf() ?? 0), +); +--- + + + ({ slug: e.id, title: e.data.title, coverImage: e.data.cover_image }))} + activeSlug={entry.id} + /> + +

{entry.data.title}

+

+ By: {entry.data.authors.join(", ")} + {entry.data.date_finished && <> · Read {entry.data.date_finished.toDateString()}} + {entry.data.rating && <> · {"★".repeat(entry.data.rating)}} +

+ {entry.data.taxonomies?.tags.map((tag) => #{tag})} + +
+ +
+ +

+ {entry.data.kind === "book" && entry.data.isbn && <>ISBN {entry.data.isbn}} + {entry.data.kind === "paper" && entry.data.arxiv_id && <>arXiv:{entry.data.arxiv_id}} + {entry.data.url && ( + <> + {" "} + · source + + )} +

+ + ← Bookshelf + diff --git a/apps/site/src/pages/bookshelf/index.astro b/apps/site/src/pages/bookshelf/index.astro index 77e3eba..45224a2 100644 --- a/apps/site/src/pages/bookshelf/index.astro +++ b/apps/site/src/pages/bookshelf/index.astro @@ -1,38 +1,92 @@ --- -import { getCollection } from "astro:content"; +import { getCollection, render } from "astro:content"; +import BookSpines from "../../components/BookSpines.astro"; import Base from "../../layouts/Base.astro"; +import { coverExists } from "../../lib/cover-exists"; +import { spineStyle } from "../../lib/spine-color"; const items = (await getCollection("bookshelf")).sort( (a, b) => (b.data.date_finished?.valueOf() ?? 0) - (a.data.date_finished?.valueOf() ?? 0), ); + +const withBodies = await Promise.all( + items.map(async (entry) => ({ + entry, + Content: (await render(entry)).Content, + })), +); --- - +

Bookshelf

+ + ({ slug: entry.id, title: entry.data.title, coverImage: entry.data.cover_image }))} /> + { - items.map((entry) => ( -
-

- {entry.data.title} {entry.data.kind} -

-

{entry.data.authors.join(", ")}

- {entry.data.rating &&

{"★".repeat(entry.data.rating)}

} - {entry.data.read_status &&

{entry.data.read_status}

} - {entry.data.kind === "book" && entry.data.isbn &&

ISBN {entry.data.isbn}

} - {entry.data.kind === "paper" && entry.data.arxiv_id && ( -

- arXiv:{entry.data.arxiv_id} - {entry.data.url && ( - <> - {" "} - · source - + withBodies.map(({ entry, Content }) => { + const style = spineStyle(entry.data.title); + return ( +

- )) + +
+

+ {entry.data.title} {entry.data.kind} +

+

+ {entry.data.authors.join(", ")} + {entry.data.date_finished && <> · Read {entry.data.date_finished.toDateString()}} + {entry.data.rating && <> · {"★".repeat(entry.data.rating)}} +

+ +
+
+ ); + }) } + +