Topics — the curated subscription layer: 18-value controlled vocabulary in src/lib/topics.ts, enum-enforced by the content schema on finds and reviews so unattended runs can't drift it. /topics/ index + /topics/<t>/ pages with finds first-class alongside reviews, per-topic RSS feeds that include finds (unlike category/tag feeds), "By topic" section on /feeds/, Topics footer link and llms.txt section, dev-shim coverage for the new feed URLs. daily-finds and build-review skills now classify at capture/graduation time; "giftable" is a tag, never a topic. Per-source indexes: /sources/<slug>/ lists every review and find from a source (catalog ∪ names in content, so retired sources resolve); find/review colophons link "(more from this source)"; /sources/ cards link "everything from this source →"; sub-3-item pages noindexed. Claude-Session: https://claude.ai/code/session_01WZaczDJjL3xZ3u5spsN5AL
279 lines
7.6 KiB
Plaintext
279 lines
7.6 KiB
Plaintext
---
|
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
import Masonry from '../components/Masonry.astro';
|
|
import sourcesData from '../../sources.json';
|
|
import { getAllFinds } from '../lib/find-items';
|
|
import { sourceSlug } from '../lib/sources';
|
|
import {
|
|
buildOgImage,
|
|
buildBreadcrumbs,
|
|
buildItemList,
|
|
absoluteUrl,
|
|
} from '../lib/seo';
|
|
|
|
type Source = {
|
|
name: string;
|
|
url: string;
|
|
type: string;
|
|
topics: string[];
|
|
cadence: string;
|
|
notes: string;
|
|
};
|
|
|
|
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',
|
|
};
|
|
|
|
function host(url: string) {
|
|
try {
|
|
return new URL(url).hostname.replace(/^www\./, '');
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
const allFinds = await getAllFinds();
|
|
const findsBySource = new Map<string, typeof allFinds>();
|
|
for (const f of allFinds) {
|
|
const arr = findsBySource.get(f.data.source) ?? [];
|
|
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 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');
|
|
|
|
const itemList = buildItemList(
|
|
sources.map((s) => ({ url: s.url, name: s.name })),
|
|
'Sources used by Unique'
|
|
);
|
|
|
|
const collectionPage: Record<string, unknown> = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'CollectionPage',
|
|
'@id': absoluteUrl('/sources/#collection'),
|
|
name: 'Sources — Unique',
|
|
description,
|
|
url: absoluteUrl('/sources/'),
|
|
inLanguage: 'en-US',
|
|
};
|
|
|
|
const breadcrumbs = buildBreadcrumbs([
|
|
{ name: 'Home', url: '/' },
|
|
{ name: 'Sources', url: '/sources/' },
|
|
]);
|
|
---
|
|
|
|
<BaseLayout
|
|
title="Sources"
|
|
description={description}
|
|
subheader={`Where the daily picks come from. ${sources.length} feeds, shops, and forums.`}
|
|
lastUpdated={lastUpdated}
|
|
ogImage={ogImage}
|
|
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
|
wide
|
|
>
|
|
<Masonry>
|
|
{
|
|
sources.map((source) => {
|
|
const recent = (findsBySource.get(source.name) ?? []).slice(0, 5);
|
|
return (
|
|
<article id={sourceSlug(source.name)} class="source-item">
|
|
<div class="card">
|
|
<a href={source.url} class="head-link" target="_blank" rel="noopener">
|
|
<div class="head">
|
|
<span class="name">{source.name}</span>
|
|
<span class="host">{host(source.url)}</span>
|
|
</div>
|
|
</a>
|
|
<p class="notes">{source.notes}</p>
|
|
<div class="meta">
|
|
<span class="type">{source.type}</span>
|
|
<span class="cadence">{source.cadence}</span>
|
|
</div>
|
|
<ul class="topics">
|
|
{source.topics.map((t) => (
|
|
<li class="topic">{topicLabels[t] ?? t}</li>
|
|
))}
|
|
</ul>
|
|
{recent.length > 0 && (
|
|
<div class="recent">
|
|
<p class="recent-label">Recent finds</p>
|
|
<ul class="recent-list">
|
|
{recent.map((f) => (
|
|
<li>
|
|
<a href={`/find/${f.id}/`}>{f.data.name}</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p class="all-link">
|
|
<a href={`/sources/${sourceSlug(source.name)}/`}>everything from this source →</a>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
})
|
|
}
|
|
</Masonry>
|
|
|
|
<p class="back"><a href="/">← back</a></p>
|
|
|
|
<style>
|
|
.source-item {
|
|
scroll-margin-top: 1rem;
|
|
}
|
|
.source-item:target .card {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
|
}
|
|
|
|
.card {
|
|
display: block;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--rule);
|
|
background: var(--bg);
|
|
padding: 0.95rem 1.05rem 1rem;
|
|
color: inherit;
|
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
|
}
|
|
.card:hover {
|
|
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);
|
|
}
|
|
.head-link { color: inherit; display: block; }
|
|
.head-link:hover { text-decoration: none; }
|
|
.head-link:hover .name { color: var(--accent); }
|
|
|
|
.head {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
margin-bottom: 0.45rem;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
.name {
|
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
|
font-weight: 700;
|
|
font-size: 1rem;
|
|
letter-spacing: -0.01em;
|
|
color: var(--fg);
|
|
transition: color 0.18s ease;
|
|
}
|
|
.host {
|
|
display: block;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 0.72rem;
|
|
color: var(--muted);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.notes {
|
|
margin: 0 0 0.7rem;
|
|
font-size: 0.92rem;
|
|
line-height: 1.4;
|
|
color: var(--fg);
|
|
}
|
|
|
|
.meta {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
margin-bottom: 0.5rem;
|
|
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);
|
|
}
|
|
.meta .type { color: var(--accent); }
|
|
.meta .cadence::before { content: '· '; color: var(--muted); }
|
|
|
|
.topics {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
}
|
|
.topic {
|
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
|
font-size: 0.72rem;
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: 999px;
|
|
background: var(--rule);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.all-link {
|
|
margin: 0.5rem 0 0;
|
|
font-size: 0.78rem;
|
|
}
|
|
.recent {
|
|
margin-top: 0.7rem;
|
|
padding-top: 0.6rem;
|
|
border-top: 1px dashed var(--rule);
|
|
}
|
|
.recent-label {
|
|
margin: 0 0 0.3rem;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
font-size: 0.72rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--muted);
|
|
}
|
|
.recent-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
}
|
|
.recent-list li { font-size: 0.85rem; line-height: 1.35; }
|
|
.recent-list a { color: var(--fg); }
|
|
.recent-list a:hover { color: var(--accent); }
|
|
|
|
.back {
|
|
margin-top: 2rem;
|
|
font-size: 0.9rem;
|
|
color: var(--muted);
|
|
}
|
|
</style>
|
|
</BaseLayout>
|