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:
+111
-44
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user