Redesign bookshelf as a spine shelf + detail pages

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 <img> — 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 <img> tags for our two seed books.
This commit is contained in:
2026-08-31 08:23:48 +03:00
parent f5cc3a2144
commit cd3a9eac70
5 changed files with 321 additions and 23 deletions
+146
View File
@@ -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;
---
<div class="spines" data-spines>
<div class="spines-track" data-track>
{
items.map((item) => {
const active = item.slug === activeSlug;
const style = spineStyle(item.title);
return (
<a
href={`/bookshelf/${item.slug}/`}
class:list={["spine", { "spine--active": active }]}
style={active ? undefined : { background: style.background, color: style.color, width: `${style.width}px` }}
title={item.title}
aria-current={active ? "page" : undefined}
>
{active && coverExists(item.coverImage) ? (
<img src={item.coverImage} alt={item.title} loading="lazy" />
) : active ? (
<span class="spine-cover-fallback" style={{ background: style.background, color: style.color }}>
{item.title}
</span>
) : (
<span>{item.title}</span>
)}
</a>
);
})
}
</div>
<button type="button" class="spines-next" data-next aria-label="Scroll shelf right">→</button>
</div>
<style>
.spines {
position: relative;
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.spines-track {
display: flex;
align-items: flex-end;
gap: 3px;
overflow-x: auto;
scroll-behavior: smooth;
padding-bottom: var(--space-2);
scrollbar-width: thin;
}
.spine {
flex-shrink: 0;
height: 168px;
border-radius: 3px 3px 0 0;
text-decoration: none;
writing-mode: vertical-rl;
text-orientation: mixed;
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--space-3) 0;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.02em;
transition:
transform 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out);
overflow: hidden;
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.15);
}
.spine span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-height: 100%;
}
.spine:hover {
transform: translateY(-8px);
}
.spine--active {
width: 112px;
padding: 0;
writing-mode: initial;
}
.spine--active img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 3px 3px 0 0;
}
.spine-cover-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-3);
font-family: var(--font-display);
font-style: italic;
writing-mode: initial;
}
.spines-next {
position: absolute;
right: 0;
top: 76px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 50%;
width: 2rem;
height: 2rem;
line-height: 1;
cursor: pointer;
color: var(--text-muted);
font-family: var(--font-mono);
}
.spines-next:hover {
color: var(--accent);
border-color: var(--accent);
}
</style>
<script>
document.querySelectorAll<HTMLElement>("[data-spines]").forEach((root) => {
const track = root.querySelector<HTMLElement>("[data-track]");
const next = root.querySelector<HTMLButtonElement>("[data-next]");
next?.addEventListener("click", () => {
track?.scrollBy({ left: 320, behavior: "smooth" });
});
});
</script>
+16
View File
@@ -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 <img> that would otherwise 404. */
export function coverExists(
coverImage: string | undefined,
): coverImage is string {
if (!coverImage) return false;
return existsSync(join(publicDir, coverImage.replace(/^\//, "")));
}
+28
View File
@@ -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,
};
}
@@ -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),
);
---
<Base title={entry.data.title} wide>
<BookSpines
items={items.map((e) => ({ slug: e.id, title: e.data.title, coverImage: e.data.cover_image }))}
activeSlug={entry.id}
/>
<h1>{entry.data.title}</h1>
<p class="meta">
By: {entry.data.authors.join(", ")}
{entry.data.date_finished && <> · Read {entry.data.date_finished.toDateString()}</>}
{entry.data.rating && <> · {"★".repeat(entry.data.rating)}</>}
</p>
{entry.data.taxonomies?.tags.map((tag) => <a class="tag" href={`/tags/${tag}/`}>#{tag}</a>)}
<article>
<Content />
</article>
<p class="meta">
{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 && (
<>
{" "}
· <a href={entry.data.url}>source</a>
</>
)}
</p>
<a class="button" href="/bookshelf/">← Bookshelf</a>
</Base>
+77 -23
View File
@@ -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,
})),
);
---
<Base title="Bookshelf">
<Base title="Bookshelf" wide>
<h1>Bookshelf</h1>
<BookSpines items={items.map((entry) => ({ slug: entry.id, title: entry.data.title, coverImage: entry.data.cover_image }))} />
{
items.map((entry) => (
<div class="card">
<h3>
{entry.data.title} <span class="tag">{entry.data.kind}</span>
</h3>
<p class="meta">{entry.data.authors.join(", ")}</p>
{entry.data.rating && <p class="meta">{"★".repeat(entry.data.rating)}</p>}
{entry.data.read_status && <p class="meta">{entry.data.read_status}</p>}
{entry.data.kind === "book" && entry.data.isbn && <p class="meta">ISBN {entry.data.isbn}</p>}
{entry.data.kind === "paper" && entry.data.arxiv_id && (
<p class="meta">
arXiv:{entry.data.arxiv_id}
{entry.data.url && (
<>
{" "}
· <a href={entry.data.url}>source</a>
</>
withBodies.map(({ entry, Content }) => {
const style = spineStyle(entry.data.title);
return (
<div class="book-row">
<a href={`/bookshelf/${entry.id}/`} class="book-cover">
{coverExists(entry.data.cover_image) ? (
<img src={entry.data.cover_image} alt={entry.data.title} loading="lazy" />
) : (
<span style={{ background: style.background, color: style.color }}>{entry.data.title}</span>
)}
</p>
)}
</div>
))
</a>
<div>
<h3>
<a href={`/bookshelf/${entry.id}/`}>{entry.data.title}</a> <span class="tag">{entry.data.kind}</span>
</h3>
<p class="meta">
{entry.data.authors.join(", ")}
{entry.data.date_finished && <> · Read {entry.data.date_finished.toDateString()}</>}
{entry.data.rating && <> · {"★".repeat(entry.data.rating)}</>}
</p>
<Content />
</div>
</div>
);
})
}
</Base>
<style>
.book-row {
display: grid;
grid-template-columns: 7rem 1fr;
gap: var(--space-5);
padding-block: var(--space-6);
border-bottom: 1px solid var(--border);
}
.book-cover {
display: block;
aspect-ratio: 2 / 3;
border-radius: var(--radius-sm);
overflow: hidden;
}
.book-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.book-cover span {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-2);
font-family: var(--font-display);
font-style: italic;
font-size: var(--font-size-sm);
}
@media (max-width: 32rem) {
.book-row {
grid-template-columns: 5rem 1fr;
}
}
</style>