site: list mosaic tiles for /finds/ + home grid

Adds a build-time mosaic generator (scripts/build-list-mosaics.mjs, sharp)
that composites a single tile image per superpost from its referenced
finds. Wired via predev/prestart/prebuild hooks; output lives in
src/generated/ (gitignored). The /finds/ index and home grid both render
the mosaic when present and fall back to text-only tiles otherwise.
This commit is contained in:
2026-05-04 20:34:29 -04:00
parent 315fc71145
commit 99a889523a
6 changed files with 373 additions and 70 deletions
+16
View File
@@ -0,0 +1,16 @@
import type { ImageMetadata } from 'astro';
const modules = import.meta.glob<{ default: ImageMetadata }>(
'../generated/list-mosaics/*.jpg',
{ eager: true }
);
const mosaics = new Map<string, ImageMetadata>();
for (const [path, mod] of Object.entries(modules)) {
const match = path.match(/list-mosaics\/([^/]+)\.jpg$/);
if (match) mosaics.set(match[1], mod.default);
}
export function findListMosaic(slug: string): ImageMetadata | undefined {
return mosaics.get(slug);
}
+60 -26
View File
@@ -1,7 +1,9 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro';
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import { findListMosaic } from '../../lib/find-list-mosaic';
const lists = (await getCollection('finds')).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
@@ -24,28 +26,44 @@ const fmt = new Intl.DateTimeFormat('en-US', {
noindex
>
<Masonry>
{lists.map((l) => (
<article>
<a href={`/finds/${l.id}/`} class="list-tile">
<p class="eyebrow">
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
<span class="dot" aria-hidden="true">·</span>
<span>{l.data.items.length} picks</span>
</p>
<h2 class="title">{l.data.title}</h2>
{l.data.blurb && <p class="blurb">{l.data.blurb}</p>}
</a>
</article>
))}
{lists.map((l) => {
const mosaic = findListMosaic(l.id);
return (
<article>
<a href={`/finds/${l.id}/`} class="list-tile">
{mosaic && (
<div class="mosaic">
<Image
src={mosaic}
widths={[320, 480, 640, 960]}
sizes="(max-width: 520px) 92vw, (max-width: 880px) 45vw, 320px"
loading="lazy"
decoding="async"
alt=""
/>
</div>
)}
<div class="body">
<h2 class="title">{l.data.title}</h2>
{l.data.blurb && <p class="blurb">{l.data.blurb}</p>}
<p class="meta">
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
<span class="dot" aria-hidden="true">·</span>
<span>{l.data.items.length} picks</span>
</p>
</div>
</a>
</article>
);
})}
</Masonry>
<style>
.list-tile {
display: block;
padding: 1.25rem 1.35rem 1.4rem;
overflow: hidden;
border-radius: 8px;
border: 2px solid var(--rule);
background: linear-gradient(155deg, var(--rule), transparent);
color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
@@ -56,21 +74,25 @@ const fmt = new Intl.DateTimeFormat('en-US', {
}
.list-tile:hover .title { color: var(--accent); }
.eyebrow {
margin: 0 0 0.5rem;
.mosaic {
display: block;
width: 100%;
}
.mosaic :global(img) {
display: block;
width: 100%;
height: auto;
}
.body {
padding: 1rem 1.15rem 1.1rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
flex-direction: column;
gap: 0.5rem;
}
.eyebrow .dot { color: var(--rule); }
.title {
margin: 0 0 0.5rem;
margin: 0;
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-weight: 700;
font-size: 1.35rem;
@@ -85,5 +107,17 @@ const fmt = new Intl.DateTimeFormat('en-US', {
font-size: 0.95rem;
line-height: 1.45;
}
.meta {
margin: 0.25rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.meta .dot { color: var(--rule); }
</style>
</BaseLayout>
+111 -44
View File
@@ -3,34 +3,80 @@ import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import Masonry from '../components/Masonry.astro';
import { heroes } from '../lib/hero';
import { findListMosaic } from '../lib/find-list-mosaic';
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
const HERO_SLUG = 'superkey';
const INITIAL_GRID = 12;
const BATCH_SIZE = 7;
const reviews = (await getCollection('reviews')).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
type Tile =
| {
kind: 'review';
href: string;
id: string;
name: string;
subtitle: string;
date: Date;
hero: ReturnType<typeof getReviewHero>;
}
| {
kind: 'list';
href: string;
id: string;
name: string;
subtitle: string | undefined;
date: Date;
itemCount: number;
mosaic: ReturnType<typeof findListMosaic>;
};
const tiles = reviews.map((review) => ({
function getReviewHero(slug: string) {
return heroes[slug];
}
const [reviews, lists] = await Promise.all([
getCollection('reviews'),
getCollection('finds'),
]);
const reviewTiles: Tile[] = reviews.map((review) => ({
kind: 'review' as const,
href: `/reviews/${review.id}/`,
id: review.id,
name: review.data.name,
subtitle: review.data.subtitle,
hero: heroes[review.id],
date: review.data.date,
hero: getReviewHero(review.id),
}));
const heroIndex = tiles.findIndex((t) => t.href === `/reviews/${HERO_SLUG}/`);
const hero = heroIndex >= 0 ? tiles[heroIndex] : tiles[0];
const rest = tiles.filter((_, i) => i !== (heroIndex >= 0 ? heroIndex : 0));
const listTiles: Tile[] = lists.map((list) => ({
kind: 'list' as const,
href: `/finds/${list.id}/`,
id: list.id,
name: list.data.title,
subtitle: list.data.blurb,
date: list.data.date,
itemCount: list.data.items.length,
mosaic: findListMosaic(list.id),
}));
const lastUpdated = reviews[0]?.data.date;
const tiles: Tile[] = [...reviewTiles, ...listTiles].sort(
(a, b) => b.date.valueOf() - a.date.valueOf()
);
const heroIndex = tiles.findIndex(
(t) => t.kind === 'review' && t.href === `/reviews/${HERO_SLUG}/`
);
const hero = heroIndex >= 0 ? tiles[heroIndex] : reviewTiles[0];
const rest = tiles.filter((t) => t !== hero);
const lastUpdated = tiles[0]?.date;
const ogImage = buildOgImage(hero?.id, 'reviews', `${hero?.name ?? 'Unique'} hero image`);
const itemList = buildItemList(
tiles.slice(0, INITIAL_GRID).map((t) => ({ url: t.href, name: t.name })),
'Latest reviews'
'Latest entries'
);
---
@@ -42,7 +88,7 @@ const itemList = buildItemList(
ogImage={ogImage}
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
>
{hero && (
{hero && hero.kind === 'review' && (
<a href={hero.href} class="hero">
{hero.hero?.kind === 'image' && (
<img
@@ -78,39 +124,46 @@ const itemList = buildItemList(
<Masonry initial={INITIAL_GRID} batch={BATCH_SIZE}>
{
rest.map((tile) => (
<article>
<a href={tile.href} class:list={['tile', { 'text-only': !tile.hero }]}>
{tile.hero?.kind === 'image' && (
<img
class="media"
src={tile.hero.src.src}
width={tile.hero.src.width}
height={tile.hero.src.height}
loading="lazy"
decoding="async"
alt={tile.name}
/>
)}
{tile.hero?.kind === 'video' && (
<video
class="media"
src={tile.hero.src}
autoplay
loop
muted
playsinline
preload="none"
aria-label={tile.name}
/>
)}
<div class="text">
<span class="name">{tile.name}</span>
<span class="subtitle">{tile.subtitle}</span>
</div>
</a>
</article>
))
rest.map((tile) => {
const visual =
tile.kind === 'review' ? tile.hero : tile.mosaic ? { kind: 'image' as const, src: tile.mosaic } : undefined;
return (
<article>
<a href={tile.href} class:list={['tile', { 'text-only': !visual, 'is-list': tile.kind === 'list' }]}>
{visual?.kind === 'image' && (
<img
class="media"
src={visual.src.src}
width={visual.src.width}
height={visual.src.height}
loading="lazy"
decoding="async"
alt={tile.name}
/>
)}
{visual?.kind === 'video' && (
<video
class="media"
src={visual.src}
autoplay
loop
muted
playsinline
preload="none"
aria-label={tile.name}
/>
)}
<div class="text">
{tile.kind === 'list' && (
<span class="eyebrow">List · {tile.itemCount} picks</span>
)}
<span class="name">{tile.name}</span>
{tile.subtitle && <span class="subtitle">{tile.subtitle}</span>}
</div>
</a>
</article>
);
})
}
</Masonry>
@@ -198,6 +251,20 @@ const itemList = buildItemList(
gap: 0.2rem;
background: var(--card-fill);
}
.eyebrow {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 600;
}
.tile.is-list .name {
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.01em;
}
.name {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-weight: 700;