Add combined blog+marketing+portfolio template
- Added blog pages: posts index, post detail, category/tag archives, search, RSS - Added BlogBase layout with Source Serif 4 font - Added PostCard and TagList components - Added utility functions: reading-time, site-identity - Added posts collection with taxonomies to seed - Updated menus with all sections: Home, Posts, Work, Pricing, Contact - Added blog seed content: 3 posts with bylines
This commit is contained in:
@@ -42,7 +42,7 @@
|
|||||||
"runtime": null
|
"runtime": null
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"buildCommand": "pnpm build 2>&1",
|
"buildCommand": "pnpm build 2>&1 | head -80",
|
||||||
"testCommand": null,
|
"testCommand": null,
|
||||||
"lintCommand": null,
|
"lintCommand": null,
|
||||||
"devCommand": "npm run dev",
|
"devCommand": "npm run dev",
|
||||||
@@ -126,12 +126,30 @@
|
|||||||
"lastAccessed": 1777616481729,
|
"lastAccessed": 1777616481729,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "src/pages/rss.xml.ts",
|
||||||
|
"accessCount": 4,
|
||||||
|
"lastAccessed": 1777618133397,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "src/styles/theme.css",
|
"path": "src/styles/theme.css",
|
||||||
"accessCount": 2,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1777616344900,
|
"lastAccessed": 1777616344900,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "src/pages/pages/index.astro",
|
||||||
|
"accessCount": 2,
|
||||||
|
"lastAccessed": 1777618173372,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/pages/posts/index.astro",
|
||||||
|
"accessCount": 2,
|
||||||
|
"lastAccessed": 1777618215078,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "src/pages/index.astro",
|
"path": "src/pages/index.astro",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
@@ -185,6 +203,24 @@
|
|||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1777616259549,
|
"lastAccessed": 1777616259549,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/layouts/Base.astro",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1777617017519,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/pages/posts/[slug].astro",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1777618182408,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/pages/posts/posts-index.astro",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1777618220299,
|
||||||
|
"type": "file"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"userDirectives": []
|
"userDirectives": []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"tool_name": "Bash",
|
"tool_name": "Bash",
|
||||||
"tool_input_preview": "{\"command\":\"git remote add origin https://git.moreminimore.com/kunthawat/emdash-marketing-template.git 2>/dev/null || git remote set-url origin https://git.moreminimore.com/kunthawat/emdash-marketing-...",
|
"tool_input_preview": "{\"command\":\"pnpm build 2>&1\",\"timeout\":120000,\"description\":\"Build template\"}",
|
||||||
"error": "Exit code 128",
|
"error": "Exit code 1",
|
||||||
"timestamp": "2026-04-30T13:58:18.498Z",
|
"timestamp": "2026-05-01T06:49:03.571Z",
|
||||||
"retry_count": 2
|
"retry_count": 1
|
||||||
}
|
}
|
||||||
105
seed/seed.json
105
seed/seed.json
@@ -30,6 +30,19 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "posts",
|
||||||
|
"label": "Posts",
|
||||||
|
"labelSingular": "Post",
|
||||||
|
"supports": ["drafts", "revisions", "search", "seo"],
|
||||||
|
"commentsEnabled": true,
|
||||||
|
"fields": [
|
||||||
|
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
|
||||||
|
{ "slug": "featured_image", "label": "Featured Image", "type": "image" },
|
||||||
|
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true },
|
||||||
|
{ "slug": "excerpt", "label": "Excerpt", "type": "text" }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"slug": "projects",
|
"slug": "projects",
|
||||||
"label": "Projects",
|
"label": "Projects",
|
||||||
@@ -89,9 +102,12 @@
|
|||||||
"name": "category",
|
"name": "category",
|
||||||
"label": "Categories",
|
"label": "Categories",
|
||||||
"labelSingular": "Category",
|
"labelSingular": "Category",
|
||||||
"hierarchical": false,
|
"hierarchical": true,
|
||||||
"collections": ["projects"],
|
"collections": ["posts", "projects"],
|
||||||
"terms": [
|
"terms": [
|
||||||
|
{ "slug": "development", "label": "Development" },
|
||||||
|
{ "slug": "design", "label": "Design" },
|
||||||
|
{ "slug": "notes", "label": "Notes" },
|
||||||
{ "slug": "branding", "label": "Branding" },
|
{ "slug": "branding", "label": "Branding" },
|
||||||
{ "slug": "web", "label": "Web Design" },
|
{ "slug": "web", "label": "Web Design" },
|
||||||
{ "slug": "print", "label": "Print" },
|
{ "slug": "print", "label": "Print" },
|
||||||
@@ -103,8 +119,12 @@
|
|||||||
"label": "Tags",
|
"label": "Tags",
|
||||||
"labelSingular": "Tag",
|
"labelSingular": "Tag",
|
||||||
"hierarchical": false,
|
"hierarchical": false,
|
||||||
"collections": ["projects"],
|
"collections": ["posts", "projects"],
|
||||||
"terms": [
|
"terms": [
|
||||||
|
{ "slug": "webdev", "label": "Web Development" },
|
||||||
|
{ "slug": "opinion", "label": "Opinion" },
|
||||||
|
{ "slug": "tools", "label": "Tools" },
|
||||||
|
{ "slug": "creativity", "label": "Creativity" },
|
||||||
{ "slug": "identity", "label": "Identity" },
|
{ "slug": "identity", "label": "Identity" },
|
||||||
{ "slug": "ui-ux", "label": "UI/UX" },
|
{ "slug": "ui-ux", "label": "UI/UX" },
|
||||||
{ "slug": "development", "label": "Development" },
|
{ "slug": "development", "label": "Development" },
|
||||||
@@ -115,6 +135,10 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"bylines": [
|
||||||
|
{ "id": "byline-editorial", "slug": "emdash-editorial", "displayName": "EmDash Editorial" },
|
||||||
|
{ "id": "byline-guest", "slug": "guest-contributor", "displayName": "Guest Contributor", "isGuest": true }
|
||||||
|
],
|
||||||
"menus": [
|
"menus": [
|
||||||
{
|
{
|
||||||
"name": "primary",
|
"name": "primary",
|
||||||
@@ -122,19 +146,24 @@
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"label": "Features",
|
"label": "Home",
|
||||||
"url": "/#features"
|
"url": "/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"label": "Pricing",
|
"label": "Posts",
|
||||||
"url": "/pricing"
|
"url": "/posts"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"label": "Work",
|
"label": "Work",
|
||||||
"url": "/work"
|
"url": "/work"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "custom",
|
||||||
|
"label": "Pricing",
|
||||||
|
"url": "/pricing"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"label": "Contact",
|
"label": "Contact",
|
||||||
@@ -632,6 +661,68 @@
|
|||||||
"tag": ["art-direction", "editorial"]
|
"tag": ["art-direction", "editorial"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"id": "post-1",
|
||||||
|
"slug": "building-for-the-long-term",
|
||||||
|
"status": "published",
|
||||||
|
"data": {
|
||||||
|
"title": "Building for the Long Term",
|
||||||
|
"excerpt": "The frameworks will change. The databases will change. What survives is the clarity of your thinking.",
|
||||||
|
"featured_image": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=800&fit=crop",
|
||||||
|
"content": [
|
||||||
|
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Every few years the industry collectively decides that everything we've been doing is wrong and there's a better way. New frameworks, new paradigms, new build tools. The churn is relentless, and if you're not careful, you spend more time migrating than building." }] },
|
||||||
|
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "I've been writing software long enough to have seen several of these cycles. jQuery to Backbone to Angular to React to whatever comes next. Each transition felt urgent at the time. Looking back, the things that actually mattered were rarely about the framework." }] },
|
||||||
|
{ "_type": "block", "style": "h2", "children": [{ "_type": "span", "text": "What survives" }] },
|
||||||
|
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Clean data models survive. Clear boundaries between systems survive. Good naming survives. The decision to keep things simple when you could have made them clever - that definitely survives." }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bylines": [{ "byline": "byline-editorial" }],
|
||||||
|
"taxonomies": {
|
||||||
|
"category": ["development"],
|
||||||
|
"tag": ["opinion"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "post-2",
|
||||||
|
"slug": "the-case-for-static",
|
||||||
|
"status": "published",
|
||||||
|
"data": {
|
||||||
|
"title": "The Case for Static",
|
||||||
|
"excerpt": "Static sites aren't a step backwards. They're what you get when you take performance and simplicity seriously.",
|
||||||
|
"featured_image": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=800&fit=crop",
|
||||||
|
"content": [
|
||||||
|
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "There's a certain irony in the fact that the web started static, went dynamic, and is now swinging back toward static again. But the static sites of today aren't the hand-coded HTML pages of 1998. They're generated, optimized, and deployed to edge networks that serve them in milliseconds." }] },
|
||||||
|
{ "_type": "block", "style": "h2", "children": [{ "_type": "span", "text": "The performance argument" }] },
|
||||||
|
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "A static file served from a CDN is as fast as the web gets. No cold starts, no database queries, no server-side rendering overhead. The Time to First Byte is essentially the network latency to your nearest edge node. You can't beat physics." }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bylines": [{ "byline": "byline-editorial" }],
|
||||||
|
"taxonomies": {
|
||||||
|
"category": ["development"],
|
||||||
|
"tag": ["webdev", "opinion"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "post-3",
|
||||||
|
"slug": "learning-in-public",
|
||||||
|
"status": "published",
|
||||||
|
"data": {
|
||||||
|
"title": "Learning in Public",
|
||||||
|
"excerpt": "Writing about what you're learning is the fastest way to find out what you don't actually understand.",
|
||||||
|
"featured_image": "https://images.unsplash.com/photo-1432821596592-e2c18b78144f?w=1200&h=800&fit=crop",
|
||||||
|
"content": [
|
||||||
|
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "I started writing about things I was learning not because I had anything original to say, but because I kept forgetting what I'd figured out. The blog posts were notes to my future self, published publicly more out of laziness than courage." }] },
|
||||||
|
{ "_type": "block", "style": "h2", "children": [{ "_type": "span", "text": "The fear of being wrong" }] },
|
||||||
|
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "The biggest barrier isn't time or writing skill. It's the fear of publishing something that turns out to be wrong. But here's the thing: being wrong publicly is one of the most efficient ways to learn. Someone will correct you, often kindly, and you'll remember that correction forever." }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"taxonomies": {
|
||||||
|
"category": ["notes"],
|
||||||
|
"tag": ["opinion"]
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
184
src/components/PostCard.astro
Normal file
184
src/components/PostCard.astro
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
---
|
||||||
|
import type { MediaValue, ContentBylineCredit } from "emdash";
|
||||||
|
import { Image } from "emdash/ui";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
excerpt?: string;
|
||||||
|
featuredImage?: MediaValue | string;
|
||||||
|
href: string;
|
||||||
|
date?: Date;
|
||||||
|
readingTime?: number;
|
||||||
|
tags?: Array<{ slug: string; label: string }>;
|
||||||
|
bylines?: ContentBylineCredit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
featuredImage,
|
||||||
|
href,
|
||||||
|
date,
|
||||||
|
readingTime,
|
||||||
|
tags,
|
||||||
|
bylines,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const formattedDate = date
|
||||||
|
? date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<a href={href} aria-hidden="true" tabindex="-1">
|
||||||
|
{featuredImage ? (
|
||||||
|
<Image
|
||||||
|
src={featuredImage}
|
||||||
|
alt=""
|
||||||
|
width="600"
|
||||||
|
height="338"
|
||||||
|
class="card__image"
|
||||||
|
quality={80}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div class="card__image-placeholder" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
{bylines && bylines.length > 0 && (
|
||||||
|
<>
|
||||||
|
{bylines.slice(0, 1).map((credit) => (
|
||||||
|
<>
|
||||||
|
{credit.byline.avatarMediaId && (
|
||||||
|
<img
|
||||||
|
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||||
|
alt=""
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
class="card__avatar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{credit.byline.displayName}</span>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{bylines.length > 1 && (
|
||||||
|
<span
|
||||||
|
class="card__bylines-extra"
|
||||||
|
title={bylines
|
||||||
|
.slice(1)
|
||||||
|
.map((c) => c.byline.displayName)
|
||||||
|
.join(", ")}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
+{bylines.length - 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(formattedDate || readingTime) && (
|
||||||
|
<div class="card__meta">
|
||||||
|
{formattedDate && <time datetime={date?.toISOString()}>{formattedDate}</time>}
|
||||||
|
{formattedDate && readingTime && <span aria-hidden="true">·</span>}
|
||||||
|
{readingTime && <span>{readingTime} min read</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 class="card__title">
|
||||||
|
<a href={href}>{title}</a>
|
||||||
|
</h2>
|
||||||
|
{excerpt && (
|
||||||
|
<p class="card__excerpt">
|
||||||
|
<a href={href} aria-hidden="true" tabindex="-1">{excerpt}</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{tags && tags.length > 0 && (
|
||||||
|
<div class="card__tags">
|
||||||
|
{tags.slice(0, 2).map((tag) => (
|
||||||
|
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__title a {
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__title a:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__excerpt {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__tags {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__tags a {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: var(--spacing-1) var(--spacing-2);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__tags a:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
43
src/components/TagList.astro
Normal file
43
src/components/TagList.astro
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
tags: Array<{ slug: string; label: string }>;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
const { tags, class: className } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<ul class={className}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li a {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: var(--spacing-1) var(--spacing-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
li a:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
233
src/layouts/BlogBase.astro
Normal file
233
src/layouts/BlogBase.astro
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
---
|
||||||
|
import { getMenu, getEmDashCollection, getSiteSettings } from "emdash";
|
||||||
|
import { WidgetArea, EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
|
||||||
|
import { createPublicPageContext } from "emdash/page";
|
||||||
|
import LiveSearch from "emdash/ui/search";
|
||||||
|
import { Font } from "astro:assets";
|
||||||
|
import { resolveBlogSiteIdentity } from "../utils/site-identity";
|
||||||
|
import "../styles/theme.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
pageTitle?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
canonical?: string | null;
|
||||||
|
robots?: string | null;
|
||||||
|
type?: "website" | "article";
|
||||||
|
publishedTime?: string | null;
|
||||||
|
modifiedTime?: string | null;
|
||||||
|
author?: string | null;
|
||||||
|
content?: {
|
||||||
|
collection: string;
|
||||||
|
id: string;
|
||||||
|
slug?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
pageTitle,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
canonical,
|
||||||
|
robots,
|
||||||
|
type = "website",
|
||||||
|
publishedTime,
|
||||||
|
modifiedTime,
|
||||||
|
author,
|
||||||
|
content,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const { siteTitle, siteTagline, siteLogo, siteFavicon } = resolveBlogSiteIdentity(await getSiteSettings());
|
||||||
|
const fullTitle = title.includes(siteTitle) ? title : `${title} — ${siteTitle}`;
|
||||||
|
const menu = await getMenu("primary");
|
||||||
|
const { entries: pages } = await getEmDashCollection("pages");
|
||||||
|
|
||||||
|
const pageCtx = createPublicPageContext({
|
||||||
|
Astro,
|
||||||
|
kind: content ? "content" : "custom",
|
||||||
|
pageType: type,
|
||||||
|
title: fullTitle,
|
||||||
|
pageTitle: pageTitle ?? title,
|
||||||
|
description,
|
||||||
|
canonical,
|
||||||
|
image,
|
||||||
|
content,
|
||||||
|
seo: { ogImage: image, robots },
|
||||||
|
articleMeta: { publishedTime, modifiedTime, author },
|
||||||
|
siteName: siteTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoggedIn = !!Astro.locals.user;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{fullTitle}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
{siteFavicon && <link rel="icon" href={siteFavicon} />}
|
||||||
|
{description && <meta name="description" content={description} />}
|
||||||
|
<EmDashHead page={pageCtx} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<EmDashBodyStart />
|
||||||
|
<header class="site-header">
|
||||||
|
<nav class="container nav">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
{siteLogo ? (
|
||||||
|
<img src={siteLogo.url} alt={siteLogo.alt || siteTitle} width="32" height="32" />
|
||||||
|
) : (
|
||||||
|
<span>{siteTitle}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
{menu?.items.map((item: any) => (
|
||||||
|
<li>
|
||||||
|
<a href={item.url}>{item.label}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<LiveSearch client:load />
|
||||||
|
{isLoggedIn && <a href="/_emdash/admin" class="admin-link">Admin</a>}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container footer-content">
|
||||||
|
<div class="footer-nav">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/posts">Posts</a>
|
||||||
|
{pages.map((page) => (
|
||||||
|
<a href={`/pages/${page.id}`}>{page.data.title}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p class="copyright">© {new Date().getFullYear()} {siteTitle}. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<EmDashBodyEnd />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(*) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
font-family: var(--font-sans, "Inter", ui-sans-serif, system-ui, sans-serif);
|
||||||
|
background: var(--color-bg, #ffffff);
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--color-bg, #ffffff);
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-4, 1rem) 0;
|
||||||
|
gap: var(--spacing-4, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-6, 1.5rem);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
transition: color var(--transition-fast, 120ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--color-primary, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-primary, #0066cc);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
padding: var(--spacing-12, 3rem) 0;
|
||||||
|
margin-top: var(--spacing-16, 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4, 1rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-4, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav a {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav a:hover {
|
||||||
|
color: var(--color-primary, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-3, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
89
src/pages/category/[slug].astro
Normal file
89
src/pages/category/[slug].astro
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
import { getTerm, getEmDashCollection, getTermsForEntries, decodeSlug } from "emdash";
|
||||||
|
import BlogBase from "../../layouts/BlogBase.astro";
|
||||||
|
import PostCard from "../../components/PostCard.astro";
|
||||||
|
|
||||||
|
const slug = decodeSlug(Astro.params.slug);
|
||||||
|
const term = slug ? await getTerm("category", slug) : null;
|
||||||
|
|
||||||
|
if (!term) {
|
||||||
|
return Astro.redirect("/404");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
|
||||||
|
where: { category: term.slug },
|
||||||
|
orderBy: { published_at: "desc" },
|
||||||
|
});
|
||||||
|
Astro.cache.set(cacheHint);
|
||||||
|
|
||||||
|
const tagsByEntry = await getTermsForEntries(
|
||||||
|
"posts",
|
||||||
|
posts.map((p) => p.data.id),
|
||||||
|
"tag",
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredPosts = posts.map((post) => ({
|
||||||
|
post,
|
||||||
|
tags: tagsByEntry.get(post.data.id) ?? [],
|
||||||
|
}));
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogBase title={`Category: ${term.label}`} description={`Posts in category "${term.label}"`}>
|
||||||
|
<div class="archive-page">
|
||||||
|
<h1>Category: {term.label}</h1>
|
||||||
|
|
||||||
|
{filteredPosts.length === 0 ? (
|
||||||
|
<p class="empty">No posts in this category yet.</p>
|
||||||
|
) : (
|
||||||
|
<div class="posts-grid">
|
||||||
|
{filteredPosts.map(({ post, tags }) => (
|
||||||
|
<PostCard
|
||||||
|
post={post}
|
||||||
|
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
|
||||||
|
bylines={post.data.bylines ?? []}
|
||||||
|
href={`/posts/${post.id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BlogBase>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.archive-page {
|
||||||
|
max-width: var(--wide-width, 1200px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-4xl, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
letter-spacing: var(--tracking-tight, -0.03em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
padding: var(--spacing-12, 3rem) 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.posts-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.posts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
src/pages/pages/[slug].astro
Normal file
57
src/pages/pages/[slug].astro
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
import { getEmDashEntry } from "emdash";
|
||||||
|
import { PortableText } from "emdash/ui";
|
||||||
|
import BlogBase from "../../layouts/BlogBase.astro";
|
||||||
|
|
||||||
|
const slug = Astro.params.slug as string;
|
||||||
|
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return Astro.redirect("/404");
|
||||||
|
}
|
||||||
|
|
||||||
|
Astro.cache.set(cacheHint);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogBase
|
||||||
|
title={page.data.title}
|
||||||
|
description={page.data.title}
|
||||||
|
content={{ collection: "pages", id: page.id }}
|
||||||
|
>
|
||||||
|
<article class="page-article">
|
||||||
|
<h1>{page.data.title}</h1>
|
||||||
|
<div class="page-content">
|
||||||
|
<PortableText value={page.data.content} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</BlogBase>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-article {
|
||||||
|
max-width: var(--content-width, 680px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-4xl, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
letter-spacing: var(--tracking-tight, -0.03em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
line-height: var(--leading-relaxed, 1.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content :global(p) {
|
||||||
|
margin-bottom: var(--spacing-6, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content :global(h2) {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
margin-top: var(--spacing-10, 2.5rem);
|
||||||
|
margin-bottom: var(--spacing-4, 1rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
src/pages/pages/index.astro
Normal file
66
src/pages/pages/index.astro
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
import { getEmDashCollection } from "emdash";
|
||||||
|
import BlogBase from "../../layouts/BlogBase.astro";
|
||||||
|
|
||||||
|
const { entries: pages, cacheHint } = await getEmDashCollection("pages");
|
||||||
|
Astro.cache.set(cacheHint);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogBase title="Pages" description="All pages">
|
||||||
|
<div class="pages-page">
|
||||||
|
<h1>Pages</h1>
|
||||||
|
|
||||||
|
{pages.length === 0 ? (
|
||||||
|
<p class="empty">No pages yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul class="pages-list">
|
||||||
|
{pages.map((page) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/pages/${page.id}`}>{page.data.title}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BlogBase>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pages-page {
|
||||||
|
max-width: var(--content-width, 680px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-4xl, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
letter-spacing: var(--tracking-tight, -0.03em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pages-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pages-list li {
|
||||||
|
padding: var(--spacing-4, 1rem) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pages-list a {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pages-list a:hover {
|
||||||
|
color: var(--color-primary, #0066cc);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
246
src/pages/posts/[slug].astro
Normal file
246
src/pages/posts/[slug].astro
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
import { getEmDashEntry, getEmDashCollection, getTermsForEntries, getSeoMeta } from "emdash";
|
||||||
|
import { Image, PortableText } from "emdash/ui";
|
||||||
|
import BlogBase from "../../layouts/BlogBase.astro";
|
||||||
|
import PostCard from "../../components/PostCard.astro";
|
||||||
|
import { getReadingTime } from "../../utils/reading-time";
|
||||||
|
|
||||||
|
const slug = Astro.params.slug as string;
|
||||||
|
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return Astro.redirect("/404");
|
||||||
|
}
|
||||||
|
|
||||||
|
Astro.cache.set(cacheHint);
|
||||||
|
|
||||||
|
const [{ entries: recentPosts }, tagsResult] = await Promise.all([
|
||||||
|
getEmDashCollection("posts", {
|
||||||
|
orderBy: { published_at: "desc" },
|
||||||
|
limit: 4,
|
||||||
|
}),
|
||||||
|
getTermsForEntries("posts", [post.data.id], "tag"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3);
|
||||||
|
const postTags = (tagsResult.get(post.data.id) ?? []).map((t) => ({
|
||||||
|
slug: t.slug,
|
||||||
|
label: t.label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const seoMeta = getSeoMeta({
|
||||||
|
title: post.data.title,
|
||||||
|
description: post.data.excerpt,
|
||||||
|
image: post.data.featured_image,
|
||||||
|
publishedTime: post.data.publishedAt?.toISOString(),
|
||||||
|
author: post.data.bylines?.[0]?.byline.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogBase
|
||||||
|
title={post.data.title}
|
||||||
|
description={post.data.excerpt}
|
||||||
|
image={post.data.featured_image as string}
|
||||||
|
type="article"
|
||||||
|
publishedTime={post.data.publishedAt?.toISOString()}
|
||||||
|
author={post.data.bylines?.[0]?.byline.displayName}
|
||||||
|
content={{ collection: "posts", id: post.id, slug }}
|
||||||
|
>
|
||||||
|
<article class="post-article">
|
||||||
|
{post.data.featured_image && (
|
||||||
|
<div class="hero-image">
|
||||||
|
<Image
|
||||||
|
src={post.data.featured_image}
|
||||||
|
alt={post.data.title}
|
||||||
|
width="1200"
|
||||||
|
height="675"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="post-content">
|
||||||
|
<header class="post-header">
|
||||||
|
{post.data.bylines && post.data.bylines.length > 0 && (
|
||||||
|
<div class="bylines">
|
||||||
|
{post.data.bylines.map((credit: any) => (
|
||||||
|
<span class="byline-credit">
|
||||||
|
{credit.byline.avatarMediaId && (
|
||||||
|
<img
|
||||||
|
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||||
|
alt={credit.byline.displayName}
|
||||||
|
class="byline-avatar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{credit.byline.displayName}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{post.data.publishedAt && (
|
||||||
|
<time datetime={post.data.publishedAt.toISOString()}>
|
||||||
|
{formatDate(post.data.publishedAt)}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
<span>{getReadingTime(post.data.content)} min read</span>
|
||||||
|
{postTags.length > 0 && (
|
||||||
|
<div class="tags">
|
||||||
|
{postTags.map((tag) => (
|
||||||
|
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="post-body">
|
||||||
|
<PortableText value={post.data.content} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{otherPosts.length > 0 && (
|
||||||
|
<section class="related-posts">
|
||||||
|
<h2>Related Posts</h2>
|
||||||
|
<div class="related-grid">
|
||||||
|
{otherPosts.map((p) => (
|
||||||
|
<PostCard
|
||||||
|
post={p}
|
||||||
|
tags={[]}
|
||||||
|
bylines={p.data.bylines ?? []}
|
||||||
|
href={`/posts/${p.id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</BlogBase>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-article {
|
||||||
|
max-width: var(--content-width, 680px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
border-radius: var(--radius-lg, 8px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2, 0.5rem);
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
padding-bottom: var(--spacing-6, 1.5rem);
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bylines {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-3, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline-credit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2, 0.5rem);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header time {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header .tags {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2, 0.5rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header .tags a {
|
||||||
|
font-size: var(--font-size-xs, 0.8125rem);
|
||||||
|
padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
|
||||||
|
background: var(--color-surface, #f7f7f7);
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-fast, 120ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header .tags a:hover {
|
||||||
|
background: var(--color-primary, #0066cc);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-body {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
line-height: var(--leading-relaxed, 1.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-body :global(h2) {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
margin-top: var(--spacing-10, 2.5rem);
|
||||||
|
margin-bottom: var(--spacing-4, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-body :global(p) {
|
||||||
|
margin-bottom: var(--spacing-6, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-body :global(a) {
|
||||||
|
color: var(--color-primary, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-body :global(blockquote) {
|
||||||
|
border-left: 4px solid var(--color-primary, #0066cc);
|
||||||
|
padding-left: var(--spacing-4, 1rem);
|
||||||
|
margin: var(--spacing-6, 1.5rem) 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-posts {
|
||||||
|
margin-top: var(--spacing-16, 4rem);
|
||||||
|
padding-top: var(--spacing-12, 3rem);
|
||||||
|
border-top: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-posts h2 {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-6, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.related-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
268
src/pages/posts/index.astro
Normal file
268
src/pages/posts/index.astro
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
---
|
||||||
|
import { getEmDashCollection, getTermsForEntries, getSiteSettings } from "emdash";
|
||||||
|
import { Image } from "emdash/ui";
|
||||||
|
import BlogBase from "../../layouts/BlogBase.astro";
|
||||||
|
import PostCard from "../../components/PostCard.astro";
|
||||||
|
import { getReadingTime } from "../../utils/reading-time";
|
||||||
|
import { resolveBlogSiteIdentity } from "../../utils/site-identity";
|
||||||
|
|
||||||
|
const POSTS_PER_PAGE = 7;
|
||||||
|
|
||||||
|
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
|
||||||
|
getEmDashCollection("posts", {
|
||||||
|
orderBy: { published_at: "desc" },
|
||||||
|
limit: POSTS_PER_PAGE + 1,
|
||||||
|
}),
|
||||||
|
getSiteSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
|
||||||
|
Astro.cache.set(cacheHint);
|
||||||
|
|
||||||
|
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
|
||||||
|
const hasMorePosts = posts.length > POSTS_PER_PAGE;
|
||||||
|
|
||||||
|
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
|
||||||
|
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
|
||||||
|
const gridPosts = visiblePosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
|
||||||
|
|
||||||
|
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 ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
function formatDate(date: Date | null | undefined) {
|
||||||
|
if (!date) return null;
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogBase title={siteTitle} description={siteTagline}>
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No posts yet</h2>
|
||||||
|
<p>Create your first post in the admin panel.</p>
|
||||||
|
<a href="/_emdash/admin">Open Admin</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="posts-page">
|
||||||
|
{featuredPost && (
|
||||||
|
<div class="featured-post">
|
||||||
|
<a href={`/posts/${featuredPost.id}`} class="featured-image">
|
||||||
|
<Image asset={featuredPost.data.featured_image} alt="" width="800" height="450" />
|
||||||
|
</a>
|
||||||
|
<div class="featured-content">
|
||||||
|
{featuredBylines.length > 0 && (
|
||||||
|
<div class="bylines">
|
||||||
|
{featuredBylines.slice(0, 2).map((credit, index) => (
|
||||||
|
<span class="byline-credit">
|
||||||
|
{index > 0 && <span class="separator">,</span>}
|
||||||
|
{credit.byline.avatarMediaId && (
|
||||||
|
<img
|
||||||
|
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||||
|
alt={credit.byline.displayName}
|
||||||
|
class="byline-avatar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{credit.byline.displayName}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{featuredBylines.length > 2 && (
|
||||||
|
<span class="byline-overflow">+{featuredBylines.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{formatDate(featuredPost.data.publishedAt) && (
|
||||||
|
<time>{formatDate(featuredPost.data.publishedAt)}</time>
|
||||||
|
)}
|
||||||
|
<span>{getReadingTime(featuredPost.data.content)} min read</span>
|
||||||
|
<h1>
|
||||||
|
<a href={`/posts/${featuredPost.id}`}>{featuredPost.data.title}</a>
|
||||||
|
</h1>
|
||||||
|
{featuredPost.data.excerpt && (
|
||||||
|
<p>{featuredPost.data.excerpt}</p>
|
||||||
|
)}
|
||||||
|
{featuredTags.length > 0 && (
|
||||||
|
<div class="tags">
|
||||||
|
{featuredTags.map((tag) => (
|
||||||
|
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gridPostsWithTags.length > 0 && (
|
||||||
|
<div class="posts-grid">
|
||||||
|
{gridPostsWithTags.map(({ post, tags, bylines }) => (
|
||||||
|
<PostCard
|
||||||
|
post={post}
|
||||||
|
tags={tags}
|
||||||
|
bylines={bylines}
|
||||||
|
href={`/posts/${post.id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</BlogBase>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-20, 5rem) var(--spacing-4, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--spacing-4, 1rem);
|
||||||
|
padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);
|
||||||
|
background: var(--color-primary, #0066cc);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-page {
|
||||||
|
max-width: var(--wide-width, 1200px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-post {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-8, 2rem);
|
||||||
|
margin-bottom: var(--spacing-12, 3rem);
|
||||||
|
padding-bottom: var(--spacing-12, 3rem);
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-image {
|
||||||
|
display: block;
|
||||||
|
border-radius: var(--radius-lg, 8px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-2, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content .bylines {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2, 0.5rem);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline-avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content time {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content h1 {
|
||||||
|
font-size: var(--font-size-4xl, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: var(--leading-tight, 1.15);
|
||||||
|
letter-spacing: var(--tracking-tight, -0.03em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content h1 a {
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content h1 a:hover {
|
||||||
|
color: var(--color-primary, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content p {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
line-height: var(--leading-relaxed, 1.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content .tags {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2, 0.5rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content .tags a {
|
||||||
|
font-size: var(--font-size-xs, 0.8125rem);
|
||||||
|
padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
|
||||||
|
background: var(--color-surface, #f7f7f7);
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-fast, 120ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-content .tags a:hover {
|
||||||
|
background: var(--color-primary, #0066cc);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.featured-post {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.posts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
src/pages/posts/posts-index.astro
Normal file
198
src/pages/posts/posts-index.astro
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
---
|
||||||
|
import { getEmDashCollection, getTermsForEntries } from "emdash";
|
||||||
|
import BlogBase from "../../layouts/BlogBase.astro";
|
||||||
|
import { getReadingTime } from "../../utils/reading-time";
|
||||||
|
|
||||||
|
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
|
||||||
|
orderBy: { published_at: "desc" },
|
||||||
|
});
|
||||||
|
Astro.cache.set(cacheHint);
|
||||||
|
|
||||||
|
const tagsByEntry = await getTermsForEntries(
|
||||||
|
"posts",
|
||||||
|
posts.map((p) => p.data.id),
|
||||||
|
"tag",
|
||||||
|
);
|
||||||
|
|
||||||
|
const postsWithTags = posts.map((post) => ({
|
||||||
|
post,
|
||||||
|
tags: tagsByEntry.get(post.data.id) ?? [],
|
||||||
|
bylines: post.data.bylines ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formatDate = (date: Date) =>
|
||||||
|
date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogBase title="All Posts" description="Browse all posts">
|
||||||
|
<div class="posts-list-page">
|
||||||
|
<h1>All Posts</h1>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p class="empty">No posts yet.</p>
|
||||||
|
) : (
|
||||||
|
<div class="post-list">
|
||||||
|
{postsWithTags.map(({ post, tags, bylines }) => (
|
||||||
|
<article class="post-card">
|
||||||
|
<header>
|
||||||
|
{bylines.length > 0 && (
|
||||||
|
<div class="byline-avatars">
|
||||||
|
{bylines.slice(0, 2).map((credit, index) => (
|
||||||
|
<>
|
||||||
|
{index > 0 && <span>, </span>}
|
||||||
|
{credit.byline.avatarMediaId && (
|
||||||
|
<img
|
||||||
|
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||||
|
alt={credit.byline.displayName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{credit.byline.displayName}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{bylines.length > 2 && (
|
||||||
|
<span>+{bylines.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{post.data.publishedAt && (
|
||||||
|
<time datetime={post.data.publishedAt.toISOString()}>
|
||||||
|
{formatDate(post.data.publishedAt)}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
<span>{getReadingTime(post.data.content)} min read</span>
|
||||||
|
</header>
|
||||||
|
<h2>
|
||||||
|
<a href={`/posts/${post.id}`}>{post.data.title}</a>
|
||||||
|
</h2>
|
||||||
|
<hr />
|
||||||
|
{post.data.excerpt && (
|
||||||
|
<p class="post-excerpt">{post.data.excerpt}</p>
|
||||||
|
)}
|
||||||
|
<a href={`/posts/${post.id}`} class="read-more">Read more →</a>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div class="tag-list">
|
||||||
|
{tags.slice(0, 3).map((t) => (
|
||||||
|
<a href={`/tag/${t.slug}`} class="tag">{t.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BlogBase>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.posts-list-page {
|
||||||
|
max-width: var(--content-width, 680px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-4xl, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
letter-spacing: var(--tracking-tight, -0.03em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
padding: var(--spacing-12, 3rem) 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card {
|
||||||
|
padding: var(--spacing-6, 1.5rem) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-3, 0.75rem);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
margin-bottom: var(--spacing-2, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline-avatars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline-avatars img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card h2 {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: var(--leading-snug, 1.3);
|
||||||
|
letter-spacing: var(--tracking-snug, -0.02em);
|
||||||
|
margin-bottom: var(--spacing-2, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card h2 a {
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card h2 a:hover {
|
||||||
|
color: var(--color-primary, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
margin: var(--spacing-4, 1rem) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-excerpt {
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
line-height: var(--leading-normal, 1.5);
|
||||||
|
margin-bottom: var(--spacing-4, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-more {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--color-primary, #0066cc);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--spacing-4, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-more:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2, 0.5rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: var(--font-size-xs, 0.8125rem);
|
||||||
|
padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
|
||||||
|
background: var(--color-surface, #f7f7f7);
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-fast, 120ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
background: var(--color-primary, #0066cc);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
src/pages/rss.xml.ts
Normal file
68
src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { getEmDashCollection, getSiteSettings } from "emdash";
|
||||||
|
import { resolveBlogSiteIdentity } from "../utils/site-identity";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ site, url }) => {
|
||||||
|
const siteUrl = site?.toString() || url.origin;
|
||||||
|
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
|
||||||
|
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(siteTagline)}</description>
|
||||||
|
<link>${siteUrl}</link>
|
||||||
|
<language>en-us</language>
|
||||||
|
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||||||
|
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml" />
|
||||||
|
${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, "&"],
|
||||||
|
[/</g, "<"],
|
||||||
|
[/>/g, ">"],
|
||||||
|
[/"/g, """],
|
||||||
|
[/'/g, "'"],
|
||||||
|
] 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;
|
||||||
|
}
|
||||||
141
src/pages/search.astro
Normal file
141
src/pages/search.astro
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
import { getEmDashCollection } from "emdash";
|
||||||
|
import BlogBase from "../layouts/BlogBase.astro";
|
||||||
|
import PostCard from "../components/PostCard.astro";
|
||||||
|
import { getReadingTime } from "../utils/reading-time";
|
||||||
|
|
||||||
|
const query = Astro.url.searchParams.get("q")?.trim() || "";
|
||||||
|
let results: any[] = [];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const { entries, cacheHint } = await getEmDashCollection("posts", {
|
||||||
|
search: query,
|
||||||
|
orderBy: { published_at: "desc" },
|
||||||
|
});
|
||||||
|
Astro.cache.set(cacheHint);
|
||||||
|
results = entries;
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogBase title="Search" description="Search posts">
|
||||||
|
<div class="search-page">
|
||||||
|
<h1>Search</h1>
|
||||||
|
|
||||||
|
<form class="search-form" method="get">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
value={query}
|
||||||
|
placeholder="Search posts..."
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{query ? (
|
||||||
|
results.length > 0 ? (
|
||||||
|
<p class="result-count">{results.length} result{results.length !== 1 ? "s" : ""} for "{query}"</p>
|
||||||
|
) : (
|
||||||
|
<p class="no-results">No results found for "{query}"</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p class="prompt">Enter a search term to find posts</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div class="results-grid">
|
||||||
|
{results.map((post) => (
|
||||||
|
<PostCard
|
||||||
|
post={post}
|
||||||
|
tags={[]}
|
||||||
|
bylines={post.data.bylines ?? []}
|
||||||
|
href={`/posts/${post.id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BlogBase>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.search-page {
|
||||||
|
max-width: var(--wide-width, 1200px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-4xl, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-6, 1.5rem);
|
||||||
|
letter-spacing: var(--tracking-tight, -0.03em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-3, 0.75rem);
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: var(--spacing-3, 0.75rem) var(--spacing-4, 1rem);
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
border: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
background: var(--color-bg, #ffffff);
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form button {
|
||||||
|
padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-primary, #0066cc);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast, 120ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form button:hover {
|
||||||
|
background: var(--color-primary-hover, #0052a3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count {
|
||||||
|
color: var(--color-text-secondary, #525252);
|
||||||
|
margin-bottom: var(--spacing-6, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results,
|
||||||
|
.prompt {
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
padding: var(--spacing-12, 3rem) 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.results-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.results-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
89
src/pages/tag/[slug].astro
Normal file
89
src/pages/tag/[slug].astro
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
import { getTerm, getEmDashCollection, getTermsForEntries, decodeSlug } from "emdash";
|
||||||
|
import BlogBase from "../../layouts/BlogBase.astro";
|
||||||
|
import PostCard from "../../components/PostCard.astro";
|
||||||
|
|
||||||
|
const slug = decodeSlug(Astro.params.slug);
|
||||||
|
const term = slug ? await getTerm("tag", slug) : null;
|
||||||
|
|
||||||
|
if (!term) {
|
||||||
|
return Astro.redirect("/404");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
|
||||||
|
where: { tag: term.slug },
|
||||||
|
orderBy: { published_at: "desc" },
|
||||||
|
});
|
||||||
|
Astro.cache.set(cacheHint);
|
||||||
|
|
||||||
|
const tagsByEntry = await getTermsForEntries(
|
||||||
|
"posts",
|
||||||
|
posts.map((p) => p.data.id),
|
||||||
|
"tag",
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredPosts = posts.map((post) => ({
|
||||||
|
post,
|
||||||
|
tags: tagsByEntry.get(post.data.id) ?? [],
|
||||||
|
}));
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogBase title={`Tag: ${term.label}`} description={`Posts tagged with "${term.label}"`}>
|
||||||
|
<div class="archive-page">
|
||||||
|
<h1>Posts tagged: {term.label}</h1>
|
||||||
|
|
||||||
|
{filteredPosts.length === 0 ? (
|
||||||
|
<p class="empty">No posts with this tag yet.</p>
|
||||||
|
) : (
|
||||||
|
<div class="posts-grid">
|
||||||
|
{filteredPosts.map(({ post, tags }) => (
|
||||||
|
<PostCard
|
||||||
|
post={post}
|
||||||
|
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
|
||||||
|
bylines={post.data.bylines ?? []}
|
||||||
|
href={`/posts/${post.id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BlogBase>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.archive-page {
|
||||||
|
max-width: var(--wide-width, 1200px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-4xl, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-8, 2rem);
|
||||||
|
letter-spacing: var(--tracking-tight, -0.03em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-muted, #8b8b8b);
|
||||||
|
padding: var(--spacing-12, 3rem) 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.posts-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.posts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
src/utils/reading-time.ts
Normal file
54
src/utils/reading-time.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { PortableTextBlock } from "emdash";
|
||||||
|
const WORDS_PER_MINUTE = 200;
|
||||||
|
const CJK_CHARACTERS_PER_MINUTE = 500;
|
||||||
|
const WHITESPACE_REGEX = /\s+/;
|
||||||
|
const CJK_CHARACTER_REGEX = /\p{Script=Han}|\p{Script=Hangul}|\p{Script=Hiragana}|\p{Script=Katakana}/gu;
|
||||||
|
|
||||||
|
type PortableTextSpan = {
|
||||||
|
_type: string;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PortableTextTextBlock = PortableTextBlock & {
|
||||||
|
_type: "block";
|
||||||
|
children: PortableTextSpan[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock {
|
||||||
|
return block._type === "block" && Array.isArray(block.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWords(text: string): number {
|
||||||
|
return text.split(WHITESPACE_REGEX).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCjkCharacters(text: string): number {
|
||||||
|
return text.match(CJK_CHARACTER_REGEX)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractText(blocks: PortableTextBlock[] | undefined): string {
|
||||||
|
if (!blocks || !Array.isArray(blocks)) return "";
|
||||||
|
return blocks
|
||||||
|
.filter(isTextBlock)
|
||||||
|
.map((block) =>
|
||||||
|
block.children
|
||||||
|
.filter((child) => child._type === "span" && typeof child.text === "string")
|
||||||
|
.map((span) => span.text)
|
||||||
|
.join(""),
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReadingTime(content: PortableTextBlock[] | undefined): number {
|
||||||
|
const text = extractText(content);
|
||||||
|
const cjkCharacterCount = countCjkCharacters(text);
|
||||||
|
const wordCount = countWords(text.replace(CJK_CHARACTER_REGEX, " "));
|
||||||
|
const minutes = Math.ceil(
|
||||||
|
wordCount / WORDS_PER_MINUTE + cjkCharacterCount / CJK_CHARACTERS_PER_MINUTE,
|
||||||
|
);
|
||||||
|
return Math.max(1, minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReadingTime(minutes: number): string {
|
||||||
|
return `${minutes} min read`;
|
||||||
|
}
|
||||||
20
src/utils/site-identity.ts
Normal file
20
src/utils/site-identity.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { MediaReference } from "emdash";
|
||||||
|
|
||||||
|
export interface BlogSiteIdentitySettings {
|
||||||
|
title?: string;
|
||||||
|
tagline?: string;
|
||||||
|
logo?: MediaReference;
|
||||||
|
favicon?: MediaReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SITE_TITLE = "My Blog";
|
||||||
|
const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas.";
|
||||||
|
|
||||||
|
export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) {
|
||||||
|
return {
|
||||||
|
siteTitle: settings?.title ?? DEFAULT_SITE_TITLE,
|
||||||
|
siteTagline: settings?.tagline ?? DEFAULT_SITE_TAGLINE,
|
||||||
|
siteLogo: settings?.logo?.url ? settings.logo : null,
|
||||||
|
siteFavicon: settings?.favicon?.url ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user