jb-website/src/pages/blog/index.astro

279 lines
8.3 KiB
Text

---
import BaseLayout from "~/layouts/BaseLayout.astro";
import Eyebrow from "~/components/atoms/Eyebrow.astro";
import Mark from "~/components/atoms/Mark.astro";
import PostRow from "~/components/molecules/PostRow.astro";
import YearHead from "~/components/molecules/YearHead.astro";
import InkArrow from "~/components/atoms/InkArrow.astro";
import BlogSearch from "~/components/molecules/BlogSearch.astro";
import { blog } from "~/lib/urls";
import { getCollection } from "astro:content";
const posts = await getCollection("posts", ({ data }) => !data.draft);
posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const [featured, ...rest] = posts;
const dateFmtMonthYear = new Intl.DateTimeFormat("en-AU", { month: "short", year: "numeric" });
const dateFmtMonthYearLong = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric" });
// Group the archive by year.
const byYear = new Map<number, typeof rest>();
for (const post of rest) {
const year = post.data.date.getFullYear();
const bucket = byYear.get(year) ?? [];
bucket.push(post);
byYear.set(year, bucket);
}
const yearGroups = Array.from(byYear.entries()).sort(([a], [b]) => b - a);
// Build the per-post `data-search` string at build time. We concatenate the
// searchable fields, lowercase, strip a small set of markdown punctuation, and
// collapse whitespace. Body text is the raw markdown from the content
// collection entry's `.body` property — MDX expressions that survive end up as
// literal text, which is harmless for substring matching.
function buildSearchText(post: (typeof posts)[number]): string {
const parts = [
post.data.title,
post.data.tag,
post.data.standfirst,
post.data.description,
post.body ?? "",
].filter(Boolean) as string[];
return parts
.join(" ")
.toLowerCase()
.replace(/[<>#*_`\[\]()]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
const searchTextById = new Map<string, string>();
for (const post of posts) {
searchTextById.set(post.id, buildSearchText(post));
}
---
<BaseLayout
title="Writing — Josh Bairstow"
description="Short pieces on craft, the ocean, and the quiet discipline of making small software."
current="blog"
>
<header class="masthead">
<Mark kind="ringdots" width={72} top="-12px" right="-14px" rotate={-4} />
<div>
<Eyebrow>Journal · field notes</Eyebrow>
<h1 class="title">Writing<span style="color:var(--accent-deep)">.</span></h1>
<p class="count">{posts.length} entries · since 2021</p>
</div>
<p class="blurb">
Short pieces on craft, the ocean, and the quiet discipline of making small
software. Mostly written early, before the light is up.
</p>
</header>
<BlogSearch />
{
featured && (
<article class="feature" data-featured data-search={searchTextById.get(featured.id) ?? ""}>
<div class="cutframe img">
<div class="grain" />
<img class="wm" src="/assets/mark-rings.svg" alt="" />
</div>
<div>
<div class="meta">
{featured.data.tag && <span class="tag">{featured.data.tag}</span>}
<span class="date">
{dateFmtMonthYearLong.format(featured.data.date)}
{featured.data.readingMinutes && ` · ${featured.data.readingMinutes} min`}
</span>
</div>
<h2>
<a href={blog(`/posts/${featured.id}/`)}>{featured.data.title}</a>
</h2>
{featured.data.standfirst && <p>{featured.data.standfirst}</p>}
<a class="read" href={blog(`/posts/${featured.id}/`)}>
<span class="u">Read the piece</span>
<InkArrow />
</a>
</div>
</article>
)
}
<section class="archive">
<Mark kind="dots" width={54} top="-14px" right="-10px" />
<Mark kind="concentric" width={40} bottom="-12px" left="-12px" rotate={6} />
{
yearGroups.map(([year, items]) => (
<>
<YearHead year={String(year)} count={items.length} />
{items.map((post) => (
<PostRow
date={dateFmtMonthYear.format(post.data.date)}
title={post.data.title}
href={blog(`/posts/${post.id}/`)}
deck={post.data.standfirst}
tag={post.data.tag}
searchText={searchTextById.get(post.id) ?? ""}
/>
))}
</>
))
}
</section>
<p class="empty" data-empty hidden aria-live="polite">
No entries match “<span data-empty-query></span>”.
</p>
</BaseLayout>
<style>
.masthead {
position: relative;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: clamp(30px, 6vw, 80px);
align-items: end;
padding: clamp(40px, 8vh, 96px) 0 clamp(30px, 5vh, 52px);
}
.masthead .title {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(56px, 9vw, 132px);
line-height: 0.9;
letter-spacing: -0.03em;
margin: 12px 0 0;
color: var(--fg-on-dark-1);
}
:global(body:not(.dark)) .masthead .title { color: var(--fg-1); }
.masthead .blurb {
font-family: var(--font-body);
font-weight: 300;
font-size: clamp(15px, 1.4vw, 18px);
line-height: 1.62;
color: var(--fg-on-dark-2);
max-width: 38ch;
margin: 0 0 6px;
}
:global(body:not(.dark)) .masthead .blurb { color: var(--fg-2); }
.masthead .count {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--fg-on-dark-3);
margin-top: 16px;
font-feature-settings: "onum" 1;
}
.feature {
display: grid;
grid-template-columns: 0.92fr 1.08fr;
gap: clamp(28px, 5vw, 64px);
align-items: center;
padding: clamp(26px, 4.5vh, 48px) 0;
border-top: 1px solid var(--hairline-on-dark);
border-bottom: 1px solid var(--hairline-on-dark);
}
:global(body:not(.dark)) .feature { border-color: var(--hairline); }
.feature .img { aspect-ratio: 5 / 4; }
.meta {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 18px;
}
.tag {
font-family: var(--font-body);
font-weight: 500;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
white-space: nowrap;
}
:global(body:not(.dark)) .tag { color: var(--accent-deep); }
.date {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-on-dark-3);
font-feature-settings: "onum" 1;
letter-spacing: 0.04em;
}
:global(body:not(.dark)) .date { color: var(--fg-3); }
.feature h2 {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(28px, 3.4vw, 46px);
line-height: 1.04;
letter-spacing: -0.02em;
margin: 0;
color: var(--fg-on-dark-1);
text-wrap: balance;
}
:global(body:not(.dark)) .feature h2 { color: var(--fg-1); }
.feature h2 a { color: inherit; text-decoration: none; }
.feature p {
font-family: var(--font-body);
font-weight: 300;
font-size: 16.5px;
line-height: 1.62;
color: var(--fg-on-dark-2);
margin: 18px 0 0;
max-width: 48ch;
}
:global(body:not(.dark)) .feature p { color: var(--fg-2); }
.read {
display: inline-flex;
align-items: baseline;
gap: 10px;
margin-top: 24px;
font-family: var(--font-body);
font-weight: 500;
font-size: 13px;
letter-spacing: 0.04em;
text-decoration: none;
color: var(--fg-on-dark-1);
white-space: nowrap;
}
:global(body:not(.dark)) .read { color: var(--fg-1); }
.read .u {
position: relative;
padding-bottom: 3px;
}
.read .u::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
height: 1px;
width: 100%;
background: var(--accent);
}
.archive {
position: relative;
padding: clamp(34px, 6vh, 72px) 0 0;
}
@media (max-width: 800px) {
.masthead,
.feature { grid-template-columns: 1fr; }
.feature .img { order: -1; aspect-ratio: 16 / 9; }
}
/* :global so the runtime-toggled class reaches PostRow and YearHead roots,
which carry their own component scope hashes — not this page's. */
:global(.is-hidden) { display: none !important; }
.empty {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--fg-on-dark-3);
margin: clamp(28px, 5vh, 52px) 0 0;
font-feature-settings: "onum" 1;
}
:global(body:not(.dark)) .empty { color: var(--fg-3); }
</style>