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:
@@ -2,6 +2,9 @@
|
|||||||
dist/
|
dist/
|
||||||
.output/
|
.output/
|
||||||
|
|
||||||
|
# generated assets
|
||||||
|
src/generated/
|
||||||
|
|
||||||
# generated types
|
# generated types
|
||||||
.astro/
|
.astro/
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build:mosaics": "node scripts/build-list-mosaics.mjs",
|
||||||
|
"predev": "npm run build:mosaics",
|
||||||
|
"prestart": "npm run build:mosaics",
|
||||||
|
"prebuild": "npm run build:mosaics",
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro check && astro build",
|
"build": "astro check && astro build",
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
"@astrojs/rss": "^4.0.12",
|
"@astrojs/rss": "^4.0.12",
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
"astro": "^5.1.1",
|
"astro": "^5.1.1",
|
||||||
|
"sharp": "^0.34.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Build-time generator: for each list in src/content/finds, compose a
|
||||||
|
// mosaic JPG from the item heroes available in that list. Skips work
|
||||||
|
// when output is newer than every input. Layout depends on hero count:
|
||||||
|
// 0 -> no mosaic (file is removed if it exists)
|
||||||
|
// 1 -> 1200x1200 single image
|
||||||
|
// 2 -> 1200x600 side-by-side
|
||||||
|
// 3 -> 1200x1200 with three cells filled, fourth left as background
|
||||||
|
// 4+ -> 1200x1200 2x2
|
||||||
|
|
||||||
|
import { readdir, readFile, mkdir, stat, unlink } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = resolve(__dirname, '..');
|
||||||
|
const FINDS_DIR = join(ROOT, 'src/content/finds');
|
||||||
|
const FIND_DIR = join(ROOT, 'src/content/find');
|
||||||
|
const OUT_DIR = join(ROOT, 'src/generated/list-mosaics');
|
||||||
|
|
||||||
|
const TILE = 598;
|
||||||
|
const GAP = 4;
|
||||||
|
const FULL = TILE * 2 + GAP; // 1200
|
||||||
|
const BG = { r: 224, g: 219, b: 210, alpha: 1 }; // matches light --rule
|
||||||
|
const HERO_EXTS = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
|
||||||
|
|
||||||
|
function parseItems(frontmatter) {
|
||||||
|
const lines = frontmatter.split('\n');
|
||||||
|
const idx = lines.findIndex((l) => /^items\s*:\s*$/.test(l));
|
||||||
|
if (idx === -1) return [];
|
||||||
|
const out = [];
|
||||||
|
for (let i = idx + 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (/^\s*$/.test(line) || /^\s*#/.test(line)) continue;
|
||||||
|
const m = line.match(/^\s+-\s+["']?([a-z0-9-]+)["']?\s*$/i);
|
||||||
|
if (!m) break;
|
||||||
|
out.push(m[1]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFrontmatter(path) {
|
||||||
|
const src = await readFile(path, 'utf8');
|
||||||
|
const m = src.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findHeroPath(slug) {
|
||||||
|
const dir = join(FIND_DIR, slug);
|
||||||
|
if (!existsSync(dir)) return null;
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
for (const ext of HERO_EXTS) {
|
||||||
|
const file = entries.find((e) => e.toLowerCase() === `hero.${ext}`);
|
||||||
|
if (file) return join(dir, file);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutFor(count) {
|
||||||
|
// Returns { canvasW, canvasH, cells: [{left, top, width, height}] } for the count.
|
||||||
|
if (count === 1) {
|
||||||
|
return { canvasW: FULL, canvasH: FULL, cells: [{ left: 0, top: 0, width: FULL, height: FULL }] };
|
||||||
|
}
|
||||||
|
if (count === 2) {
|
||||||
|
return {
|
||||||
|
canvasW: FULL,
|
||||||
|
canvasH: TILE,
|
||||||
|
cells: [
|
||||||
|
{ left: 0, top: 0, width: TILE, height: TILE },
|
||||||
|
{ left: TILE + GAP, top: 0, width: TILE, height: TILE },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 3 or 4 -- always 1200x1200 with up to 4 cells in row-major order.
|
||||||
|
const cells = [
|
||||||
|
{ left: 0, top: 0, width: TILE, height: TILE },
|
||||||
|
{ left: TILE + GAP, top: 0, width: TILE, height: TILE },
|
||||||
|
{ left: 0, top: TILE + GAP, width: TILE, height: TILE },
|
||||||
|
{ left: TILE + GAP, top: TILE + GAP, width: TILE, height: TILE },
|
||||||
|
].slice(0, count);
|
||||||
|
return { canvasW: FULL, canvasH: FULL, cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tileFromHero(heroPath, w, h) {
|
||||||
|
return sharp(heroPath)
|
||||||
|
.resize(Math.round(w), Math.round(h), { fit: 'cover', position: 'attention' })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildMosaic(heroPaths, outPath) {
|
||||||
|
const count = Math.min(heroPaths.length, 4);
|
||||||
|
const { canvasW, canvasH, cells } = layoutFor(count);
|
||||||
|
const composites = [];
|
||||||
|
for (let i = 0; i < cells.length; i++) {
|
||||||
|
const buf = await tileFromHero(heroPaths[i], cells[i].width, cells[i].height);
|
||||||
|
composites.push({ input: buf, left: Math.round(cells[i].left), top: Math.round(cells[i].top) });
|
||||||
|
}
|
||||||
|
await sharp({
|
||||||
|
create: { width: canvasW, height: canvasH, channels: 3, background: BG },
|
||||||
|
})
|
||||||
|
.composite(composites)
|
||||||
|
.jpeg({ quality: 84, mozjpeg: true })
|
||||||
|
.toFile(outPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maxMtime(paths) {
|
||||||
|
let max = 0;
|
||||||
|
for (const p of paths) {
|
||||||
|
if (!p) continue;
|
||||||
|
try {
|
||||||
|
const s = await stat(p);
|
||||||
|
if (s.mtimeMs > max) max = s.mtimeMs;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryUnlink(path) {
|
||||||
|
try { await unlink(path); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(OUT_DIR, { recursive: true });
|
||||||
|
const listDirs = (await readdir(FINDS_DIR, { withFileTypes: true }))
|
||||||
|
.filter((d) => d.isDirectory())
|
||||||
|
.map((d) => d.name);
|
||||||
|
|
||||||
|
let built = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let removed = 0;
|
||||||
|
let absent = 0;
|
||||||
|
|
||||||
|
for (const listSlug of listDirs) {
|
||||||
|
const mdxPath = join(FINDS_DIR, listSlug, 'index.mdx');
|
||||||
|
const mdPath = join(FINDS_DIR, listSlug, 'index.md');
|
||||||
|
const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null;
|
||||||
|
if (!sourcePath) continue;
|
||||||
|
|
||||||
|
const outPath = join(OUT_DIR, `${listSlug}.jpg`);
|
||||||
|
const fm = await readFrontmatter(sourcePath);
|
||||||
|
const items = parseItems(fm);
|
||||||
|
if (items.length === 0) {
|
||||||
|
if (await tryUnlink(outPath)) removed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take items in declared order, keep only those with heroes, cap at 4.
|
||||||
|
const allHeroes = await Promise.all(items.map(findHeroPath));
|
||||||
|
const heroPaths = allHeroes.filter((p) => p !== null).slice(0, 4);
|
||||||
|
|
||||||
|
if (heroPaths.length === 0) {
|
||||||
|
if (await tryUnlink(outPath)) removed++;
|
||||||
|
else absent++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputMtime = await maxMtime([sourcePath, ...heroPaths]);
|
||||||
|
const outMtime = existsSync(outPath) ? (await stat(outPath)).mtimeMs : 0;
|
||||||
|
|
||||||
|
if (outMtime >= inputMtime && outMtime > 0) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await buildMosaic(heroPaths, outPath);
|
||||||
|
built++;
|
||||||
|
console.log(` built ${listSlug}.jpg (${heroPaths.length}-up)`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`list mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+52
-18
@@ -1,7 +1,9 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import Masonry from '../../components/Masonry.astro';
|
import Masonry from '../../components/Masonry.astro';
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
import { findListMosaic } from '../../lib/find-list-mosaic';
|
||||||
|
|
||||||
const lists = (await getCollection('finds')).sort(
|
const lists = (await getCollection('finds')).sort(
|
||||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
@@ -24,28 +26,44 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
noindex
|
noindex
|
||||||
>
|
>
|
||||||
<Masonry>
|
<Masonry>
|
||||||
{lists.map((l) => (
|
{lists.map((l) => {
|
||||||
|
const mosaic = findListMosaic(l.id);
|
||||||
|
return (
|
||||||
<article>
|
<article>
|
||||||
<a href={`/finds/${l.id}/`} class="list-tile">
|
<a href={`/finds/${l.id}/`} class="list-tile">
|
||||||
<p class="eyebrow">
|
{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>
|
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
|
||||||
<span class="dot" aria-hidden="true">·</span>
|
<span class="dot" aria-hidden="true">·</span>
|
||||||
<span>{l.data.items.length} picks</span>
|
<span>{l.data.items.length} picks</span>
|
||||||
</p>
|
</p>
|
||||||
<h2 class="title">{l.data.title}</h2>
|
</div>
|
||||||
{l.data.blurb && <p class="blurb">{l.data.blurb}</p>}
|
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Masonry>
|
</Masonry>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.list-tile {
|
.list-tile {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 1.25rem 1.35rem 1.4rem;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 2px solid var(--rule);
|
border: 2px solid var(--rule);
|
||||||
background: linear-gradient(155deg, var(--rule), transparent);
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
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); }
|
.list-tile:hover .title { color: var(--accent); }
|
||||||
|
|
||||||
.eyebrow {
|
.mosaic {
|
||||||
margin: 0 0 0.5rem;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.mosaic :global(img) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 1rem 1.15rem 1.1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
.eyebrow .dot { color: var(--rule); }
|
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0;
|
||||||
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
|
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
@@ -85,5 +107,17 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.45;
|
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>
|
</style>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
+88
-21
@@ -3,34 +3,80 @@ import { getCollection } from 'astro:content';
|
|||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import Masonry from '../components/Masonry.astro';
|
import Masonry from '../components/Masonry.astro';
|
||||||
import { heroes } from '../lib/hero';
|
import { heroes } from '../lib/hero';
|
||||||
|
import { findListMosaic } from '../lib/find-list-mosaic';
|
||||||
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
|
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
|
||||||
|
|
||||||
const HERO_SLUG = 'superkey';
|
const HERO_SLUG = 'superkey';
|
||||||
const INITIAL_GRID = 12;
|
const INITIAL_GRID = 12;
|
||||||
const BATCH_SIZE = 7;
|
const BATCH_SIZE = 7;
|
||||||
|
|
||||||
const reviews = (await getCollection('reviews')).sort(
|
type Tile =
|
||||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
| {
|
||||||
);
|
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}/`,
|
href: `/reviews/${review.id}/`,
|
||||||
id: review.id,
|
id: review.id,
|
||||||
name: review.data.name,
|
name: review.data.name,
|
||||||
subtitle: review.data.subtitle,
|
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 listTiles: Tile[] = lists.map((list) => ({
|
||||||
const hero = heroIndex >= 0 ? tiles[heroIndex] : tiles[0];
|
kind: 'list' as const,
|
||||||
const rest = tiles.filter((_, i) => i !== (heroIndex >= 0 ? heroIndex : 0));
|
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 ogImage = buildOgImage(hero?.id, 'reviews', `${hero?.name ?? 'Unique'} hero image`);
|
||||||
const itemList = buildItemList(
|
const itemList = buildItemList(
|
||||||
tiles.slice(0, INITIAL_GRID).map((t) => ({ url: t.href, name: t.name })),
|
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}
|
ogImage={ogImage}
|
||||||
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
|
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
|
||||||
>
|
>
|
||||||
{hero && (
|
{hero && hero.kind === 'review' && (
|
||||||
<a href={hero.href} class="hero">
|
<a href={hero.href} class="hero">
|
||||||
{hero.hero?.kind === 'image' && (
|
{hero.hero?.kind === 'image' && (
|
||||||
<img
|
<img
|
||||||
@@ -78,24 +124,27 @@ const itemList = buildItemList(
|
|||||||
|
|
||||||
<Masonry initial={INITIAL_GRID} batch={BATCH_SIZE}>
|
<Masonry initial={INITIAL_GRID} batch={BATCH_SIZE}>
|
||||||
{
|
{
|
||||||
rest.map((tile) => (
|
rest.map((tile) => {
|
||||||
|
const visual =
|
||||||
|
tile.kind === 'review' ? tile.hero : tile.mosaic ? { kind: 'image' as const, src: tile.mosaic } : undefined;
|
||||||
|
return (
|
||||||
<article>
|
<article>
|
||||||
<a href={tile.href} class:list={['tile', { 'text-only': !tile.hero }]}>
|
<a href={tile.href} class:list={['tile', { 'text-only': !visual, 'is-list': tile.kind === 'list' }]}>
|
||||||
{tile.hero?.kind === 'image' && (
|
{visual?.kind === 'image' && (
|
||||||
<img
|
<img
|
||||||
class="media"
|
class="media"
|
||||||
src={tile.hero.src.src}
|
src={visual.src.src}
|
||||||
width={tile.hero.src.width}
|
width={visual.src.width}
|
||||||
height={tile.hero.src.height}
|
height={visual.src.height}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
alt={tile.name}
|
alt={tile.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tile.hero?.kind === 'video' && (
|
{visual?.kind === 'video' && (
|
||||||
<video
|
<video
|
||||||
class="media"
|
class="media"
|
||||||
src={tile.hero.src}
|
src={visual.src}
|
||||||
autoplay
|
autoplay
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
@@ -105,12 +154,16 @@ const itemList = buildItemList(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
|
{tile.kind === 'list' && (
|
||||||
|
<span class="eyebrow">List · {tile.itemCount} picks</span>
|
||||||
|
)}
|
||||||
<span class="name">{tile.name}</span>
|
<span class="name">{tile.name}</span>
|
||||||
<span class="subtitle">{tile.subtitle}</span>
|
{tile.subtitle && <span class="subtitle">{tile.subtitle}</span>}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</Masonry>
|
</Masonry>
|
||||||
|
|
||||||
@@ -198,6 +251,20 @@ const itemList = buildItemList(
|
|||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
background: var(--card-fill);
|
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 {
|
.name {
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
Reference in New Issue
Block a user