capsite/potluck: a weekly zine of issues, not a blog

- issues collection (ISO-week slug, week, dek, rich-text body); home,
  /issues, and /issues/<week> pages; seed/issues.json holds three
  sketched issues (W38-W40) from public diigo bookmarks, editorial
  lines marked (draft); issues-sketch.md is the dry run behind them
- microformats: h-feed of h-entry lists; an issue is an h-entry
  (p-name, dt-published, p-summary, e-content); off-site links render
  as h-cite, /tag/<tag> links as rel-tag p-category (CiteLink.astro)
- diigo tags: shown on each bookmark and attached to issues via the
  tag taxonomy (now posts + issues); /tag/<tag> lists issues
- demo blog content gone: posts dropped from the seed, home page lists
  issues, site title/tagline, footer About widget, and About page
  rewritten; nav is Home / About / Issues; search and RSS cover issues
- .column container gives potluck's pages the header's margins

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 20:31:54 -05:00
1 parent b9f3156d6d
commit fd7fd5728d
13 files changed
+2444 -1198

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
---
/**
* Portable Text link mark for issue bodies: an off-site link is a citation,
* marked up as an `h-cite` (name + url) so microformats parsers see each
* bookmark; a `/tag/<tag>` link is a rel-tag `p-category` of the issue
* (the `#` is CSS, so the category value is the bare tag); other on-site
* and anchor links stay plain.
*
* @see https://microformats.org/wiki/h-cite
* @see https://microformats.org/wiki/rel-tag
*/
export interface Props {
node: { markDef: { _type: "link"; _key: string; href: string; blank?: boolean } };
}
const href = Astro.props.node?.markDef?.href ?? "";
// only these schemes become hrefs (the stored href is editor input)
const safe = /^(https?:|mailto:|\/|#)/i.test(href) ? href : "";
const offSite = /^https?:/i.test(safe);
const isTag = safe.startsWith("/tag/");
---
{
offSite ? (
<cite class="h-cite"><a class="u-url p-name" href={safe}><slot /></a></cite>
) : isTag ? (
<a rel="tag" class="p-category" href={safe}><slot /></a>
) : (
<a href={safe || undefined}><slot /></a>
)
}
<style>
cite {
font-style: normal;
}
a[rel="tag"] {
font-size: 90%;
}
a[rel="tag"]::before {
content: "#";
}
</style>
+1 -1
View File
@@ -126,7 +126,7 @@ const isLoggedIn = !!Astro.locals.user;
inputClass="site-search-input"
resultsClass="site-search-results"
resultClass="site-search-result"
collections={["posts", "pages"]}
collections={["issues", "pages"]}
/>
<div class="nav-links">
{
+34 -452
View File
@@ -1,466 +1,48 @@
---
import {
getEmDashCollection,
getTermsForEntries,
getSiteSettings,
} from "emdash";
import { Image } from "emdash/ui";
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
// Limit to what we render (1 featured + 6 grid). The DB does the slicing
// instead of fetching every post and discarding the tail in JS.
const POSTS_PER_PAGE = 7;
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need
}),
getSiteSettings(),
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
const { entries: issues, cacheHint } = await getEmDashCollection("issues", {
orderBy: { published_at: "desc" },
limit: 12,
});
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
const hasMorePosts = posts.length > POSTS_PER_PAGE;
// Find the first post with a featured image for the hero
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = visiblePosts
.filter((_, i) => i !== featuredIndex)
.slice(0, 6);
// Single batched query for tags across the featured post + grid posts.
// Avoids the N+1 pattern of calling getEntryTerms() per entry.
// Bylines are already hydrated on entry.data.bylines by getEmDashCollection.
const tagEntryIds = [
...(featuredPost ? [featuredPost.data.id] : []),
...gridPosts.map((p) => p.data.id),
];
const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag");
const featuredTags = featuredPost
? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
}))
: [];
const featuredBylines = featuredPost?.data.bylines ?? [];
const gridPostsWithTags = gridPosts.map((post) => ({
post,
tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
})),
bylines: post.data.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={siteTitle} description={siteTagline}>
{
posts.length === 0 ? (
<section class="empty-state">
<h1>No posts yet</h1>
<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" {...featuredPost.edit.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.edit.title}>{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt" {...featuredPost.edit.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}
edit={post.edit}
/>
))}
</div>
</section>
)}
</div>
)
}
<Base title="potluck" description="A weekly zine: everyone brings a dish">
<div class="column">
<h1>potluck</h1>
<p>A weekly zine: everyone brings a dish.</p>
{
issues.length === 0 ? (
<p>No issues yet.</p>
) : (
<ul class="h-feed">
{issues.map((issue) => (
<li class="h-entry">
<span class="week">{issue.data.week}</span>{" "}
<a class="u-url p-name" href={`/issues/${issue.id}`}>{issue.data.title}</a>
{issue.data.dek && <>: <span class="p-summary">{issue.data.dek}</span></>}
</li>
))}
</ul>
)
}
<p><a href="/issues">all issues</a></p>
</div>
</Base>
<style>
.home-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6);
h1,
p,
ul {
margin-block: 1em;
}
/* Featured Section - Side by side */
.featured-section {
margin-bottom: var(--spacing-16);
ul {
padding-inline-start: 1.5em;
}
.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: var(--font-weight-display);
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-brand);
}
.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(--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);
}
.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-brand);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-brand-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: var(--font-weight-heading);
}
.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-brand);
color: var(--color-on-brand);
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-brand-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);
}
.week {
font-family: var(--font-mono);
}
</style>
+59
View File
@@ -0,0 +1,59 @@
---
import { getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
import CiteLink from "../../components/CiteLink.astro";
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");
const { entry: issue, cacheHint } = await getEmDashEntry("issues", slug);
if (!issue) return Astro.redirect("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
const published = issue.data.publishedAt;
---
<Base
title={issue.data.title}
description={issue.data.dek}
type="article"
publishedTime={published?.toISOString()}
content={{ collection: "issues", id: issue.data.id, slug: issue.id }}
>
<div class="column">
<article class="issue h-entry">
<header>
<p class="week">
<a class="u-url" href={`/issues/${issue.id}`}>{issue.data.week}</a>
{published && <> · <time class="dt-published" datetime={published.toISOString()}>{published.toISOString().slice(0, 10)}</time></>}
</p>
<h1 class="p-name">{issue.data.title}</h1>
{issue.data.dek && <p class="p-summary"><i>{issue.data.dek}</i></p>}
</header>
<div class="e-content">
<PortableText value={issue.data.body ?? []} components={{ mark: { link: CiteLink } }} />
</div>
<footer><a href="/issues">all issues</a></footer>
</article>
</div>
</Base>
<style>
.issue :global(h1),
.issue :global(h2),
.issue :global(p),
.issue :global(ul) {
margin-block: 0.75em;
}
.issue :global(h2) {
margin-block-start: 1.5em;
}
.issue :global(ul) {
padding-inline-start: 1.5em;
}
.week {
font-family: var(--font-mono);
}
</style>
+45
View File
@@ -0,0 +1,45 @@
---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
const { entries: issues, cacheHint } = await getEmDashCollection("issues", {
orderBy: { published_at: "desc" },
limit: 52,
});
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
<Base title="Issues" description="potluck, week by week">
<div class="column">
<h1>Issues</h1>
{
issues.length === 0 ? (
<p>No issues yet.</p>
) : (
<ul class="h-feed">
{issues.map((issue) => (
<li class="h-entry">
<span class="week">{issue.data.week}</span>{" "}
<a class="u-url p-name" href={`/issues/${issue.id}`}>{issue.data.title}</a>
{issue.data.dek && <>: <span class="p-summary">{issue.data.dek}</span></>}
</li>
))}
</ul>
)
}
</div>
</Base>
<style>
h1,
ul {
margin-block: 1em;
}
ul {
padding-inline-start: 1.5em;
}
.week {
font-family: var(--font-mono);
}
</style>
+10 -10
View File
@@ -7,24 +7,24 @@ export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site ?? new URL(url.origin);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
const { entries: posts } = await getEmDashCollection("posts", {
const { entries: issues } = await getEmDashCollection("issues", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = posts
.map((post) => {
if (!post.data.publishedAt) return null;
const pubDate = post.data.publishedAt.toUTCString();
const items = issues
.map((issue) => {
if (!issue.data.publishedAt) return null;
const pubDate = issue.data.publishedAt.toUTCString();
const postUrl = new URL(`/posts/${post.id}`, siteUrl).href;
const title = escapeXml(post.data.title || "Untitled");
const description = escapeXml(post.data.excerpt || "");
const issueUrl = new URL(`/issues/${issue.id}`, siteUrl).href;
const title = escapeXml(`${issue.data.week}: ${issue.data.title || "Untitled"}`);
const description = escapeXml(issue.data.dek || "");
return ` <item>
<title>${title}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<link>${issueUrl}</link>
<guid isPermaLink="true">${issueUrl}</guid>
<pubDate>${pubDate}</pubDate>
<description>${description}</description>
</item>`;
+27 -116
View File
@@ -1,148 +1,59 @@
---
import {
getTaxonomyTermsWithCacheHint,
getEmDashCollection,
getTermsForEntries,
decodeSlug,
} from "emdash";
import { getTaxonomyTermsWithCacheHint, getEmDashCollection, decodeSlug } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const slug = decodeSlug(Astro.params.slug);
const termsResult = await getTaxonomyTermsWithCacheHint("tag", {
includeCounts: false,
});
const termsResult = await getTaxonomyTermsWithCacheHint("tag", { includeCounts: false });
const term = slug ? termsResult.data.find((item) => item.slug === slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const cursor = Astro.url.searchParams.get("cursor") || undefined;
const {
entries: posts,
cacheHint,
nextCursor,
} = await getEmDashCollection("posts", {
const { entries: issues, cacheHint } = await getEmDashCollection("issues", {
where: { tag: term.slug },
orderBy: { published_at: "desc" },
limit: 20,
cursor,
limit: 52,
});
if (Astro.cache?.enabled) {
Astro.cache.set(termsResult.cacheHint);
Astro.cache.set(cacheHint);
}
// Single batched query for tags on every post tagged with this term,
// rather than calling getEntryTerms() per post.
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const filteredPosts = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
}));
---
<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>
</header>
<Base title={`#${term.label}`} description={`Issues tagged ${term.label}`}>
<div class="column">
<h1>#{term.label}</h1>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts with this tag yet.</p>
issues.length === 0 ? (
<p>No issues tagged {term.label}.</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 }))}
edit={post.edit}
/>
<ul class="h-feed">
{issues.map((issue) => (
<li class="h-entry">
<span class="week">{issue.data.week}</span>{" "}
<a class="u-url p-name" href={`/issues/${issue.id}`}>{issue.data.title}</a>
{issue.data.dek && <>: <span class="p-summary">{issue.data.dek}</span></>}
</li>
))}
</div>
</ul>
)
}
<nav class="pagination" aria-label="Pagination">
{cursor && <a href={`/tag/${term.slug}`}>First page</a>}
{
nextCursor && (
<a
href={`/tag/${term.slug}?cursor=${encodeURIComponent(nextCursor)}`}
>
Next page
</a>
)
}
</nav>
</section>
<p><a href="/issues">all issues</a></p>
</div>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
h1,
p,
ul {
margin-block: 1em;
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
ul {
padding-inline-start: 1.5em;
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-brand);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-display);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.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;
}
.week {
font-family: var(--font-mono);
}
</style>
+8
View File
@@ -57,6 +57,14 @@ a:where(:not([class*="emdash"]):not([class*="ec-"])):hover {
border-bottom-style: solid;
}
/* potluck's own pages: one column, aligned with the header's nav (same
width and side padding as the template's page containers). */
.column {
max-width: var(--wide-width);
margin-inline: auto;
padding: var(--spacing-6) var(--spacing-6) var(--spacing-16);
}
/* Keep the signed-in Admin link separated from the primary navigation. */
.nav-admin {
margin-inline-start: var(--spacing-5);
+2 -2
View File
@@ -12,8 +12,8 @@ export interface BlogSiteIdentitySettings {
favicon?: MediaReference;
}
const DEFAULT_SITE_TITLE = "My Blog";
const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas.";
const DEFAULT_SITE_TITLE = "potluck";
const DEFAULT_SITE_TAGLINE = "A weekly zine: everyone brings a dish";
export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) {
return {