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
305 lines
8.3 KiB
Plaintext
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>
|