Files
unique/src/pages/index.astro
T
rzen e06e7311d6 site: layout width tiers + masonry glide — column changes animate instead of snapping
Named width tiers (mobile/narrow/regular/wide) with body max-width easing
between them; grid pages opt in via BaseLayout `wide`. Masonry is now
absolute-positioned with FLIP glides on column-count changes: targets are
computed from the *final* container width (reading --max, which flips
instantly while max-width tweens), mid-glide column changes retarget
instead of being dropped, and container height eases so content below
rides along.

Claude-Session: https://claude.ai/code/session_01ADR9r5rw5NuvvnCxfYbMuc
2026-07-11 13:42:23 -04:00

305 lines
8.3 KiB
Plaintext

---
import { getCollection } from 'astro:content';
import { Image } from 'astro:assets';
import BaseLayout from '../layouts/BaseLayout.astro';
import Masonry from '../components/Masonry.astro';
import { heroes } from '../lib/hero';
import { bundleMosaic } from '../lib/bundle-mosaic';
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
import pick from '../data/pick.json';
// Pick of the day. Set in src/data/pick.json; null falls back to the latest review.
const HERO_SLUG: string | null = pick.hero;
const INITIAL_GRID = 24;
const BATCH_SIZE = 7;
type Tile =
| {
kind: 'review';
href: string;
id: string;
name: string;
subtitle: string;
date: Date;
hero: ReturnType<typeof getReviewHero>;
}
| {
kind: 'bundle';
href: string;
id: string;
name: string;
subtitle: string | undefined;
date: Date;
itemCount: number;
mosaic: ReturnType<typeof bundleMosaic>;
};
function getReviewHero(slug: string) {
return heroes[slug];
}
const [reviews, bundles] = await Promise.all([
getCollection('reviews'),
getCollection('bundles'),
]);
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,
date: review.data.date,
hero: getReviewHero(review.id),
}));
const bundleTiles: Tile[] = bundles.map((bundle) => ({
kind: 'bundle' as const,
href: `/bundles/${bundle.id}/`,
id: bundle.id,
name: bundle.data.title,
subtitle: bundle.data.blurb,
date: bundle.data.date,
itemCount: bundle.data.items.length,
mosaic: bundleMosaic(bundle.id),
}));
const tiles: Tile[] = [...reviewTiles, ...bundleTiles].sort(
(a, b) => b.date.valueOf() - a.date.valueOf()
);
const heroIndex = tiles.findIndex(
(t) => t.kind === 'review' && t.href === `/reviews/${HERO_SLUG}/`
);
// Fall back to the newest review (tiles are sorted by date desc).
const hero =
heroIndex >= 0 ? tiles[heroIndex] : tiles.find((t) => t.kind === 'review');
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 entries'
);
---
<BaseLayout
title="Unique"
description="A small log of delightful, unique things — products, apps, phenomena, oddities."
subheader="A small log of delightful things."
lastUpdated={lastUpdated}
ogImage={ogImage}
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
wide
>
{hero && hero.kind === 'review' && (
<a href={hero.href} class="hero">
{hero.hero?.kind === 'image' && (
<Image
class="hero-media"
src={hero.hero.src}
widths={[480, 720, 960, 1280]}
sizes="(max-width: 720px) 92vw, 720px"
loading="eager"
decoding="sync"
fetchpriority="high"
alt={hero.name}
/>
)}
{hero.hero?.kind === 'video' && (
<video
class="hero-media"
src={hero.hero.src}
autoplay
loop
muted
playsinline
preload="metadata"
aria-label={hero.name}
/>
)}
<div class="hero-text">
<span class="hero-eyebrow">Today's pick</span>
<h1 class="hero-name">{hero.name}</h1>
{hero.subtitle && <p class="hero-subtitle">{hero.subtitle}</p>}
</div>
</a>
)}
<Masonry initial={INITIAL_GRID} batch={BATCH_SIZE}>
{
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-bundle': tile.kind === 'bundle' }]}>
{visual?.kind === 'image' && (
<Image
class="media"
src={visual.src}
widths={[320, 480, 640]}
sizes="(max-width: 520px) 92vw, (max-width: 880px) 45vw, 320px"
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 === 'bundle' && (
<span class="eyebrow">Bundle · {tile.itemCount} picks</span>
)}
<span class="name">{tile.name}</span>
{tile.subtitle && <span class="subtitle">{tile.subtitle}</span>}
</div>
</a>
</article>
);
})
}
</Masonry>
<style>
.hero {
display: block;
position: relative;
border-radius: 14px;
overflow: hidden;
border: 2px solid var(--rule);
background: var(--bg);
color: inherit;
margin-bottom: 2.5rem;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.hero:hover {
text-decoration: none;
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
box-shadow: 0 0 32px -4px color-mix(in srgb, var(--accent) 35%, transparent);
}
.hero:hover .hero-name { color: var(--accent); }
.hero-media {
display: block;
width: 100%;
height: auto;
max-height: 62vh;
object-fit: cover;
background: var(--rule);
}
.hero-text {
padding: 1.6rem 1.75rem 1.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.hero-eyebrow {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
}
.hero-name {
margin: 0;
font-size: clamp(1.6rem, 3vw, 2.4rem);
letter-spacing: -0.02em;
transition: color 0.18s ease;
}
.hero-subtitle {
margin: 0;
color: var(--muted);
font-size: clamp(1rem, 1.4vw, 1.15rem);
line-height: 1.45;
}
.tile {
display: block;
border-radius: 8px;
overflow: hidden;
border: 2px solid var(--rule);
background: var(--bg);
color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.tile:hover {
text-decoration: none;
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent);
}
.tile:hover .name { color: var(--accent); }
.media {
display: block;
width: 100%;
height: auto;
background: var(--rule);
}
.text {
padding: 0.85rem 1rem 1rem;
display: flex;
flex-direction: column;
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-bundle .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;
font-size: 1rem;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.subtitle {
color: var(--muted);
font-size: 0.92rem;
line-height: 1.4;
}
.tile.text-only {
background: linear-gradient(155deg, var(--rule), transparent);
}
.tile.text-only .text {
padding: 1.5rem 1.25rem;
}
.tile.text-only .name {
font-size: 1.25rem;
margin-bottom: 0.3rem;
}
.tile.text-only .subtitle {
font-size: 1rem;
}
</style>
</BaseLayout>