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,30 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import EntryList from '../../components/EntryList.astro';
|
||||
import { getAllEntries } from '../../lib/entries';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = await getAllEntries();
|
||||
const categories = [...new Set(entries.map((e) => e.category))];
|
||||
return categories.map((category) => ({
|
||||
params: { category },
|
||||
props: {
|
||||
category,
|
||||
entries: entries.filter((e) => e.category === category),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const { category, entries } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={`Category: ${category}`}>
|
||||
<h1>Category: {category}</h1>
|
||||
<EntryList entries={entries} />
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
|
||||
<style>
|
||||
h1 { margin-bottom: 1rem; }
|
||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import SourceBadge from '../../components/SourceBadge.astro';
|
||||
import Backlinks from '../../components/Backlinks.astro';
|
||||
import { findsReferencing } from '../../lib/backlinks';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const items = await getCollection('find');
|
||||
return items.map((item) => ({
|
||||
params: { slug: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
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 linkLabel =
|
||||
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={item.data.name}
|
||||
description={item.data.description ?? item.data.subtitle ?? item.data.name}
|
||||
>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<p class="external">
|
||||
<a href={item.data.link} target="_blank" rel="noopener noreferrer">
|
||||
{linkLabel} ↗
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{
|
||||
item.data.promotedTo && (
|
||||
<p class="promoted">
|
||||
→ Read the full review:
|
||||
<a href={`/reviews/${item.data.promotedTo}/`}>{item.data.promotedTo}</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<Backlinks posts={featuringPosts} />
|
||||
|
||||
{
|
||||
item.data.tags.length > 0 && (
|
||||
<footer class="tags">
|
||||
{item.data.tags.map((tag) => (
|
||||
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
</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;
|
||||
}
|
||||
.body :global(p) { margin: 0 0 1rem; }
|
||||
.external {
|
||||
margin: 1.5rem 0 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.external a {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.external a:hover { text-decoration: none; opacity: 0.9; }
|
||||
.promoted {
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 0.85rem 1rem;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.promoted a { font-weight: 600; }
|
||||
.tags {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tag { font-size: 0.85rem; color: var(--muted); }
|
||||
.tag:hover { color: var(--accent); }
|
||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import FindsGrid from '../../components/FindsGrid.astro';
|
||||
import { resolveFinds } from '../../lib/find-items';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const finds = await getCollection('finds');
|
||||
return finds.map((post) => ({
|
||||
params: { slug: post.id },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={post.data.title}
|
||||
description={post.data.description ?? post.data.blurb}
|
||||
wide
|
||||
>
|
||||
<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} />
|
||||
|
||||
{
|
||||
post.data.tags.length > 0 && (
|
||||
<footer class="tags">
|
||||
{post.data.tags.map((tag) => (
|
||||
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
</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;
|
||||
}
|
||||
.body :global(p) { margin: 0 0 1rem; }
|
||||
.body :global(blockquote) {
|
||||
margin: 1.25rem 0;
|
||||
padding: 0.15rem 0 0.15rem 1rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--fg);
|
||||
font-style: normal;
|
||||
}
|
||||
.body :global(blockquote p) { margin: 0; }
|
||||
.tags {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tag { font-size: 0.85rem; color: var(--muted); }
|
||||
.tag:hover { color: var(--accent); }
|
||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { heroes } from '../lib/hero';
|
||||
|
||||
const reviews = (await getCollection('reviews')).sort(
|
||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||
);
|
||||
|
||||
const tiles = reviews.map((review) => ({
|
||||
href: `/reviews/${review.id}/`,
|
||||
name: review.data.name,
|
||||
subtitle: review.data.subtitle,
|
||||
hero: heroes[review.id],
|
||||
}));
|
||||
---
|
||||
|
||||
<BaseLayout title="Grid" wide>
|
||||
<div class="intro">
|
||||
<h1>Grid view</h1>
|
||||
<p><a href="/">← list view</a></p>
|
||||
</div>
|
||||
|
||||
<ul class="grid">
|
||||
{
|
||||
tiles.map((tile) => (
|
||||
<li>
|
||||
<a href={tile.href} class:list={['tile', { 'text-only': !tile.hero }]}>
|
||||
{tile.hero?.kind === 'image' && (
|
||||
<img
|
||||
class="media"
|
||||
src={tile.hero.src.src}
|
||||
width={tile.hero.src.width}
|
||||
height={tile.hero.src.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{tile.hero?.kind === 'video' && (
|
||||
<video
|
||||
class="media"
|
||||
src={tile.hero.src}
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
/>
|
||||
)}
|
||||
<div class="text">
|
||||
<span class="name">{tile.name}</span>
|
||||
<span class="subtitle">{tile.subtitle}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.intro h1 { margin: 0; font-size: 1.5rem; }
|
||||
.intro p { margin: 0; font-size: 0.9rem; 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;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.tile:hover .name { color: var(--accent); }
|
||||
|
||||
.media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: var(--rule);
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tile.text-only {
|
||||
background: linear-gradient(155deg, var(--rule), transparent);
|
||||
}
|
||||
.tile.text-only .text {
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
.tile.text-only .name {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.tile.text-only .subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import EntryList from '../components/EntryList.astro';
|
||||
import { getAllEntries } from '../lib/entries';
|
||||
|
||||
const entries = (await getAllEntries()).slice(0, 12);
|
||||
---
|
||||
|
||||
<BaseLayout title="Unique">
|
||||
<EntryList entries={entries} />
|
||||
<p class="view-toggle">
|
||||
<a href="/sources/">sources</a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<a href="/grid/">grid view →</a>
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.view-toggle {
|
||||
margin-top: 1.75rem;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.view-toggle a { color: var(--muted); }
|
||||
.view-toggle a:hover { color: var(--accent); }
|
||||
.view-toggle span { margin: 0 0.4rem; color: var(--muted); }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.id },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
---
|
||||
|
||||
<BaseLayout title={post.data.title} description={post.data.description}>
|
||||
<article>
|
||||
<header class="post-header">
|
||||
<h1>{post.data.title}</h1>
|
||||
<p class="meta">
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
{fmt.format(post.data.date)}
|
||||
</time>
|
||||
<span class="dot">·</span>
|
||||
<a href={`/categories/${post.data.category}/`} class="category">
|
||||
{post.data.category}
|
||||
</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
{
|
||||
post.data.tags.length > 0 && (
|
||||
<footer class="tags">
|
||||
{post.data.tags.map((tag) => (
|
||||
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.post-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
h1 { margin: 0 0 0.5rem; }
|
||||
.meta { margin: 0; color: var(--muted); font-size: 0.9rem; }
|
||||
.meta .dot { margin: 0 0.4rem; }
|
||||
.meta .category { color: var(--muted); }
|
||||
.meta .category:hover { color: var(--accent); }
|
||||
.body :global(p) { margin: 0 0 1rem; }
|
||||
.body :global(h2) { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
|
||||
.body :global(h3) { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
||||
.body :global(ul), .body :global(ol) { padding-left: 1.4rem; }
|
||||
.body :global(li) { margin: 0.25rem 0; }
|
||||
.body :global(blockquote) {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
font-style: normal;
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 0.15rem 0 0.15rem 1rem;
|
||||
color: var(--fg);
|
||||
margin: 0 0 1.75rem;
|
||||
}
|
||||
.body :global(blockquote p) { margin: 0; }
|
||||
.tags {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tag { font-size: 0.85rem; color: var(--muted); }
|
||||
.tag:hover { color: var(--accent); }
|
||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import SourceBadge from '../../components/SourceBadge.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const reviews = await getCollection('reviews');
|
||||
return reviews.map((review) => ({
|
||||
params: { slug: review.id },
|
||||
props: { review },
|
||||
}));
|
||||
}
|
||||
|
||||
const { review } = Astro.props;
|
||||
const { Content } = await render(review);
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const linkLabel =
|
||||
review.data.link &&
|
||||
(review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, ''));
|
||||
|
||||
const fromFindItem = review.data.fromFind
|
||||
? (await getCollection('find')).find((f) => f.id === review.data.fromFind)
|
||||
: undefined;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={review.data.name}
|
||||
description={review.data.description ?? review.data.subtitle}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
{
|
||||
review.data.link && (
|
||||
<>
|
||||
<span class="dot">·</span>
|
||||
<a href={review.data.link} target="_blank" rel="noopener noreferrer">
|
||||
{linkLabel} ↗
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{fromFindItem && (
|
||||
<p class="from-find">
|
||||
← First surfaced on{' '}
|
||||
<time datetime={fromFindItem.data.date.toISOString()}>
|
||||
{fmt.format(fromFindItem.data.date)}
|
||||
</time>{' '}
|
||||
as <a href={`/find/${fromFindItem.id}/`}>a find</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div class="body">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
{
|
||||
review.data.tags.length > 0 && (
|
||||
<footer class="tags">
|
||||
{review.data.tags.map((tag) => (
|
||||
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.source-line { margin: 0 0 0.5rem; }
|
||||
.from-find {
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: var(--rule);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.meta {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.meta .dot { margin: 0 0.4rem; }
|
||||
.meta .category { color: var(--muted); }
|
||||
.meta .category:hover { color: var(--accent); }
|
||||
.body :global(p) { margin: 0 0 1rem; }
|
||||
.body :global(h2) { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
|
||||
.body :global(h3) { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
||||
.body :global(ul), .body :global(ol) { padding-left: 1.4rem; }
|
||||
.body :global(li) { margin: 0.25rem 0; }
|
||||
.body :global(img),
|
||||
.body :global(video) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 1.5rem auto 0.5rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.body :global(p:has(> em:only-child)) {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.body :global(.carousel) {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.body :global(.carousel > figure) {
|
||||
flex: 0 0 calc(100% - 2.5rem);
|
||||
scroll-snap-align: start;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.body :global(.carousel img),
|
||||
.body :global(.carousel video) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
.body :global(.carousel figcaption) {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.body :global(.carousel)::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
.body :global(.carousel)::-webkit-scrollbar-track {
|
||||
background: var(--rule);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.body :global(.carousel)::-webkit-scrollbar-thumb {
|
||||
background: var(--muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.body :global(.carousel-nav) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 1.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.body :global(.carousel-nav a) {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
background: var(--rule);
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
min-width: 1.6rem;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.body :global(.carousel-nav a:hover) {
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
}
|
||||
.body :global(.carousel:has(figure:target)) {
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
.body :global(blockquote) {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
font-style: normal;
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 0.15rem 0 0.15rem 1rem;
|
||||
color: var(--fg);
|
||||
margin: 0 0 1.75rem;
|
||||
}
|
||||
.body :global(blockquote p) { margin: 0; }
|
||||
.tags {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tag { font-size: 0.85rem; color: var(--muted); }
|
||||
.tag:hover { color: var(--accent); }
|
||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import EntryList from '../../components/EntryList.astro';
|
||||
import { getAllEntries } from '../../lib/entries';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = await getAllEntries();
|
||||
const tags = [...new Set(entries.flatMap((e) => e.tags))];
|
||||
return tags.map((tag) => ({
|
||||
params: { tag },
|
||||
props: {
|
||||
tag,
|
||||
entries: entries.filter((e) => e.tags.includes(tag)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const { tag, entries } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={`Tag: ${tag}`}>
|
||||
<h1>#{tag}</h1>
|
||||
<EntryList entries={entries} />
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
|
||||
<style>
|
||||
h1 { margin-bottom: 1rem; }
|
||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user