first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
---
import Base from "../layouts/Base.astro";
---
<Base title="Page not found">
<div class="not-found">
<h1>404</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go back home</a>
</div>
</Base>
<style>
.not-found {
text-align: center;
padding: var(--spacing-24) var(--spacing-6);
}
.not-found h1 {
font-size: var(--font-size-5xl);
margin-bottom: var(--spacing-2);
color: var(--color-border);
}
.not-found p {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.not-found a {
color: var(--color-text);
}
</style>

View File

@@ -0,0 +1,117 @@
---
import { getTerm, getEmDashCollection, getEntryTerms } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const { slug } = Astro.params;
const term = slug ? await getTerm("category", slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const { entries: posts } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
// Fetch tags for display on each post card
const filteredPosts = await Promise.all(
posts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
return { post, tags };
})
);
---
<Base title={`${term.label} posts`} description={`All posts in ${term.label}`}>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Category</span>
<h1 class="archive-title">{term.label}</h1>
<p class="archive-count">
{filteredPosts.length}
{filteredPosts.length === 1 ? "post" : "posts"}
</p>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts in this category yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
/>
))}
</div>
)
}
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.archive-count {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,448 @@
---
import { getEmDashCollection, getEntryTerms } from "emdash";
import { Image } from "emdash/ui";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
Astro.cache.set(cacheHint);
const sortedPosts = posts.toSorted((a, b) => {
const dateA = a.data.publishedAt?.getTime() ?? 0;
const dateB = b.data.publishedAt?.getTime() ?? 0;
return dateB - dateA;
});
// Find the first post with a featured image for the hero
const featuredPost = sortedPosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? sortedPosts.indexOf(featuredPost) : -1;
// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = sortedPosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
// Total posts shown = featured (if any) + grid posts
const totalShown = (featuredPost ? 1 : 0) + gridPosts.length;
const hasMorePosts = sortedPosts.length > totalShown;
// Fetch tags for featured post (bylines are already hydrated by getEmDashCollection)
let featuredTags: Array<{ slug: string; label: string }> = [];
const featuredBylines = featuredPost?.data.bylines ?? [];
if (featuredPost) {
const tags = await getEntryTerms("posts", featuredPost.data.id, "tag");
featuredTags = tags.map((t) => ({ slug: t.slug, label: t.label }));
}
// Fetch tags for grid posts (bylines are already hydrated by getEmDashCollection)
const gridPostsWithTags = await Promise.all(
gridPosts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
const bylines = post.data.bylines ?? [];
return {
post,
tags: tags.map((t) => ({ slug: t.slug, label: t.label })),
bylines,
};
})
);
// Format date helper
function formatDate(date: Date | null | undefined) {
if (!date) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<Base title="My Blog" description="Welcome to my blog">
{
posts.length === 0 ? (
<section class="empty-state">
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new" class="btn">
Create a post
</a>
</section>
) : (
<div class="home-content">
{/* Featured Post - Side by side */}
{featuredPost && (
<section class="featured-section">
<div class="featured-grid">
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
<div class="featured-image">
<Image image={featuredPost.data.featured_image} />
</div>
</a>
<div class="featured-content">
<div class="featured-meta">
{featuredBylines.length > 0 && (
<>
<div class="featured-bylines">
{featuredBylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="featured-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="featured-byline-avatar"
/>
)}
<span class="featured-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{featuredBylines.length > 2 && (
<span class="byline-more">
+{featuredBylines.length - 2}
</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{formatDate(featuredPost.data.publishedAt) && (
<time>{formatDate(featuredPost.data.publishedAt)}</time>
)}
<span class="meta-dot" />
<span>
{getReadingTime(featuredPost.data.content)} min read
</span>
</div>
<a
href={`/posts/${featuredPost.id}`}
class="featured-title-link"
>
<h1 class="featured-title">{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt">{featuredPost.data.excerpt}</p>
)}
{featuredTags.length > 0 && (
<div class="featured-tags">
{featuredTags.map((tag) => (
<a href={`/tag/${tag.slug}`} class="featured-tag">
{tag.label}
</a>
))}
</div>
)}
</div>
</div>
</section>
)}
{/* Latest Posts */}
{gridPostsWithTags.length > 0 && (
<section class="posts-section">
<header class="section-header">
<h2 class="section-title">Latest</h2>
{hasMorePosts && (
<a href="/posts" class="section-link">
View all
</a>
)}
</header>
<div class="posts-grid">
{gridPostsWithTags.map(({ post, tags, bylines }) => (
<PostCard
title={post.data.title ?? "Untitled"}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags}
bylines={bylines}
/>
))}
</div>
</section>
)}
</div>
)
}
</Base>
<style>
.home-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6);
}
/* Featured Section - Side by side */
.featured-section {
margin-bottom: var(--spacing-16);
}
.featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
align-items: center;
}
.featured-image-link {
grid-column: 1 / 3;
display: block;
/* Extend to viewport edge, but cap at -6rem minimum extension */
margin-left: min(
-6rem,
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
);
}
.featured-image {
overflow: hidden;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
background: var(--color-surface);
}
.featured-image img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
transition: transform 0.4s ease;
}
.featured-image-link:hover .featured-image img,
.featured-grid:has(.featured-title-link:hover) .featured-image img {
transform: scale(1.02);
}
.featured-content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.featured-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Featured bylines */
.featured-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.featured-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.featured-byline-avatar {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: 50%;
object-fit: cover;
}
.featured-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.featured-title-link {
text-decoration: none;
color: inherit;
}
.featured-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: 700;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.featured-title-link:hover .featured-title,
.featured-grid:has(.featured-image-link:hover) .featured-title {
color: var(--color-accent);
}
.featured-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.featured-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.featured-tag {
display: inline-block;
padding: var(--spacing-1) var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.featured-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Section header */
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-subtle);
}
.section-title {
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-accent-hover);
}
/* Posts Grid */
.posts-section {
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
text-align: center;
padding: var(--spacing-20) var(--spacing-6);
max-width: 400px;
margin: 0 auto;
}
.empty-state h2 {
font-size: var(--font-size-2xl);
font-weight: 600;
}
.empty-state p {
color: var(--color-muted);
}
.btn {
display: inline-block;
margin-top: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--color-accent);
color: var(--color-on-accent);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
transition: background var(--transition-fast);
}
.btn:hover {
background: var(--color-accent-hover);
}
/* Responsive */
@media (max-width: 900px) {
.home-content {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.featured-image-link {
margin-left: 0;
}
.featured-grid {
grid-template-columns: 1fr;
gap: var(--spacing-6);
}
.featured-image {
border-radius: var(--radius-lg);
}
.featured-image img {
aspect-ratio: 16 / 9;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8) var(--spacing-6);
}
}
@media (max-width: 600px) {
.featured-title {
font-size: var(--font-size-2xl);
}
.posts-grid {
grid-template-columns: 1fr;
gap: var(--spacing-8);
}
}
</style>

View File

@@ -0,0 +1,108 @@
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
if (!slug) {
return Astro.redirect("/404");
}
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);
if (!page) {
return Astro.redirect("/404");
}
Astro.cache.set(cacheHint);
---
<Base
title={page.data.title}
content={{ collection: "pages", id: page.data.id, slug }}
>
<article class="page-article">
<header class="page-header">
<h1 class="page-title" {...page.edit.title}>{page.data.title}</h1>
</header>
<div class="page-content">
<PortableText value={page.data.content} />
</div>
</article>
</Base>
<style>
.page-article {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: 700;
line-height: var(--leading-tight);
}
.page-content :global(p) {
margin-bottom: 1.5em;
}
.page-content :global(h2) {
font-size: var(--font-size-2xl);
margin-top: 2em;
margin-bottom: 0.75em;
}
.page-content :global(h3) {
font-size: var(--font-size-xl);
margin-top: 1.75em;
margin-bottom: 0.5em;
}
.page-content :global(blockquote) {
margin: 1.5em 0;
padding-left: var(--spacing-6);
border-left: 3px solid var(--color-border);
color: var(--color-muted);
}
.page-content :global(pre) {
margin: 1.5em 0;
padding: var(--spacing-4);
background: var(--color-surface);
border-radius: var(--radius);
overflow-x: auto;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
.page-content :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-surface);
padding: 0.15em 0.3em;
border-radius: var(--radius);
}
.page-content :global(pre code) {
background: none;
padding: 0;
}
.page-content :global(ul),
.page-content :global(ol) {
margin-bottom: 1.5em;
padding-left: var(--spacing-5);
}
.page-content :global(li) {
margin-bottom: 0.5em;
}
</style>

View File

@@ -0,0 +1,958 @@
---
import {
getEmDashEntry,
getEmDashCollection,
getEntryTerms,
getSeoMeta,
} from "emdash";
import {
Image,
PortableText,
Comments,
CommentForm,
WidgetArea,
} from "emdash/ui";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const { slug } = Astro.params;
if (!slug) {
return Astro.redirect("/404");
}
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
Astro.cache.set(cacheHint);
// Get featured image URL for OG fallback
// The image may have src (external) or meta.storageKey (local)
function getImageUrl(img: unknown): string | undefined {
if (!img || typeof img !== "object") return undefined;
const image = img as Record<string, unknown>;
// Check for direct src
if (typeof image.src === "string" && image.src) {
return image.src.startsWith("http")
? image.src
: `${Astro.url.origin}${image.src}`;
}
// Build from storageKey for local images
const meta = image.meta as Record<string, unknown> | undefined;
const storageKey =
(typeof meta?.storageKey === "string" ? meta.storageKey : undefined) ||
(typeof image.id === "string" ? image.id : undefined);
if (storageKey) {
return `${Astro.url.origin}/_emdash/api/media/file/${storageKey}`;
}
return undefined;
}
const featuredImageUrl = getImageUrl(post.data.featured_image);
// Generate SEO meta from content
const seo = getSeoMeta(post, {
siteTitle: "My Blog",
siteUrl: Astro.url.origin,
path: `/posts/${slug}`,
defaultOgImage: featuredImageUrl,
});
// Get tags for this post
// Note: post.id is the slug, post.data.id is the database ULID
const tags = await getEntryTerms("posts", post.data.id, "tag");
// Bylines are already hydrated by getEmDashEntry
const bylines = post.data.bylines ?? [];
// Get reading time
const readingTime = getReadingTime(post.data.content);
// Get other posts for "More posts" section, with their tags
// Fetch a few extra in case the current post is among them
const { entries: recentPosts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 4,
});
const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3);
// Fetch tags for related posts (bylines are already hydrated by getEmDashCollection)
const otherPostsWithTags = await Promise.all(
otherPosts.map(async (p) => {
const postTags = await getEntryTerms("posts", p.data.id, "tag");
const postBylines = p.data.bylines ?? [];
return { post: p, tags: postTags, bylines: postBylines };
})
);
const publishDate =
post.data.publishedAt?.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}) ?? null;
---
<Base
title={seo.title}
description={seo.description}
image={seo.ogImage}
canonical={seo.canonical}
robots={seo.robots}
type="article"
publishedTime={post.data.publishedAt?.toISOString() ?? null}
modifiedTime={post.data.updatedAt.toISOString()}
content={{ collection: "posts", id: post.data.id, slug }}
>
<article class="article">
{/* Hero: Full-width featured image */}
{
post.data.featured_image && (
<div class="article-hero" {...post.edit.featured_image}>
<Image image={post.data.featured_image} />
</div>
)
}
{/* Three-column layout */}
<div class="article-grid">
{/* Left gutter: Meta information */}
<aside class="article-meta-col">
<div class="meta-sticky">
{
bylines.length > 0 && (
<div class="meta-block byline-block">
<span class="meta-label">
{bylines.length === 1 ? "Author" : "Authors"}
</span>
<div class="bylines">
{bylines.map((credit) => (
<div class="byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="byline-avatar"
/>
)}
<div class="byline-info">
<span class="byline-name">
{credit.byline.displayName}
</span>
{credit.roleLabel && (
<span class="byline-role">{credit.roleLabel}</span>
)}
</div>
</div>
))}
</div>
</div>
)
}
{
publishDate && (
<div class="meta-block">
<span class="meta-label">Published</span>
<time class="meta-value">{publishDate}</time>
</div>
)
}
<div class="meta-block">
<span class="meta-label">Reading time</span>
<span class="meta-value">{readingTime} min</span>
</div>
{
tags.length > 0 && (
<div class="meta-block">
<span class="meta-label">Tags</span>
<div class="meta-tags">
{tags.map((t) => (
<a href={`/tag/${t.slug}`} class="meta-tag">
{t.label}
</a>
))}
</div>
</div>
)
}
</div>
</aside>
{/* Main content */}
<div class="article-main">
<header class="article-header">
<div class="article-meta">
{
bylines.length > 0 && (
<>
<span class="article-meta-byline">
{bylines.map((credit, i) => (
<>
{i > 0 && ", "}
{credit.byline.displayName}
</>
))}
</span>
<span class="meta-dot" />
</>
)
}
{
publishDate && (
<>
<time>{publishDate}</time>
<span class="meta-dot" />
</>
)
}
<span>{readingTime} min read</span>
</div>
<h1 class="article-title" {...post.edit.title}>{post.data.title}</h1>
{
post.data.excerpt && (
<p class="article-excerpt">{post.data.excerpt}</p>
)
}
</header>
<div class="article-content">
<PortableText value={post.data.content} />
</div>
<div class="article-comments">
<Comments collection="posts" contentId={post.data.id} threaded />
<CommentForm collection="posts" contentId={post.data.id} />
</div>
</div>
{/* Right gutter: TOC + Sidebar widgets */}
<aside class="article-sidebar">
<div class="sidebar-sticky">
<nav class="toc" aria-label="Table of contents">
<h4 class="toc-title">On this page</h4>
<div class="toc-content" id="toc-content">
<!-- Populated by JS -->
</div>
</nav>
<div class="sidebar-widgets">
<WidgetArea name="sidebar" />
</div>
</div>
</aside>
</div>
</article>
{
otherPostsWithTags.length > 0 && (
<section class="more-posts">
<div class="more-inner">
<h2 class="more-title">Continue reading</h2>
<div class="more-grid">
{otherPostsWithTags.map(
({ post: p, tags: postTags, bylines: postBylines }) => (
<PostCard
title={p.data.title}
excerpt={p.data.excerpt}
featuredImage={p.data.featured_image}
href={`/posts/${p.id}`}
date={p.data.publishedAt ?? undefined}
readingTime={getReadingTime(p.data.content)}
tags={postTags.map((t) => ({ slug: t.slug, label: t.label }))}
bylines={postBylines}
/>
)
)}
</div>
</div>
</section>
)
}
<script>
// Build table of contents from h2/h3 headings
function buildToc() {
const content = document.querySelector(".article-content");
const tocContainer = document.getElementById("toc-content");
if (!content || !tocContainer) return;
const headings = content.querySelectorAll("h2, h3");
if (headings.length === 0) {
// Hide TOC if no headings
const toc = document.querySelector(".toc") as HTMLElement | null;
if (toc) toc.style.display = "none";
return;
}
const list = document.createElement("ul");
list.className = "toc-list";
headings.forEach((heading, index) => {
// Add ID if missing
if (!heading.id) {
heading.id = `heading-${index}`;
}
const li = document.createElement("li");
li.className =
heading.tagName === "H3" ? "toc-item toc-item--nested" : "toc-item";
const link = document.createElement("a");
link.href = `#${heading.id}`;
link.className = "toc-link";
link.textContent = heading.textContent;
li.appendChild(link);
list.appendChild(li);
});
tocContainer.appendChild(list);
// Highlight current section on scroll
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = entry.target.id;
const link = tocContainer.querySelector(`a[href="#${id}"]`);
if (link) {
if (entry.isIntersecting) {
tocContainer
.querySelectorAll(".toc-link")
.forEach((l) => l.classList.remove("active"));
link.classList.add("active");
}
}
});
},
{ rootMargin: "-80px 0px -80% 0px" }
);
headings.forEach((heading) => observer.observe(heading));
}
buildToc();
</script>
</Base>
<style>
/* Article container */
.article {
max-width: var(--wide-width);
margin: 0 auto;
}
/* Hero image - full width within container */
.article-hero {
margin: var(--spacing-16) var(--spacing-6);
border-radius: var(--radius-lg);
overflow: hidden;
}
.article-hero img {
width: 100%;
height: auto;
max-height: 500px;
object-fit: cover;
}
/* Three-column grid */
.article-grid {
display: grid;
grid-template-columns:
var(--meta-col-width) minmax(0, var(--content-width))
var(--gutter-width);
gap: var(--spacing-10);
justify-content: center;
padding: 0 var(--spacing-6);
margin: var(--spacing-16) 0;
}
/* Left column: Meta */
.article-meta-col {
display: block;
}
.meta-sticky {
position: sticky;
top: calc(var(--nav-height) + var(--spacing-8));
}
.meta-block {
margin-bottom: var(--spacing-6);
}
.meta-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-1);
}
.meta-value {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.meta-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.meta-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.meta-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Byline styles */
.bylines {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.byline {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.byline-avatar {
width: var(--avatar-size-lg);
height: var(--avatar-size-lg);
border-radius: 50%;
object-fit: cover;
}
.byline-info {
display: flex;
flex-direction: column;
}
.byline-name {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
}
.byline-role {
font-size: var(--font-size-xs);
color: var(--color-muted);
}
/* Main content column */
.article-main {
min-width: 0;
}
.article-header {
margin-bottom: var(--spacing-10);
}
.article-header .article-meta {
display: none;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-4);
}
.article-meta-byline {
font-weight: 500;
color: var(--color-text-secondary);
}
.article-title {
font-size: clamp(2rem, 5vw, var(--font-size-5xl));
font-weight: 700;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-4);
}
.article-excerpt {
font-size: var(--font-size-xl);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
/* Article content typography */
.article-content {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
}
.article-content :global(p) {
margin-bottom: 1.5em;
}
.article-content :global(h2) {
font-size: var(--font-size-2xl);
margin-top: 2.5em;
margin-bottom: 0.75em;
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
}
.article-content :global(h3) {
font-size: var(--font-size-xl);
margin-top: 2em;
margin-bottom: 0.5em;
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
}
.article-content :global(blockquote) {
margin: 2em 0;
padding: var(--spacing-4) var(--spacing-6);
border-left: 3px solid var(--color-border);
background: var(--color-bg-subtle);
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--color-text-secondary);
font-style: italic;
}
.article-content :global(pre) {
margin: 2em 0;
padding: var(--spacing-5);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow-x: auto;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.6;
}
.article-content :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-surface);
padding: 0.2em 0.4em;
border-radius: var(--radius);
}
.article-content :global(pre code) {
background: none;
padding: 0;
}
.article-content :global(ul),
.article-content :global(ol) {
margin-bottom: 1.5em;
padding-left: 1.5em;
}
.article-content :global(li) {
margin-bottom: 0.5em;
}
.article-content :global(img) {
margin: 2em 0;
border-radius: var(--radius-lg);
}
.article-content :global(hr) {
margin: 3em 0;
border: none;
border-top: 1px solid var(--color-border);
}
.article-content :global(a) {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
}
.article-content :global(a:hover) {
text-decoration-thickness: 2px;
}
/* Right column: TOC + Sidebar */
.article-sidebar {
display: block;
}
.sidebar-sticky {
position: sticky;
top: calc(var(--nav-height) + var(--spacing-8));
}
.toc {
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-6);
border-bottom: 1px solid var(--color-border-subtle);
}
.toc-title {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-3);
}
.toc-content :global(.toc-list) {
list-style: none;
padding: 0;
margin: 0;
}
.toc-content :global(.toc-item) {
margin-bottom: var(--spacing-1);
}
.toc-content :global(.toc-item--nested) {
padding-left: var(--spacing-3);
}
.toc-content :global(.toc-link) {
display: block;
font-size: var(--font-size-sm);
color: var(--color-muted);
text-decoration: none;
padding: var(--spacing-1) 0;
transition: color var(--transition-fast);
line-height: var(--leading-snug);
}
.toc-content :global(.toc-link:hover),
.toc-content :global(.toc-link.active) {
color: var(--color-text);
}
/* Sidebar widgets */
.sidebar-widgets :global(.widget-area) {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.sidebar-widgets :global(.widget) {
font-size: var(--font-size-sm);
}
.sidebar-widgets :global(.widget__title) {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-3);
}
.sidebar-widgets :global(.widget__content) {
color: var(--color-text-secondary);
line-height: var(--leading-relaxed);
}
/* Sidebar search widget */
.sidebar-widgets :global(.widget-search) {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.sidebar-widgets :global(.widget-search__input) {
width: 100%;
padding: var(--spacing-2) var(--spacing-3);
font-family: var(--font-sans);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.sidebar-widgets :global(.widget-search__input)::placeholder {
color: var(--color-muted);
}
.sidebar-widgets :global(.widget-search__input):focus,
.sidebar-widgets :global(.widget-search__input):focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-ring);
}
.sidebar-widgets :global(.widget-search__button) {
display: none;
}
/* Sidebar categories widget */
.sidebar-widgets :global(.widget-categories) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-categories li) {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-categories li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-categories__link) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-widgets :global(.widget-categories__link:hover) {
color: var(--color-text);
}
.sidebar-widgets :global(.widget-categories__count) {
font-size: var(--font-size-xs);
color: var(--color-muted);
background: var(--color-surface);
padding: var(--tag-padding-y) var(--spacing-2);
border-radius: var(--radius);
}
/* Sidebar tags widget - pill style */
.sidebar-widgets :global(.widget-tags__cloud) {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.sidebar-widgets :global(.widget-tags__cloud li) {
margin: 0;
}
.sidebar-widgets :global(.widget-tags__link) {
display: inline-block;
padding: var(--spacing-1) var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.sidebar-widgets :global(.widget-tags__link:hover) {
color: var(--color-text);
background: var(--color-border);
}
.sidebar-widgets :global(.widget-tags__count) {
display: none;
}
/* Sidebar recent posts widget */
.sidebar-widgets :global(.widget-recent-posts) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-recent-posts li) {
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-recent-posts li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-recent-posts a) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
line-height: var(--leading-snug);
}
.sidebar-widgets :global(.widget-recent-posts a:hover) {
color: var(--color-text);
}
/* Sidebar archives widget */
.sidebar-widgets :global(.widget-archives) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-archives li) {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-archives li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-archives__link) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-widgets :global(.widget-archives__link:hover) {
color: var(--color-text);
}
.sidebar-widgets :global(.widget-archives__count) {
font-size: var(--font-size-xs);
color: var(--color-muted);
background: var(--color-surface);
padding: var(--tag-padding-y) var(--spacing-2);
border-radius: var(--radius);
}
/* Comments section */
.article-comments {
margin-top: var(--spacing-16);
padding-top: var(--spacing-10);
border-top: 1px solid var(--color-border);
}
.article-comments :global(.ec-comments) {
--ec-comment-border: 1px solid var(--color-border);
}
.article-comments :global(.ec-comments-heading) {
font-size: var(--font-size-2xl);
font-weight: 600;
margin-bottom: var(--spacing-8);
}
.article-comments :global(.ec-comment-author) {
color: var(--color-text);
}
.article-comments :global(.ec-comment-date) {
font-family: var(--font-mono);
color: var(--color-muted);
}
.article-comments :global(.ec-comment-body) {
color: var(--color-text);
}
.article-comments :global(.ec-comment-form-field input),
.article-comments :global(.ec-comment-form-field textarea) {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
color: var(--color-text) !important;
}
.article-comments :global(.ec-comment-user-info) {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
}
.article-comments :global(.ec-comment-form-submit) {
background: var(--color-accent) !important;
color: var(--color-on-accent) !important;
}
/* More posts section */
.more-posts {
background: var(--color-bg-subtle);
padding: var(--spacing-16) 0;
margin-top: var(--spacing-16);
}
.more-inner {
max-width: var(--wide-width);
margin: 0 auto;
padding: 0 var(--spacing-6);
}
.more-title {
font-size: var(--font-size-2xl);
font-weight: 600;
margin-bottom: var(--spacing-10);
}
.more-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(5, auto);
gap: var(--spacing-8);
}
/* Responsive */
@media (max-width: 1100px) {
.article-grid {
grid-template-columns: minmax(0, var(--content-width));
gap: 0;
}
.article-meta-col,
.article-sidebar {
display: none;
}
.article-header .article-meta {
display: flex;
}
}
@media (max-width: 900px) {
.article-hero {
margin: var(--spacing-4) var(--spacing-4) var(--spacing-8);
border-radius: var(--radius);
}
.article-grid {
padding: 0 var(--spacing-4);
}
.more-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.article-title {
font-size: var(--font-size-3xl);
}
.more-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,268 @@
---
import { getEmDashCollection, getEntryTerms } from "emdash";
import Base from "../../layouts/Base.astro";
import { getReadingTime } from "../../utils/reading-time";
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
Astro.cache.set(cacheHint);
const sortedPosts = posts.toSorted((a, b) => {
const dateA = a.data.publishedAt?.getTime() ?? 0;
const dateB = b.data.publishedAt?.getTime() ?? 0;
return dateB - dateA;
});
// Fetch tags for each post (bylines are already hydrated by getEmDashCollection)
const postsWithTags = await Promise.all(
sortedPosts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
const bylines = post.data.bylines ?? [];
return { post, tags, bylines };
})
);
const formatDate = (date: Date) =>
date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
---
<Base title="All Posts" description="Browse all blog posts">
<div class="posts-page">
<header class="page-header">
<h1 class="page-title">All Posts</h1>
<p class="page-description">
{posts.length}
{posts.length === 1 ? "article" : "articles"}
</p>
</header>
{
sortedPosts.length === 0 ? (
<p class="empty">No posts yet.</p>
) : (
<div class="posts-list">
{postsWithTags.map(({ post, tags, bylines }) => (
<article class="post-item">
<a href={`/posts/${post.id}`} class="post-link">
<div class="post-meta">
{bylines.length > 0 && (
<>
<div class="post-bylines">
{bylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="post-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="post-byline-avatar"
/>
)}
<span class="post-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{bylines.length > 2 && (
<span class="byline-more">+{bylines.length - 2}</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{post.data.publishedAt && (
<time>{formatDate(post.data.publishedAt)}</time>
)}
{post.data.publishedAt && <span class="meta-dot" />}
<span>{getReadingTime(post.data.content)} min read</span>
</div>
<h2 class="post-title">{post.data.title}</h2>
{post.data.excerpt && (
<p class="post-excerpt">{post.data.excerpt}</p>
)}
</a>
{tags.length > 0 && (
<div class="post-tags">
{tags.slice(0, 3).map((t) => (
<a href={`/tag/${t.slug}`} class="post-tag">
{t.label}
</a>
))}
</div>
)}
</article>
))}
</div>
)
}
</div>
</Base>
<style>
.posts-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-12);
}
.page-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.page-description {
font-size: var(--font-size-lg);
color: var(--color-muted);
}
.empty {
color: var(--color-muted);
font-size: var(--font-size-lg);
}
.posts-list {
display: flex;
flex-direction: column;
}
.post-item {
padding: var(--spacing-8) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.post-item:first-child {
padding-top: 0;
}
.post-item:last-child {
border-bottom: none;
}
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Post bylines */
.post-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.post-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.post-byline-avatar {
width: var(--avatar-size-sm);
height: var(--avatar-size-sm);
border-radius: 50%;
object-fit: cover;
}
.post-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.post-title {
font-size: var(--font-size-2xl);
font-weight: 600;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.post-link:hover .post-title {
color: var(--color-accent);
}
.post-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}
.post-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.post-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
@media (max-width: 600px) {
.posts-page {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.page-title {
font-size: var(--font-size-3xl);
}
.post-title {
font-size: var(--font-size-xl);
}
}
</style>

View File

@@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { getEmDashCollection } from "emdash";
const siteTitle = "My Blog";
const siteDescription = "A blog about software, design, and the occasional stray thought.";
export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site?.toString() || url.origin;
const { entries: posts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = posts
.map((post) => {
if (!post.data.publishedAt) return null;
const pubDate = post.data.publishedAt.toUTCString();
const postUrl = `${siteUrl}/posts/${post.id}`;
const title = escapeXml(post.data.title || "Untitled");
const description = escapeXml(post.data.excerpt || "");
return ` <item>
<title>${title}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<pubDate>${pubDate}</pubDate>
<description>${description}</description>
</item>`;
})
.filter(Boolean)
.join("\n");
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<description>${escapeXml(siteDescription)}</description>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};
const XML_ESCAPE_PATTERNS = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
[/'/g, "&apos;"],
] as const;
function escapeXml(str: string): string {
let result = str;
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}

View File

@@ -0,0 +1,141 @@
---
export const prerender = false;
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime, extractText } from "../utils/reading-time";
const query = Astro.url.searchParams.get("q")?.trim() || "";
const { entries: allPosts } = await getEmDashCollection("posts");
// Simple search: match query against title, excerpt, and content
function matchesQuery(post: (typeof allPosts)[0], q: string): boolean {
if (!q) return false;
const lower = q.toLowerCase();
const title = (post.data.title || "").toLowerCase();
const excerpt = (post.data.excerpt || "").toLowerCase();
// Extract plain text from portable text blocks (avoids matching on _type, _key, etc.)
const content = extractText(post.data.content).toLowerCase();
return (
title.includes(lower) || excerpt.includes(lower) || content.includes(lower)
);
}
const results = query ? allPosts.filter((p) => matchesQuery(p, query)) : [];
---
<Base
title={query ? `Search: ${query}` : "Search"}
description="Search blog posts"
>
<section class="search-page">
<h1 class="search-title">Search</h1>
<form method="get" action="/search" class="search-form">
<input
type="search"
name="q"
value={query}
placeholder="Search posts..."
class="search-input"
autofocus
/>
<button type="submit" class="search-button">Search</button>
</form>
{
query && (
<p class="search-summary">
{results.length === 0
? `No results for "${query}"`
: `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
</p>
)
}
{
results.length > 0 && (
<div class="search-results">
{results.map((post) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
/>
))}
</div>
)
}
{!query && <p class="search-hint">Enter a search term to find posts.</p>}
</section>
</Base>
<style>
.search-page {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.search-title {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-6);
}
.search-form {
display: flex;
gap: var(--spacing-2);
margin-bottom: var(--spacing-8);
}
.search-input {
flex: 1;
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-button {
padding: var(--spacing-2) var(--spacing-6);
font-size: var(--font-size-base);
background: var(--color-accent);
color: var(--color-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
}
.search-button:hover {
opacity: 0.9;
}
.search-summary {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.search-hint {
color: var(--color-muted);
}
.search-results {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
</style>

View File

@@ -0,0 +1,120 @@
---
import { getTerm, getEmDashCollection, getEntryTerms } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const { slug } = Astro.params;
const term = slug ? await getTerm("tag", slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const { entries: posts } = await getEmDashCollection("posts", {
where: { tag: term.slug },
orderBy: { published_at: "desc" },
});
// Fetch tags for display on each post card
const filteredPosts = await Promise.all(
posts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
return { post, tags };
})
);
---
<Base
title={`Posts tagged "${term.label}"`}
description={`All posts tagged with ${term.label}`}
>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Tag</span>
<h1 class="archive-title">{term.label}</h1>
<p class="archive-count">
{filteredPosts.length}
{filteredPosts.length === 1 ? "post" : "posts"}
</p>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts with this tag yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
/>
))}
</div>
)
}
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.archive-count {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>