Files
unique/src/pages/reviews/[...slug].astro
T

299 lines
8.1 KiB
Plaintext

---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import EditAction from '../../components/EditAction.astro';
import DiscussCta from '../../components/DiscussCta.astro';
import { sourceSlug } from '../../lib/sources';
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 amazon = review.data.amazonLink
? resolveExternalLink(review.data.amazonLink)
: 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}/` },
]);
const categoryLabel =
review.data.category.charAt(0).toUpperCase() + review.data.category.slice(1);
---
<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>
<div class="body">
<Content />
</div>
{external && (
<p class="external">
<a
href={external.href}
target="_blank"
rel={external.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
>
{linkLabel} ↗
</a>
{external.isAffiliate && <span class="aff-badge">Affiliate</span>}
</p>
)}
{amazon && (
<p class="external">
<a
href={amazon.href}
target="_blank"
rel={amazon.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
>
Also on Amazon ↗
</a>
{amazon.isAffiliate && <span class="aff-badge">Affiliate</span>}
</p>
)}
{(external?.isAffiliate || amazon?.isAffiliate) && (
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</p>
)}
<DiscussCta />
{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 (writes src/data/pick.json).`}
/>
</div>
)}
<aside class="colophon" aria-label="Colophon">
<dl>
{review.data.source && (
<>
<dt>Via</dt>
<dd>
<a href={`/sources/#${sourceSlug(review.data.source)}`}>
{review.data.source}
</a>
</dd>
</>
)}
<dt>Filed under</dt>
<dd>
<a href={`/categories/${review.data.category}/`}>{categoryLabel}</a>
</dd>
{fromFindItem && (
<>
<dt>First surfaced</dt>
<dd>
<time datetime={fromFindItem.data.date.toISOString()}>
{fmt.format(fromFindItem.data.date)}
</time>
{' '}as <a href={`/find/${fromFindItem.id}/`}>a find</a>
</dd>
</>
)}
{review.data.tags.length > 0 && (
<>
<dt>Tagged</dt>
<dd class="colophon-tags">
{review.data.tags.map((tag) => (
<a href={`/tags/${tag}/`}>#{tag}</a>
))}
</dd>
</>
)}
</dl>
</aside>
<p class="back"><a href="/">← back</a></p>
</article>
<style>
.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; }
.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>