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:
@@ -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