Captured 27 finds from the daily sweep across 15 sources (Daring Fireball, HN Show HN, Kottke, Wallpaper, Outbound, Uncommon Goods, Kikkerland, BIFL, Quote Investigator, Marginalian, r/dadjokes, icanhazdadjoke, r/shortcuts, Recomendo, Messy Nessy) plus one user- suggested find (valchoose-barf-bags) and 11 additional reader- suggestion finds (appcleaner, blip, cleanmykeyboard, clocker, daisydisk, ia-writer, launchos, macfolio, mos, vivid, wallspace). New reviews: monitorcontrol (graduated from today's reader suggestion, category interface) and blip (graduated from today's reader suggestion, category indie). Both registered in hero.ts. Site tooling: new dev-only POST /api/set-pick middleware that rewrites HERO_SLUG in src/pages/index.astro for the Make today's pick action. EditConfirm reworked to support a non-copy "set-pick" mode; EditAction takes action and slug props; the reviews template switches the toolbar button over to the new wiring. Log + candidate-sources updated. Two new candidates: Routinehub.co and Moss & Fog.
320 lines
8.7 KiB
Plaintext
320 lines
8.7 KiB
Plaintext
---
|
|
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,
|
|
AFFILIATE_DISCLOSURE,
|
|
} from '../../lib/affiliate';
|
|
import {
|
|
buildOgImage,
|
|
buildBreadcrumbs,
|
|
reviewedItemType,
|
|
PERSON_AUTHOR,
|
|
absoluteUrl,
|
|
} from '../../lib/seo';
|
|
|
|
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',
|
|
timeZone: 'UTC',
|
|
});
|
|
|
|
const linkLabel =
|
|
review.data.link &&
|
|
(review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, ''));
|
|
|
|
const external = review.data.link ? resolveExternalLink(review.data.link) : undefined;
|
|
|
|
const fromFindItem = review.data.fromFind
|
|
? (await getCollection('find')).find((f) => f.id === review.data.fromFind)
|
|
: undefined;
|
|
|
|
const description =
|
|
review.data.description ?? review.data.subtitle ?? `A review of ${review.data.name}.`;
|
|
|
|
const ogImage = buildOgImage(review.id, 'reviews', `${review.data.name} hero image`);
|
|
|
|
const itemReviewed: Record<string, unknown> = {
|
|
'@type': reviewedItemType(review.data.category),
|
|
name: review.data.name,
|
|
};
|
|
if (review.data.link) itemReviewed.url = review.data.link;
|
|
if (ogImage) itemReviewed.image = ogImage.url;
|
|
|
|
const reviewSchema: Record<string, unknown> = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Review',
|
|
'@id': absoluteUrl(`/reviews/${review.id}/#review`),
|
|
url: absoluteUrl(`/reviews/${review.id}/`),
|
|
name: review.data.name,
|
|
headline: review.data.subtitle || review.data.name,
|
|
description,
|
|
datePublished: review.data.date.toISOString(),
|
|
author: PERSON_AUTHOR,
|
|
inLanguage: 'en-US',
|
|
itemReviewed,
|
|
};
|
|
|
|
const breadcrumbs = buildBreadcrumbs([
|
|
{ name: 'Home', url: '/' },
|
|
{ name: review.data.category, url: `/categories/${review.data.category}/` },
|
|
{ name: review.data.name, url: `/reviews/${review.id}/` },
|
|
]);
|
|
---
|
|
|
|
<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}
|
|
articleTags={review.data.tags}
|
|
jsonLd={[reviewSchema, breadcrumbs]}
|
|
>
|
|
<article>
|
|
<header class="review-header">
|
|
{review.data.source && (
|
|
<p class="source-line"><SourceBadge source={review.data.source} /></p>
|
|
)}
|
|
<p class="meta">
|
|
<a href={`/categories/${review.data.category}/`} class="category">
|
|
{review.data.category}
|
|
</a>
|
|
{
|
|
external && (
|
|
<>
|
|
<span class="dot">·</span>
|
|
<a
|
|
href={external.href}
|
|
target="_blank"
|
|
rel={external.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
|
|
>
|
|
{linkLabel} ↗
|
|
</a>
|
|
{external.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
|
</>
|
|
)
|
|
}
|
|
</p>
|
|
{external?.isAffiliate && (
|
|
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</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>
|
|
|
|
{import.meta.env.DEV && (
|
|
<div class="edit-toolbar">
|
|
<EditAction
|
|
label="Make today's pick"
|
|
action="set-pick"
|
|
slug={review.id}
|
|
confirm={`This will set ${review.data.name} as the homepage hero (rewrites HERO_SLUG in src/pages/index.astro).`}
|
|
/>
|
|
</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: 1.5rem;
|
|
}
|
|
.meta {
|
|
margin: 0.5rem 0 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); }
|
|
.aff-badge {
|
|
display: inline-block;
|
|
margin-left: 0.45rem;
|
|
padding: 0.12rem 0.45rem;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
background: var(--rule);
|
|
border-radius: 999px;
|
|
vertical-align: middle;
|
|
}
|
|
.aff-disclosure {
|
|
margin: 0.6rem 0 0;
|
|
font-size: 0.78rem;
|
|
color: var(--muted);
|
|
font-style: italic;
|
|
}
|
|
.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; }
|
|
.edit-toolbar {
|
|
margin: 2rem 0 0;
|
|
padding-top: 1rem;
|
|
border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--rule));
|
|
}
|
|
</style>
|
|
</BaseLayout>
|