site: media + colocation — chunk 1/4 (code, configs, docs)

Code, configs, README, CHANGELOG, sources/log files, .claude.
Subsequent chunks land the renamed media files.
This commit is contained in:
2026-05-04 17:54:14 -04:00
parent e873460465
commit 5dcd99361f
25 changed files with 1138 additions and 243 deletions
+1
View File
@@ -11,6 +11,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
---
+87 -14
View File
@@ -1,11 +1,14 @@
---
import { Image } from 'astro:assets';
import type { FindItem } from '../lib/find-items';
import { findHero } from '../lib/find-hero';
import SourceBadge from './SourceBadge.astro';
interface Props {
item: FindItem;
}
const { item } = Astro.props;
const hero = findHero(item.id);
function host(url: string) {
try {
@@ -16,36 +19,64 @@ function host(url: string) {
}
---
<a href={`/find/${item.id}/`} class="find-card">
<div class="head">
<span class="name">{item.data.name}</span>
<SourceBadge source={item.data.source} />
<article class:list={['find-card', { 'has-hero': !!hero }]}>
{import.meta.env.DEV && (
<label class="edit-checkbox" aria-label={`Select ${item.data.name}`}>
<input type="checkbox" data-find-checkbox data-slug={item.id} />
</label>
)}
{hero && (
<Image
class="media"
src={hero}
widths={[320, 480, 640]}
sizes="(max-width: 520px) 92vw, (max-width: 880px) 45vw, 240px"
loading="lazy"
decoding="async"
alt={item.data.name}
/>
)}
<div class="content">
<div class="head">
<a href={`/find/${item.id}/`} class="name">{item.data.name}</a>
<SourceBadge source={item.data.source} />
</div>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="external">
<span class="ext-label">→</span>
<span class="host">{host(item.data.link)}</span>
</p>
</div>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="external">
<span class="ext-label">→</span>
<span class="host">{host(item.data.link)}</span>
</p>
</a>
</article>
<style>
.find-card {
display: block;
position: relative;
border-radius: 8px;
border: 1px solid var(--rule);
background: var(--bg);
padding: 0.95rem 1.05rem 1rem;
color: inherit;
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.find-card:hover {
text-decoration: none;
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
}
.find-card:hover .name { color: var(--accent); }
.media {
display: block;
width: 100%;
height: auto;
background: var(--card-fill);
border-bottom: 1px solid var(--rule);
}
.content {
padding: 0.95rem 1.05rem 1rem;
}
.head {
display: flex;
flex-direction: column;
@@ -58,8 +89,23 @@ function host(url: string) {
font-size: 1.05rem;
color: var(--fg);
letter-spacing: -0.01em;
text-decoration: none;
transition: color 0.18s ease;
}
.name:hover { text-decoration: none; }
/* Stretched click target — covers the whole card. */
.name::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
}
/* Anchors inside the card (e.g. the source badge) sit above the stretched target. */
.find-card :global(a:not(.name)) {
position: relative;
z-index: 1;
}
.subtitle {
margin: 0 0 0.6rem;
color: var(--muted);
@@ -77,4 +123,31 @@ function host(url: string) {
align-items: baseline;
}
.ext-label { color: var(--accent); font-family: ui-sans-serif, system-ui, sans-serif; }
.edit-checkbox {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 3;
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--rule);
border-radius: 4px;
cursor: pointer;
}
.edit-checkbox input {
width: 1rem;
height: 1rem;
margin: 0;
cursor: pointer;
accent-color: var(--accent);
}
.find-card:has(.edit-checkbox input:checked) {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent);
}
</style>
-35
View File
@@ -1,35 +0,0 @@
---
import type { FindItem } from '../lib/find-items';
import FindCard from './FindCard.astro';
interface Props {
items: FindItem[];
}
const { items } = Astro.props;
---
<ul class="finds-grid">
{items.map((item) => (
<li>
<FindCard item={item} />
</li>
))}
</ul>
<style>
.finds-grid {
column-count: 3;
column-gap: 1rem;
list-style: none;
margin: 0;
padding: 0;
}
@media (max-width: 880px) { .finds-grid { column-count: 2; } }
@media (max-width: 520px) { .finds-grid { column-count: 1; } }
.finds-grid > li {
break-inside: avoid;
display: block;
margin: 0 0 1rem;
}
</style>
+29 -22
View File
@@ -42,10 +42,11 @@ const dataBatch = batch != null ? String(batch) : undefined;
source.parentElement?.insertBefore(masonry, source);
source.remove();
const sentinel = document.createElement('div');
sentinel.className = 'masonry-sentinel';
sentinel.setAttribute('aria-hidden', 'true');
masonry.parentElement?.insertBefore(sentinel, masonry.nextSibling);
const loadMore = document.createElement('button');
loadMore.type = 'button';
loadMore.className = 'masonry-load-more';
loadMore.textContent = 'Load more';
masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling);
let cols: HTMLDivElement[] = [];
let stash: HTMLElement[] = tiles.slice();
@@ -94,31 +95,23 @@ const dataBatch = batch != null ? String(batch) : undefined;
}
function rebuild() {
const visibleCount = placed.length;
stash = placed.concat(stash);
placed = [];
buildColumns();
placeBatch(stash.length, false);
placeBatch(visibleCount, false);
}
buildColumns();
placeBatch(Math.min(initial, stash.length), false);
if (progressive && stash.length > 0) {
let busy = false;
const io = new IntersectionObserver((entries) => {
if (busy || stash.length === 0) return;
if (!entries[0].isIntersecting) return;
busy = true;
loadMore.addEventListener('click', () => {
placeBatch(Math.min(batch, stash.length), true);
setTimeout(() => { busy = false; }, 120);
if (stash.length === 0) {
io.disconnect();
sentinel.remove();
}
}, { rootMargin: '600px 0px' });
io.observe(sentinel);
if (stash.length === 0) loadMore.remove();
});
} else {
sentinel.remove();
loadMore.remove();
}
let resizeTimer: number | undefined;
@@ -173,9 +166,23 @@ const dataBatch = batch != null ? String(batch) : undefined;
@media (prefers-reduced-motion: reduce) {
.masonry-tile.revealing { animation: none; }
}
.masonry-sentinel {
height: 1px;
margin-top: -1px;
pointer-events: none;
.masonry-load-more {
display: block;
margin: 1.5rem auto 0;
padding: 0.55rem 1.1rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
background: transparent;
border: 1px solid var(--rule);
border-radius: 999px;
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease;
}
.masonry-load-more:hover {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
}
</style>
+23 -4
View File
@@ -1,8 +1,15 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const stripIndex = ({ entry }: { entry: string }) =>
entry.replace(/\/index\.mdx$/, '');
const reviews = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/reviews' }),
loader: glob({
pattern: '**/index.mdx',
base: './src/content/reviews',
generateId: stripIndex,
}),
schema: z.object({
name: z.string(),
subtitle: z.string(),
@@ -18,7 +25,11 @@ const reviews = defineCollection({
});
const posts = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }),
loader: glob({
pattern: '**/index.mdx',
base: './src/content/posts',
generateId: stripIndex,
}),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
@@ -29,7 +40,11 @@ const posts = defineCollection({
});
const find = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/find' }),
loader: glob({
pattern: '**/index.mdx',
base: './src/content/find',
generateId: stripIndex,
}),
schema: z.object({
name: z.string(),
subtitle: z.string().optional(),
@@ -45,7 +60,11 @@ const find = defineCollection({
});
const finds = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/finds' }),
loader: glob({
pattern: '**/index.mdx',
base: './src/content/finds',
generateId: stripIndex,
}),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
+16 -1
View File
@@ -1,12 +1,14 @@
---
import { getSiteStats } from '../lib/entries';
import SEO from '../components/SEO.astro';
import EditConfirm from '../components/EditConfirm.astro';
import type { OgImage } from '../components/SEO.astro';
interface Props {
title: string;
description?: string;
subheader?: string;
subheaderSubtitle?: string;
subheaderVariant?: 'tagline' | 'headline';
lastUpdated?: Date;
canonicalPath?: string;
@@ -24,6 +26,7 @@ const {
title,
description = 'A small log of delightful, unique things — products, apps, phenomena, oddities.',
subheader,
subheaderSubtitle,
subheaderVariant = 'tagline',
lastUpdated,
canonicalPath,
@@ -87,18 +90,21 @@ const year = new Date().getFullYear();
<header class="masthead">
<div class="masthead-row">
<div class="masthead-stats">
<span>{stats.total} entries</span>
<span><a href="/posts/2026-05-03-why-this-exists/">founded {fmtShort.format(SITE_FOUNDED)}</a></span>
</div>
<a href="/" class="masthead-brand">{siteName}</a>
<nav class="masthead-nav" aria-label="Primary">
<a href="/about/">About</a>
<a href="/blog/">Blog</a>
<a href="/search/">Search</a>
</nav>
</div>
{subheader && (
<div class="subheader">
<p class={`subheader-line${subheaderVariant === 'headline' ? ' subheader-headline' : ''}`}>{subheader}</p>
{subheaderSubtitle && subheaderVariant === 'headline' && (
<p class="subheader-subtitle">{subheaderSubtitle}</p>
)}
{updatedDate && subheaderVariant === 'headline' && (
<p class="subheader-date">
<time datetime={updatedDate.toISOString()}>{fmtLong.format(updatedDate)}</time>
@@ -291,6 +297,14 @@ const year = new Date().getFullYear();
letter-spacing: -0.01em;
color: var(--fg);
}
.subheader-subtitle {
margin: 0.35rem 0 0;
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-style: italic;
font-size: 1.05rem;
line-height: 1.4;
color: var(--muted);
}
.subheader-date {
margin: 0.25rem 0 0;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
@@ -403,5 +417,6 @@ const year = new Date().getFullYear();
}
hr { border: 0; border-top: 1px solid var(--rule); margin: 2rem 0; }
</style>
{import.meta.env.DEV && <EditConfirm />}
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
import type { ImageMetadata } from 'astro';
const modules = import.meta.glob<{ default: ImageMetadata }>(
'../content/find/*/hero.{png,jpg,jpeg,gif,webp}',
{ eager: true }
);
const heroes = new Map<string, ImageMetadata>();
for (const [path, mod] of Object.entries(modules)) {
const match = path.match(/find\/([^/]+)\/hero\./);
if (match) heroes.set(match[1], mod.default);
}
export function findHero(slug: string): ImageMetadata | undefined {
return heroes.get(slug);
}
+19 -17
View File
@@ -1,21 +1,22 @@
import type { ImageMetadata } from 'astro';
import alcove from '../assets/reviews/alcove/hero.png';
import altTab from '../assets/reviews/alt-tab/hero.jpg';
import cotypist from '../assets/reviews/cotypist/hero.gif';
import fluxMarkdown from '../assets/reviews/flux-markdown/demo.gif';
import folderPeek from '../assets/reviews/folder-peek/screenshot.png';
import hyperkey from '../assets/reviews/hyperkey/hero.png';
import ice from '../assets/reviews/ice/ice-bar.png';
import macMouseFix from '../assets/reviews/mac-mouse-fix/studio-display.png';
import tot from '../assets/reviews/tot/seven-dots.png';
import alcove from '../content/reviews/alcove/hero.png';
import altTab from '../content/reviews/alt-tab/hero.jpg';
import esproP7 from '../content/reviews/espro-p7-french-press/hero.jpg';
import folderPeek from '../content/reviews/folder-peek/screenshot.png';
import hyperkey from '../content/reviews/hyperkey/hero.png';
import ice from '../content/reviews/ice/ice-bar.png';
import macMouseFix from '../content/reviews/mac-mouse-fix/studio-display.png';
import tot from '../content/reviews/tot/seven-dots.png';
import antinote from '../assets/reviews/antinote/swipe.mp4';
import dockdoor from '../assets/reviews/dockdoor/alttab.mp4';
import homerow from '../assets/reviews/homerow/overlay.mp4';
import monocle from '../assets/reviews/monocle/spotlight.mp4';
import shottr from '../assets/reviews/shottr/text-erase.mp4';
import typewhisper from '../assets/reviews/typewhisper/demo.mp4';
import antinote from '../content/reviews/antinote/swipe.mp4';
import cotypist from '../content/reviews/cotypist/hero.mp4';
import dockdoor from '../content/reviews/dockdoor/alttab.mp4';
import fluxMarkdown from '../content/reviews/flux-markdown/demo.mp4';
import homerow from '../content/reviews/homerow/overlay.mp4';
import monocle from '../content/reviews/monocle/spotlight.mp4';
import shottr from '../content/reviews/shottr/text-erase.mp4';
import typewhisper from '../content/reviews/typewhisper/demo.mp4';
export type Hero =
| { kind: 'image'; src: ImageMetadata }
@@ -24,15 +25,16 @@ export type Hero =
export const heroes: Record<string, Hero | undefined> = {
alcove: { kind: 'image', src: alcove },
'alt-tab': { kind: 'image', src: altTab },
cotypist: { kind: 'image', src: cotypist },
'flux-markdown': { kind: 'image', src: fluxMarkdown },
'espro-p7-french-press': { kind: 'image', src: esproP7 },
'folder-peek': { kind: 'image', src: folderPeek },
hyperkey: { kind: 'image', src: hyperkey },
ice: { kind: 'image', src: ice },
'mac-mouse-fix': { kind: 'image', src: macMouseFix },
tot: { kind: 'image', src: tot },
antinote: { kind: 'video', src: antinote },
cotypist: { kind: 'video', src: cotypist },
dockdoor: { kind: 'video', src: dockdoor },
'flux-markdown': { kind: 'video', src: fluxMarkdown },
homerow: { kind: 'video', src: homerow },
monocle: { kind: 'video', src: monocle },
shottr: { kind: 'video', src: shottr },
+47 -29
View File
@@ -1,9 +1,12 @@
---
import { getCollection, render } from 'astro:content';
import { Image } from 'astro:assets';
import BaseLayout from '../../layouts/BaseLayout.astro';
import SourceBadge from '../../components/SourceBadge.astro';
import Backlinks from '../../components/Backlinks.astro';
import { findsReferencing } from '../../lib/backlinks';
import { findHero } from '../../lib/find-hero';
import EditAction from '../../components/EditAction.astro';
import {
resolveExternalLink,
AFFILIATE_REL,
@@ -22,12 +25,7 @@ export async function getStaticPaths() {
const { item } = Astro.props;
const { Content } = await render(item);
const featuringPosts = await findsReferencing(item.id);
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const hero = findHero(item.id);
const linkLabel =
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
@@ -72,6 +70,10 @@ const breadcrumbs = buildBreadcrumbs([
<BaseLayout
title={item.data.name}
description={description}
subheader={item.data.name}
subheaderSubtitle={item.data.subtitle}
subheaderVariant="headline"
lastUpdated={item.data.date}
ogType="article"
ogImage={ogImage}
canonicalOverride={canonicalOverride}
@@ -80,18 +82,22 @@ const breadcrumbs = buildBreadcrumbs([
jsonLd={[productSchema, breadcrumbs]}
>
<article>
<p class="source-line"><SourceBadge source={item.data.source} /></p>
<header class="find-header">
<h1>{item.data.name}</h1>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="meta">
<time datetime={item.data.date.toISOString()}>
{fmt.format(item.data.date)}
</time>
</p>
<p class="source-line"><SourceBadge source={item.data.source} /></p>
</header>
{hero && (
<Image
class="hero"
src={hero}
widths={[640, 960, 1280]}
sizes="(max-width: 720px) 92vw, 42rem"
loading="eager"
decoding="async"
alt={item.data.name}
/>
)}
<div class="body">
<Content />
</div>
@@ -120,6 +126,16 @@ const breadcrumbs = buildBreadcrumbs([
)
}
{import.meta.env.DEV && !item.data.promotedTo && (
<div class="edit-toolbar">
<EditAction
label="Graduate to review"
command={`/build-review ${item.id}`}
confirm={`This will draft src/content/reviews/${item.id}.mdx, expand the find body into a full review, propose a hero asset, link the find via promotedTo, and append a graduation note to daily-finds.log.md.`}
/>
</div>
)}
<Backlinks posts={featuringPosts} />
{
@@ -136,23 +152,20 @@ const breadcrumbs = buildBreadcrumbs([
</article>
<style>
.source-line { margin: 0 0 0.5rem; }
.find-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
h1 { margin: 0 0 0.35rem; }
.subtitle {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 1.1rem;
font-style: italic;
}
.meta {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
.source-line { margin: 0; }
.hero {
display: block;
width: 100%;
height: auto;
max-height: 28rem;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--rule);
background: var(--card-fill);
margin: 0 0 1.5rem;
}
.body :global(p) { margin: 0 0 1rem; }
.external {
@@ -208,5 +221,10 @@ const breadcrumbs = buildBreadcrumbs([
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
.edit-toolbar {
margin: 2rem 0 0;
padding-top: 1rem;
border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--rule));
}
</style>
</BaseLayout>
+59 -30
View File
@@ -1,15 +1,15 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro';
import FindCard from '../../components/FindCard.astro';
import EditAction from '../../components/EditAction.astro';
import { getAllFinds } from '../../lib/find-items';
const INITIAL_GRID = 18;
const BATCH_SIZE = 18;
const finds = await getAllFinds();
const lastUpdated = finds[0]?.data.date;
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
---
<BaseLayout
@@ -19,35 +19,64 @@ const fmt = new Intl.DateTimeFormat('en-US', {
lastUpdated={lastUpdated}
noindex
>
<ul class="find-index">
{finds.map((f) => (
<li>
<a href={`/find/${f.id}/`}>{f.data.name}</a>
<time datetime={f.data.date.toISOString()}>{fmt.format(f.data.date)}</time>
</li>
))}
</ul>
<Masonry initial={INITIAL_GRID} batch={BATCH_SIZE}>
{finds.map((f) => <FindCard item={f} />)}
</Masonry>
{import.meta.env.DEV && (
<div class="edit-floating-toolbar" data-find-toolbar>
<span class="edit-toolbar-count">
<span data-find-selected-count>0</span> selected
</span>
<EditAction
action="finds-collection"
label="Make a finds collection"
confirm="This will draft a themed finds list (.mdx in src/content/finds/) from the selected items."
/>
</div>
)}
<script>
// Update the selected-count badge as boxes are toggled.
const counter = document.querySelector('[data-find-selected-count]');
if (counter) {
const refresh = () => {
const n = document.querySelectorAll('[data-find-checkbox]:checked').length;
counter.textContent = String(n);
};
document.addEventListener('change', (e) => {
const t = e.target as HTMLElement;
if (t.matches('[data-find-checkbox]')) refresh();
});
refresh();
}
</script>
<style>
.find-index {
list-style: none;
margin: 0;
padding: 0;
}
.find-index li {
.edit-floating-toolbar {
position: sticky;
bottom: 1rem;
margin-top: 2rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--rule);
align-items: center;
gap: 0.85rem;
justify-content: flex-end;
padding: 0.65rem 0.85rem;
background: var(--bg);
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
border-radius: 8px;
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
}
.find-index li:last-child { border-bottom: 0; }
.find-index time {
.edit-toolbar-count {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.edit-toolbar-count [data-find-selected-count] {
color: var(--fg);
font-weight: 700;
}
</style>
</BaseLayout>
+8 -26
View File
@@ -1,7 +1,8 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FindsGrid from '../../components/FindsGrid.astro';
import Masonry from '../../components/Masonry.astro';
import FindCard from '../../components/FindCard.astro';
import { resolveFinds } from '../../lib/find-items';
import {
buildOgImage,
@@ -23,12 +24,6 @@ const { post } = Astro.props;
const { Content } = await render(post);
const items = await resolveFinds(post.data.items);
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const description =
post.data.description ?? post.data.blurb ?? `A collection: ${post.data.title}.`;
@@ -65,6 +60,9 @@ const breadcrumbs = buildBreadcrumbs([
<BaseLayout
title={post.data.title}
description={description}
subheader={post.data.title}
subheaderVariant="headline"
lastUpdated={post.data.date}
ogType="article"
ogImage={ogImage}
publishedTime={post.data.date}
@@ -72,20 +70,13 @@ const breadcrumbs = buildBreadcrumbs([
jsonLd={[articleSchema, itemList, breadcrumbs]}
>
<article>
<header class="finds-header">
<h1>{post.data.title}</h1>
<p class="meta">
<time datetime={post.data.date.toISOString()}>
{fmt.format(post.data.date)}
</time>
</p>
</header>
<div class="body">
<Content />
</div>
<FindsGrid items={items} />
<Masonry>
{items.map((item) => <FindCard item={item} />)}
</Masonry>
{
post.data.tags.length > 0 && (
@@ -101,15 +92,6 @@ const breadcrumbs = buildBreadcrumbs([
</article>
<style>
.finds-header {
margin-bottom: 1.5rem;
}
h1 { margin: 0 0 0.35rem; font-size: 1.85rem; }
.meta {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.body {
margin-bottom: 1.75rem;
max-width: 42rem;
+56 -22
View File
@@ -1,5 +1,6 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro';
import { getCollection } from 'astro:content';
const lists = (await getCollection('finds')).sort(
@@ -11,6 +12,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
---
@@ -21,35 +23,67 @@ const fmt = new Intl.DateTimeFormat('en-US', {
lastUpdated={lastUpdated}
noindex
>
<ul class="lists-index">
<Masonry>
{lists.map((l) => (
<li>
<a href={`/finds/${l.id}/`}>{l.data.title}</a>
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
</li>
<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>
))}
</ul>
</Masonry>
<style>
.lists-index {
list-style: none;
margin: 0;
padding: 0;
.list-tile {
display: block;
padding: 1.25rem 1.35rem 1.4rem;
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;
}
.lists-index li {
.list-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);
}
.list-tile:hover .title { color: var(--accent); }
.eyebrow {
margin: 0 0 0.5rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--rule);
}
.lists-index li:last-child { border-bottom: 0; }
.lists-index time {
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);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eyebrow .dot { color: var(--rule); }
.title {
margin: 0 0 0.5rem;
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-weight: 700;
font-size: 1.35rem;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.blurb {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.45;
}
</style>
</BaseLayout>
+1 -1
View File
@@ -48,7 +48,7 @@ const lastUpdated = reviews[0]?.data.date;
loop
muted
playsinline
preload="metadata"
preload="none"
aria-label={tile.name}
/>
)}
+1 -1
View File
@@ -100,7 +100,7 @@ const itemList = buildItemList(
loop
muted
playsinline
preload="metadata"
preload="none"
aria-label={tile.name}
/>
)}
+26 -21
View File
@@ -2,6 +2,7 @@
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import SourceBadge from '../../components/SourceBadge.astro';
import EditAction from '../../components/EditAction.astro';
import {
resolveExternalLink,
AFFILIATE_REL,
@@ -30,6 +31,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
const linkLabel =
@@ -78,6 +80,10 @@ const breadcrumbs = buildBreadcrumbs([
<BaseLayout
title={review.data.name}
description={description}
subheader={review.data.name}
subheaderSubtitle={review.data.subtitle}
subheaderVariant="headline"
lastUpdated={review.data.date}
ogType="article"
ogImage={ogImage}
publishedTime={review.data.date}
@@ -85,18 +91,11 @@ const breadcrumbs = buildBreadcrumbs([
jsonLd={[reviewSchema, breadcrumbs]}
>
<article>
{review.data.source && (
<p class="source-line"><SourceBadge source={review.data.source} /></p>
)}
<header class="review-header">
<h1>{review.data.name}</h1>
<p class="subtitle">{review.data.subtitle}</p>
{review.data.source && (
<p class="source-line"><SourceBadge source={review.data.source} /></p>
)}
<p class="meta">
<time datetime={review.data.date.toISOString()}>
{fmt.format(review.data.date)}
</time>
<span class="dot">·</span>
<a href={`/categories/${review.data.category}/`} class="category">
{review.data.category}
</a>
@@ -135,6 +134,16 @@ const breadcrumbs = buildBreadcrumbs([
<Content />
</div>
{import.meta.env.DEV && (
<div class="edit-toolbar">
<EditAction
label="Make today's pick"
command={`/set-pick ${review.id}`}
confirm={`This will set ${review.data.name} as the homepage hero (updates HERO_SLUG in src/pages/index.astro).`}
/>
</div>
)}
{
review.data.tags.length > 0 && (
<footer class="tags">
@@ -160,19 +169,10 @@ const breadcrumbs = buildBreadcrumbs([
}
.from-find time { color: var(--muted); }
.review-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
h1 { margin: 0 0 0.35rem; }
.subtitle {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 1.1rem;
font-style: italic;
margin-bottom: 1.5rem;
}
.meta {
margin: 0;
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.9rem;
}
@@ -309,5 +309,10 @@ const breadcrumbs = buildBreadcrumbs([
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
.edit-toolbar {
margin: 2rem 0 0;
padding-top: 1rem;
border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--rule));
}
</style>
</BaseLayout>
+313
View File
@@ -0,0 +1,313 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout
title="Search"
description="Search reviews, finds, lists, and posts across Unique."
subheader="Search"
noindex
>
<form class="search-form" role="search" autocomplete="off">
<input
id="q"
type="search"
name="q"
placeholder="Search reviews, finds, lists, posts…"
aria-label="Search"
autofocus
/>
</form>
<p id="status" class="status" aria-live="polite"></p>
<div id="results" class="masonry" role="list"></div>
<script>
const input = document.getElementById('q') as HTMLInputElement;
const status = document.getElementById('status') as HTMLParagraphElement;
const results = document.getElementById('results') as HTMLDivElement;
type Entry = {
type: 'review' | 'find' | 'list' | 'post';
url: string;
title: string;
subtitle: string;
date: string;
tags: string[];
blurb: string;
source?: string;
};
const typeLabels: Record<Entry['type'], string> = {
review: 'Review',
find: 'Find',
list: 'List',
post: 'Post',
};
let entries: Entry[] = [];
let ready = false;
let lastTiles: HTMLElement[] = [];
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function score(entry: Entry, terms: string[]): number {
const title = entry.title.toLowerCase();
const subtitle = entry.subtitle.toLowerCase();
const blurb = entry.blurb.toLowerCase();
const tags = entry.tags.join(' ').toLowerCase();
const source = (entry.source ?? '').toLowerCase();
let total = 0;
for (const term of terms) {
let s = 0;
if (title.includes(term)) s += 10;
if (title.startsWith(term)) s += 5;
if (subtitle.includes(term)) s += 4;
if (tags.includes(term)) s += 3;
if (blurb.includes(term)) s += 2;
if (source.includes(term)) s += 2;
if (s === 0) return 0;
total += s;
}
return total;
}
function colCount(): number {
const w = window.innerWidth;
if (w <= 520) return 1;
if (w <= 880) return 2;
return 3;
}
function tileHtml(entry: Entry): string {
const date = fmt.format(new Date(entry.date));
const blurb = entry.blurb || entry.subtitle;
const blurbEl = blurb
? `<p class="r-blurb">${escapeHtml(blurb)}</p>`
: '';
const source = entry.source
? `<p class="r-source">via ${escapeHtml(entry.source)}</p>`
: '';
return `<a href="${entry.url}" class="r-tile" role="listitem">
<p class="r-eyebrow"><span class="r-type">${typeLabels[entry.type]}</span><span class="r-dot" aria-hidden="true">·</span><time datetime="${entry.date}">${date}</time></p>
<p class="r-title">${escapeHtml(entry.title)}</p>
${source}${blurbEl}
</a>`;
}
function layout(tiles: HTMLElement[]) {
results.replaceChildren();
const cols: HTMLDivElement[] = [];
const n = colCount();
for (let i = 0; i < n; i++) {
const c = document.createElement('div');
c.className = 'masonry-col';
results.appendChild(c);
cols.push(c);
}
for (const tile of tiles) {
let min = Infinity;
let idx = 0;
for (let i = 0; i < cols.length; i++) {
const h = cols[i].offsetHeight;
if (h < min) { min = h; idx = i; }
}
cols[idx].appendChild(tile);
}
}
function buildTiles(items: Entry[]): HTMLElement[] {
const wrap = document.createElement('div');
wrap.innerHTML = items.map(tileHtml).join('');
return Array.from(wrap.children) as HTMLElement[];
}
function render(query: string) {
const q = query.trim().toLowerCase();
if (!q) {
lastTiles = [];
results.replaceChildren();
status.textContent = ready
? `${entries.length} entries indexed.`
: 'Loading index…';
return;
}
if (!ready) {
status.textContent = 'Loading index…';
return;
}
const terms = q.split(/\s+/).filter(Boolean);
const matches: Array<{ entry: Entry; score: number }> = [];
for (const entry of entries) {
const s = score(entry, terms);
if (s > 0) matches.push({ entry, score: s });
}
matches.sort((a, b) => b.score - a.score || b.entry.date.localeCompare(a.entry.date));
const top = matches.slice(0, 80);
status.textContent = matches.length
? `${matches.length} match${matches.length === 1 ? '' : 'es'}`
: 'No matches.';
lastTiles = buildTiles(top.map((m) => m.entry));
layout(lastTiles);
}
function syncFromUrl() {
const params = new URLSearchParams(window.location.search);
const q = params.get('q') ?? '';
input.value = q;
render(q);
}
let urlTimer: number | undefined;
input.addEventListener('input', () => {
render(input.value);
if (urlTimer) window.clearTimeout(urlTimer);
urlTimer = window.setTimeout(() => {
const url = new URL(window.location.href);
if (input.value) url.searchParams.set('q', input.value);
else url.searchParams.delete('q');
window.history.replaceState(null, '', url.toString());
}, 300);
});
let resizeTimer: number | undefined;
let currentCols = colCount();
window.addEventListener('resize', () => {
if (resizeTimer) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
const next = colCount();
if (next !== currentCols) {
currentCols = next;
if (lastTiles.length) layout(lastTiles);
}
}, 150);
});
fetch('/search.json')
.then((r) => r.json())
.then((data: { entries: Entry[] }) => {
entries = data.entries;
ready = true;
syncFromUrl();
})
.catch(() => {
status.textContent = 'Could not load search index.';
});
syncFromUrl();
</script>
<style>
.search-form {
margin: 0 0 1.25rem;
}
#q {
width: 100%;
padding: 0.7rem 0.9rem;
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-size: 1.05rem;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--rule);
border-radius: 8px;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
#q:focus {
border-color: color-mix(in srgb, var(--accent) 55%, var(--rule));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.status {
margin: 0 0 1rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.masonry {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.masonry :global(.masonry-col) {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.masonry :global(.r-tile) {
display: block;
padding: 1rem 1.1rem 1.1rem;
border-radius: 8px;
border: 1px solid var(--rule);
background: var(--bg);
color: inherit;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.masonry :global(.r-tile:hover) {
text-decoration: none;
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
}
.masonry :global(.r-tile:hover .r-title) { color: var(--accent); }
.masonry :global(.r-eyebrow) {
margin: 0 0 0.4rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.masonry :global(.r-type) { color: var(--accent); }
.masonry :global(.r-dot) { color: var(--rule); }
.masonry :global(.r-title) {
margin: 0 0 0.35rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.masonry :global(.r-source) {
margin: 0 0 0.45rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.masonry :global(.r-blurb) {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
line-height: 1.4;
}
</style>
</BaseLayout>
+56
View File
@@ -0,0 +1,56 @@
import { getCollection } from 'astro:content';
export async function GET() {
const [reviews, finds, lists, posts] = await Promise.all([
getCollection('reviews'),
getCollection('find'),
getCollection('finds'),
getCollection('posts'),
]);
const entries = [
...reviews.map((r) => ({
type: 'review',
url: `/reviews/${r.id}/`,
title: r.data.name,
subtitle: r.data.subtitle ?? '',
date: r.data.date.toISOString(),
tags: r.data.tags ?? [],
blurb: r.data.description ?? '',
})),
...finds.map((f) => ({
type: 'find',
url: `/find/${f.id}/`,
title: f.data.name,
subtitle: f.data.subtitle ?? '',
date: f.data.date.toISOString(),
tags: f.data.tags ?? [],
blurb: f.data.description ?? '',
source: f.data.source,
})),
...lists.map((l) => ({
type: 'list',
url: `/finds/${l.id}/`,
title: l.data.title,
subtitle: l.data.blurb ?? '',
date: l.data.date.toISOString(),
tags: l.data.tags ?? [],
blurb: l.data.description ?? '',
})),
...posts.map((p) => ({
type: 'post',
url: `/posts/${p.id}/`,
title: p.data.title,
subtitle: '',
date: p.data.date.toISOString(),
tags: p.data.tags ?? [],
blurb: p.data.description ?? '',
})),
];
entries.sort((a, b) => b.date.localeCompare(a.date));
return new Response(JSON.stringify({ entries }), {
headers: { 'Content-Type': 'application/json' },
});
}
+23 -7
View File
@@ -20,16 +20,15 @@ type Source = {
notes: string;
};
const sources: Source[] = [...sourcesData.sources].sort((a, b) =>
a.name.localeCompare(b.name)
);
const topicLabels: Record<string, string> = {
'macos-apps': 'macOS apps',
'apple-tips': 'apple tips',
'tools': 'tools',
'gifts': 'gifts',
'clothing': 'clothing',
'travel': 'travel',
'quotes': 'quotes',
'dad-jokes': 'dad jokes',
'general-delight': 'general delight',
};
@@ -48,10 +47,27 @@ for (const f of allFinds) {
arr.push(f);
findsBySource.set(f.data.source, arr);
}
for (const arr of findsBySource.values()) {
arr.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
const lastUpdated = allFinds.sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
)[0]?.data.date;
const sources: Source[] = [...sourcesData.sources].sort((a, b) => {
const af = findsBySource.get(a.name) ?? [];
const bf = findsBySource.get(b.name) ?? [];
const aLatest = af[0]?.data.date.valueOf() ?? -Infinity;
const bLatest = bf[0]?.data.date.valueOf() ?? -Infinity;
if (aLatest !== bLatest) return bLatest - aLatest;
if (af.length !== bf.length) return bf.length - af.length;
return a.name.localeCompare(b.name);
});
const lastUpdated = findsBySource.size
? new Date(
Math.max(
...[...findsBySource.values()].map((arr) => arr[0].data.date.valueOf())
)
)
: undefined;
const description = `The ${sources.length} feeds, shops, and forums Unique draws daily picks from.`;
const ogImage = buildOgImage(undefined, 'site', 'Unique sources catalog');