initial: four-collection content pipeline for unique.rzen.dev
A small Astro + MDX blog for delightful, daily-use finds. The architecture separates capture from publication and supports reuse: - find/ captured items, hidden from indexes - finds/ editorial superposts referencing find slugs in a grid - reviews/ full-length writeups; can graduate from a find via fromFind - posts/ regular essays Three project-local skills under .claude/skills/ drive the pipeline: daily-finds (capture-only sweep across sources.json), build-finds (draft a thematic superpost), build-review (graduate a find). sources.json carries 47 curated feeds with per-source fetch strategy (webfetch / curl / skip) so Cloudflare-blocked sites and Reddit JSON endpoints are handled deterministically. /sources renders a 3-column card stream with a "Recent finds" cross-reference per source. Seeds: 17 reviews and one superpost referencing 5 finds.
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import sourcesData from '../../sources.json';
|
||||
import { getAllFinds } from '../lib/find-items';
|
||||
|
||||
type Source = {
|
||||
name: string;
|
||||
url: string;
|
||||
type: string;
|
||||
topics: string[];
|
||||
cadence: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const sources: Source[] = [...sourcesData.sources].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
const topicLabels: Record<string, string> = {
|
||||
'macos-apps': 'macOS apps',
|
||||
'tools': 'tools',
|
||||
'gifts': 'gifts',
|
||||
'clothing': 'clothing',
|
||||
'travel': 'travel',
|
||||
'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);
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title="Sources" wide>
|
||||
<div class="intro">
|
||||
<h1>Sources</h1>
|
||||
<p>Where the daily picks come from. {sources.length} feeds, shops, and forums.</p>
|
||||
</div>
|
||||
|
||||
<ul class="grid">
|
||||
{
|
||||
sources.map((source) => {
|
||||
const recent = (findsBySource.get(source.name) ?? []).slice(0, 5);
|
||||
return (
|
||||
<li>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.intro h1 { margin: 0 0 0.25rem; font-size: 1.85rem; }
|
||||
.intro p { margin: 0; color: var(--muted); }
|
||||
|
||||
.grid {
|
||||
column-count: 3;
|
||||
column-gap: 1rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@media (max-width: 880px) { .grid { column-count: 2; } }
|
||||
@media (max-width: 520px) { .grid { column-count: 1; } }
|
||||
|
||||
.grid > li {
|
||||
break-inside: avoid;
|
||||
display: block;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.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 {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user