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:
@@ -11,6 +11,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -48,7 +48,7 @@ const lastUpdated = reviews[0]?.data.date;
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
preload="none"
|
||||
aria-label={tile.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,7 @@ const itemList = buildItemList(
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
preload="none"
|
||||
aria-label={tile.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user