mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 01:12:53 +00:00
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:
@@ -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>
|
||||||
@@ -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(/^\//, "")));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 Base from "../../layouts/Base.astro";
|
||||||
|
import { coverExists } from "../../lib/cover-exists";
|
||||||
|
import { spineStyle } from "../../lib/spine-color";
|
||||||
|
|
||||||
const items = (await getCollection("bookshelf")).sort(
|
const items = (await getCollection("bookshelf")).sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.data.date_finished?.valueOf() ?? 0) -
|
(b.data.date_finished?.valueOf() ?? 0) -
|
||||||
(a.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>
|
<h1>Bookshelf</h1>
|
||||||
|
|
||||||
|
<BookSpines items={items.map((entry) => ({ slug: entry.id, title: entry.data.title, coverImage: entry.data.cover_image }))} />
|
||||||
|
|
||||||
{
|
{
|
||||||
items.map((entry) => (
|
withBodies.map(({ entry, Content }) => {
|
||||||
<div class="card">
|
const style = spineStyle(entry.data.title);
|
||||||
<h3>
|
return (
|
||||||
{entry.data.title} <span class="tag">{entry.data.kind}</span>
|
<div class="book-row">
|
||||||
</h3>
|
<a href={`/bookshelf/${entry.id}/`} class="book-cover">
|
||||||
<p class="meta">{entry.data.authors.join(", ")}</p>
|
{coverExists(entry.data.cover_image) ? (
|
||||||
{entry.data.rating && <p class="meta">{"★".repeat(entry.data.rating)}</p>}
|
<img src={entry.data.cover_image} alt={entry.data.title} loading="lazy" />
|
||||||
{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>}
|
<span style={{ background: style.background, color: style.color }}>{entry.data.title}</span>
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</a>
|
||||||
)}
|
<div>
|
||||||
</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>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user