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:
1 parent
b9f3156d6d
commit
fd7fd5728d
13 files changed
+2444
-1198
No files matched your search
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user