Initial commit: EmDash blog template
Fixed index.astro: escaped curly braces in code display block to prevent Astro parser misinterpreting them as expressions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
279
src/components/PostCard.astro
Normal file
279
src/components/PostCard.astro
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
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="post-card">
|
||||
<a href={href} class="card-link">
|
||||
{
|
||||
featuredImage ? (
|
||||
<div class="card-image">
|
||||
<Image image={featuredImage} />
|
||||
</div>
|
||||
) : (
|
||||
<div class="card-placeholder" />
|
||||
)
|
||||
}
|
||||
<div class="card-body">
|
||||
<div class="card-meta">
|
||||
{
|
||||
bylines && bylines.length > 0 && (
|
||||
<>
|
||||
<div class="card-bylines">
|
||||
{bylines.slice(0, 1).map((credit) => (
|
||||
<span class="card-byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="card-byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<span class="card-byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{bylines.length > 1 && (
|
||||
<span
|
||||
class="byline-more"
|
||||
data-tooltip={bylines
|
||||
.slice(1)
|
||||
.map((c) => c.byline.displayName)
|
||||
.join(", ")}
|
||||
title={bylines
|
||||
.slice(1)
|
||||
.map((c) => c.byline.displayName)
|
||||
.join(", ")}
|
||||
tabindex="0"
|
||||
>
|
||||
+{bylines.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(formattedDate || readingTime) && <span class="meta-dot" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{formattedDate && <time>{formattedDate}</time>}
|
||||
{formattedDate && readingTime && <span class="meta-dot" />}
|
||||
{readingTime && <span>{readingTime} min</span>}
|
||||
</div>
|
||||
<h2 class="card-title">{title}</h2>
|
||||
{excerpt && <p class="card-excerpt">{excerpt}</p>}
|
||||
</div>
|
||||
</a>
|
||||
{
|
||||
tags && tags.length > 0 && (
|
||||
<div class="card-tags">
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<a href={`/tag/${tag.slug}`} class="card-tag">
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.post-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .card-image img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.card-placeholder {
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-2);
|
||||
row-gap: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.card-meta time,
|
||||
.card-meta span:not(.meta-dot) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-snug);
|
||||
letter-spacing: var(--tracking-snug);
|
||||
margin-bottom: var(--spacing-2);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-link:hover .card-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.card-excerpt {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-3);
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Byline styles */
|
||||
.card-bylines {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.card-byline-avatar {
|
||||
width: var(--avatar-size-xs);
|
||||
height: var(--avatar-size-xs);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-byline-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.byline-more {
|
||||
position: relative;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
margin-left: 2px;
|
||||
cursor: default;
|
||||
border-radius: var(--radius);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.byline-more:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.byline-more[data-tooltip]:hover::after,
|
||||
.byline-more[data-tooltip]:focus-visible::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
45
src/components/TagList.astro
Normal file
45
src/components/TagList.astro
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
interface Props {
|
||||
tags: Array<{ slug: string; label: string }>;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { tags, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
{tags.length > 0 && (
|
||||
<ul class:list={["tag-list", className]}>
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a href={`/tag/${tag.slug}`} class="tag">{tag.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
1011
src/layouts/Base.astro
Normal file
1011
src/layouts/Base.astro
Normal file
File diff suppressed because it is too large
Load Diff
13
src/live.config.ts
Normal file
13
src/live.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* EmDash Live Content Collections
|
||||
*
|
||||
* Defines the _emdash collection that handles all content types from the database.
|
||||
* Query specific types using getEmDashCollection() and getEmDashEntry().
|
||||
*/
|
||||
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
33
src/pages/404.astro
Normal file
33
src/pages/404.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base title="Page not found">
|
||||
<div class="not-found">
|
||||
<h1>404</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="/">Go back home</a>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: var(--spacing-24) var(--spacing-6);
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: var(--font-size-5xl);
|
||||
margin-bottom: var(--spacing-2);
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
.not-found p {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.not-found a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
129
src/pages/category/[slug].astro
Normal file
129
src/pages/category/[slug].astro
Normal file
@@ -0,0 +1,129 @@
|
||||
---
|
||||
import {
|
||||
getTerm,
|
||||
getEmDashCollection,
|
||||
getTermsForEntries,
|
||||
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 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);
|
||||
|
||||
// Single batched query for tags on every post in this category, rather
|
||||
// than calling getEntryTerms() per post (which would be one round-trip
|
||||
// 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={`${term.label} posts`} description={`All posts in ${term.label}`}>
|
||||
<section class="archive-section">
|
||||
<header class="archive-header">
|
||||
<span class="archive-label">Category</span>
|
||||
<h1 class="archive-title">{term.label}</h1>
|
||||
<p class="archive-count">
|
||||
{filteredPosts.length}
|
||||
{filteredPosts.length === 1 ? "post" : "posts"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{
|
||||
filteredPosts.length === 0 ? (
|
||||
<p class="no-posts">No posts in this category yet.</p>
|
||||
) : (
|
||||
<div class="posts-grid">
|
||||
{filteredPosts.map(({ post, tags }) => (
|
||||
<PostCard
|
||||
title={post.data.title}
|
||||
excerpt={post.data.excerpt}
|
||||
featuredImage={post.data.featured_image}
|
||||
href={`/posts/${post.id}`}
|
||||
date={post.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(post.data.content)}
|
||||
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.archive-section {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
}
|
||||
|
||||
.archive-header {
|
||||
margin-bottom: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.archive-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.archive-title {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.archive-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-12) var(--spacing-8);
|
||||
}
|
||||
|
||||
.no-posts {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
544
src/pages/index.astro
Normal file
544
src/pages/index.astro
Normal file
@@ -0,0 +1,544 @@
|
||||
---
|
||||
import { getSiteSettings } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
---
|
||||
<Base title="EmDash CMS - Self-Hosted for Astro" description="Fully self-hosted CMS for Astro. No cloud required, fully local, with admin panel, authentication, and plugin system.">
|
||||
<main class="landing">
|
||||
<!-- Hero Section -->
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="badge">Open Source • 10k+ Stars</div>
|
||||
<h1 class="hero-title">
|
||||
The CMS that<br />
|
||||
<span class="accent">runs on your server</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
EmDash is a full-stack TypeScript CMS built on Astro.
|
||||
No cloud account required, no external dependencies.
|
||||
Just a complete admin panel and your content.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/_emdash/admin" class="btn btn-primary">
|
||||
<span class="btn-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18M9 21V9"/>
|
||||
</svg>
|
||||
</span>
|
||||
Open Admin Panel
|
||||
</a>
|
||||
<a href="https://github.com/emdash-cms/emdash" class="btn btn-secondary" target="_blank">
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
<div class="code-window">
|
||||
<div class="code-header">
|
||||
<span class="dot red"></span>
|
||||
<span class="dot yellow"></span>
|
||||
<span class="dot green"></span>
|
||||
<span class="code-title">astro.config.mjs</span>
|
||||
</div>
|
||||
<pre class="code-content"><code><span class="keyword">import</span> emdash <span class="keyword">from</span> <span class="string">"emdash/astro"</span>;
|
||||
<span class="keyword">import</span> { betterSqlite } <span class="keyword">from</span> <span class="string">"emdash/db"</span>;
|
||||
|
||||
<span class="keyword">export default</span> defineConfig({
|
||||
<span class="property">integrations</span>: [
|
||||
emdash({
|
||||
<span class="property">database</span>: betterSqlite({
|
||||
<span class="property">databasePath</span>: <span class="string">"./data.db"</span>
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="features" id="features">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Everything you need</h2>
|
||||
<p class="section-subtitle">A complete CMS without the vendor lock-in</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Fully Self-Hosted</h3>
|
||||
<p class="feature-desc">
|
||||
SQLite, D1, Turso, or PostgreSQL. Your data stays on your servers.
|
||||
No cloud account required.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18M9 21V9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Admin Panel</h3>
|
||||
<p class="feature-desc">
|
||||
Visual schema builder, media library, navigation menus.
|
||||
Full admin at /_emdash/admin
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Passkey Auth</h3>
|
||||
<p class="feature-desc">
|
||||
WebAuthn passkey-first authentication with OAuth and magic link fallbacks.
|
||||
Role-based access control.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Built-in MCP</h3>
|
||||
<p class="feature-desc">
|
||||
Model Context Protocol server for AI tools.
|
||||
Claude and ChatGPT can interact with your site directly.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16 18 22 12 16 6"/>
|
||||
<polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Plugin System</h3>
|
||||
<p class="feature-desc">
|
||||
Sandboxed plugins on Cloudflare Workers.
|
||||
Define capabilities, run safely in isolation.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">WordPress Import</h3>
|
||||
<p class="feature-desc">
|
||||
Import posts, pages, media, and taxonomies from WXR exports,
|
||||
REST API, or WordPress.com.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comparison Section -->
|
||||
<section class="comparison">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">EmDash vs Tina CMS</h2>
|
||||
<p class="section-subtitle">Both work with Astro, but differ in approach</p>
|
||||
</div>
|
||||
<div class="comparison-table">
|
||||
<div class="comparison-header">
|
||||
<div class="comparison-cell header">Feature</div>
|
||||
<div class="comparison-cell header">EmDash</div>
|
||||
<div class="comparison-cell header">Tina CMS</div>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<div class="comparison-cell label">Self-hosted</div>
|
||||
<div class="comparison-cell emdash">Fully local (SQLite)</div>
|
||||
<div class="comparison-cell tina">Needs Tina Cloud</div>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<div class="comparison-cell label">Admin URL</div>
|
||||
<div class="comparison-cell emdash">/_emdash/admin</div>
|
||||
<div class="comparison-cell tina">/admin</div>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<div class="comparison-cell label">Database</div>
|
||||
<div class="comparison-cell emdash">SQLite, D1, PostgreSQL</div>
|
||||
<div class="comparison-cell tina">Git-based</div>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<div class="comparison-cell label">Setup</div>
|
||||
<div class="comparison-cell emdash">Template-based</div>
|
||||
<div class="comparison-cell tina">Manual config</div>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<div class="comparison-cell label">Auth</div>
|
||||
<div class="comparison-cell emdash">Passkey + OAuth</div>
|
||||
<div class="comparison-cell tina">Git-based</div>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<div class="comparison-cell label">Price</div>
|
||||
<div class="comparison-cell emdash">Free (open source)</div>
|
||||
<div class="comparison-cell tina">Free tier + paid plans</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="cta">
|
||||
<div class="cta-content">
|
||||
<h2 class="cta-title">Ready to get started?</h2>
|
||||
<p class="cta-subtitle">
|
||||
Clone the template, run bootstrap, and you're ready to build.
|
||||
</p>
|
||||
<div class="cta-actions">
|
||||
<a href="/_emdash/admin" class="btn btn-primary btn-large">
|
||||
Try Admin Panel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.landing {
|
||||
--color-bg: #0a0a0f;
|
||||
--color-surface: #14141f;
|
||||
--color-surface-hover: #1a1a2e;
|
||||
--color-border: #2a2a3e;
|
||||
--color-text: #f0f0f5;
|
||||
--color-text-secondary: #a0a0b0;
|
||||
--color-muted: #6a6a80;
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #818cf8;
|
||||
--color-emdash: #10b981;
|
||||
--color-tina: #f59e0b;
|
||||
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 6rem 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.accent {
|
||||
color: var(--color-emdash);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-emdash);
|
||||
color: #0a0a0f;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.code-window {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-hover);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.red { background: #ef4444; }
|
||||
.dot.yellow { background: #eab308; }
|
||||
.dot.green { background: #22c55e; }
|
||||
|
||||
.code-title {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 1.5rem;
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.keyword { color: #c678dd; }
|
||||
.string { color: #98c379; }
|
||||
.property { color: #e5c07b; }
|
||||
|
||||
.features {
|
||||
padding: 6rem 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--color-emdash);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-emdash), #059669);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
padding: 6rem 2rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comparison-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
background: var(--color-surface-hover);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comparison-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comparison-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comparison-cell {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.comparison-cell.header {
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.comparison-cell.label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.comparison-cell.emdash {
|
||||
color: var(--color-emdash);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.comparison-cell.tina {
|
||||
color: var(--color-tina);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cta {
|
||||
padding: 8rem 2rem;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-surface) 100%);
|
||||
}
|
||||
|
||||
.cta-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cta-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.comparison-header,
|
||||
.comparison-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.comparison-cell.header:last-child,
|
||||
.comparison-cell:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
src/pages/pages/[slug].astro
Normal file
108
src/pages/pages/[slug].astro
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
import { getEmDashEntry, decodeSlug } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const slug = decodeSlug(Astro.params.slug);
|
||||
|
||||
if (!slug) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);
|
||||
|
||||
if (!page) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
Astro.cache.set(cacheHint);
|
||||
---
|
||||
|
||||
<Base
|
||||
title={page.data.title}
|
||||
content={{ collection: "pages", id: page.data.id, slug }}
|
||||
>
|
||||
<article class="page-article">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title" {...page.edit.title}>{page.data.title}</h1>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
<PortableText value={page.data.content} />
|
||||
</div>
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.page-article {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-16) var(--spacing-6) var(--spacing-16);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
|
||||
font-weight: 700;
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.page-content :global(p) {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.page-content :global(h2) {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.page-content :global(h3) {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.page-content :global(blockquote) {
|
||||
margin: 1.5em 0;
|
||||
padding-left: var(--spacing-6);
|
||||
border-left: 3px solid var(--color-border);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.page-content :global(pre) {
|
||||
margin: 1.5em 0;
|
||||
padding: var(--spacing-4);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.page-content :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--color-surface);
|
||||
padding: 0.15em 0.3em;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.page-content :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-content :global(ul),
|
||||
.page-content :global(ol) {
|
||||
margin-bottom: 1.5em;
|
||||
padding-left: var(--spacing-5);
|
||||
}
|
||||
|
||||
.page-content :global(li) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
</style>
|
||||
970
src/pages/posts/[slug].astro
Normal file
970
src/pages/posts/[slug].astro
Normal file
@@ -0,0 +1,970 @@
|
||||
---
|
||||
import {
|
||||
getEmDashEntry,
|
||||
getEmDashCollection,
|
||||
getEntryTerms,
|
||||
getTermsForEntries,
|
||||
getSeoMeta,
|
||||
decodeSlug,
|
||||
getSiteSettings,
|
||||
} from "emdash";
|
||||
import {
|
||||
Image,
|
||||
PortableText,
|
||||
Comments,
|
||||
CommentForm,
|
||||
WidgetArea,
|
||||
} from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import PostCard from "../../components/PostCard.astro";
|
||||
import { getReadingTime } from "../../utils/reading-time";
|
||||
import { resolveBlogSiteIdentity } from "../../utils/site-identity";
|
||||
|
||||
const slug = decodeSlug(Astro.params.slug);
|
||||
|
||||
if (!slug) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
// Get featured image URL for OG fallback
|
||||
// The image may have src (external) or meta.storageKey (local)
|
||||
function getImageUrl(img: unknown): string | undefined {
|
||||
if (!img || typeof img !== "object") return undefined;
|
||||
const image = img as Record<string, unknown>;
|
||||
// Check for direct src
|
||||
if (typeof image.src === "string" && image.src) {
|
||||
return image.src.startsWith("http")
|
||||
? image.src
|
||||
: `${Astro.url.origin}${image.src}`;
|
||||
}
|
||||
// Build from storageKey for local images
|
||||
const meta = image.meta as Record<string, unknown> | undefined;
|
||||
const storageKey =
|
||||
(typeof meta?.storageKey === "string" ? meta.storageKey : undefined) ||
|
||||
(typeof image.id === "string" ? image.id : undefined);
|
||||
if (storageKey) {
|
||||
return `${Astro.url.origin}/_emdash/api/media/file/${storageKey}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const featuredImageUrl = getImageUrl(post.data.featured_image);
|
||||
const { siteTitle } = resolveBlogSiteIdentity(await getSiteSettings());
|
||||
|
||||
// Generate SEO meta from content
|
||||
const seo = getSeoMeta(post, {
|
||||
siteTitle,
|
||||
siteUrl: Astro.url.origin,
|
||||
path: `/posts/${slug}`,
|
||||
defaultOgImage: featuredImageUrl,
|
||||
});
|
||||
|
||||
// Bylines are already hydrated by getEmDashEntry
|
||||
const bylines = post.data.bylines ?? [];
|
||||
|
||||
// Get reading time
|
||||
const readingTime = getReadingTime(post.data.content);
|
||||
|
||||
// Fetch this post's tags and the related-posts list in parallel — they're
|
||||
// independent queries, so running them concurrently halves the round-trip
|
||||
// cost on remote databases.
|
||||
// Note: post.id is the slug, post.data.id is the database ULID.
|
||||
const [tags, { entries: recentPosts }] = await Promise.all([
|
||||
getEntryTerms("posts", post.data.id, "tag"),
|
||||
// Fetch a few extra in case the current post is among them
|
||||
getEmDashCollection("posts", {
|
||||
orderBy: { published_at: "desc" },
|
||||
limit: 4,
|
||||
}),
|
||||
]);
|
||||
const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3);
|
||||
|
||||
// Single batched query for related-posts tags, rather than one
|
||||
// getEntryTerms() call per related post.
|
||||
const otherTagsByEntry = await getTermsForEntries(
|
||||
"posts",
|
||||
otherPosts.map((p) => p.data.id),
|
||||
"tag",
|
||||
);
|
||||
const otherPostsWithTags = otherPosts.map((p) => ({
|
||||
post: p,
|
||||
tags: otherTagsByEntry.get(p.data.id) ?? [],
|
||||
bylines: p.data.bylines ?? [],
|
||||
}));
|
||||
|
||||
const publishDate =
|
||||
post.data.publishedAt?.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}) ?? null;
|
||||
---
|
||||
|
||||
<Base
|
||||
title={seo.title}
|
||||
pageTitle={seo.ogTitle}
|
||||
description={seo.description}
|
||||
image={seo.ogImage}
|
||||
canonical={seo.canonical}
|
||||
robots={seo.robots}
|
||||
type="article"
|
||||
publishedTime={post.data.publishedAt?.toISOString() ?? null}
|
||||
modifiedTime={post.data.updatedAt.toISOString()}
|
||||
content={{ collection: "posts", id: post.data.id, slug }}
|
||||
>
|
||||
<article class="article">
|
||||
{/* Hero: Full-width featured image */}
|
||||
{
|
||||
post.data.featured_image && (
|
||||
<div class="article-hero" {...post.edit.featured_image}>
|
||||
<Image image={post.data.featured_image} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Three-column layout */}
|
||||
<div class="article-grid">
|
||||
{/* Left gutter: Meta information */}
|
||||
<aside class="article-meta-col">
|
||||
<div class="meta-sticky">
|
||||
{
|
||||
bylines.length > 0 && (
|
||||
<div class="meta-block byline-block">
|
||||
<span class="meta-label">
|
||||
{bylines.length === 1 ? "Author" : "Authors"}
|
||||
</span>
|
||||
<div class="bylines">
|
||||
{bylines.map((credit) => (
|
||||
<div class="byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<div class="byline-info">
|
||||
<span class="byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
{credit.roleLabel && (
|
||||
<span class="byline-role">{credit.roleLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
publishDate && (
|
||||
<div class="meta-block">
|
||||
<span class="meta-label">Published</span>
|
||||
<time class="meta-value">{publishDate}</time>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="meta-block">
|
||||
<span class="meta-label">Reading time</span>
|
||||
<span class="meta-value">{readingTime} min</span>
|
||||
</div>
|
||||
{
|
||||
tags.length > 0 && (
|
||||
<div class="meta-block">
|
||||
<span class="meta-label">Tags</span>
|
||||
<div class="meta-tags">
|
||||
{tags.map((t) => (
|
||||
<a href={`/tag/${t.slug}`} class="meta-tag">
|
||||
{t.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div class="article-main">
|
||||
<header class="article-header">
|
||||
<div class="article-meta">
|
||||
{
|
||||
bylines.length > 0 && (
|
||||
<>
|
||||
<span class="article-meta-byline">
|
||||
{bylines.map((credit, i) => (
|
||||
<>
|
||||
{i > 0 && ", "}
|
||||
{credit.byline.displayName}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
<span class="meta-dot" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
publishDate && (
|
||||
<>
|
||||
<time>{publishDate}</time>
|
||||
<span class="meta-dot" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span>{readingTime} min read</span>
|
||||
</div>
|
||||
<h1 class="article-title" {...post.edit.title}>{post.data.title}</h1>
|
||||
{
|
||||
post.data.excerpt && (
|
||||
<p class="article-excerpt" {...post.edit.excerpt}>{post.data.excerpt}</p>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="article-content">
|
||||
<PortableText value={post.data.content} />
|
||||
</div>
|
||||
|
||||
<div class="article-comments">
|
||||
<Comments collection="posts" contentId={post.data.id} threaded />
|
||||
<CommentForm collection="posts" contentId={post.data.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right gutter: TOC + Sidebar widgets */}
|
||||
<aside class="article-sidebar">
|
||||
<div class="sidebar-sticky">
|
||||
<nav class="toc" aria-label="Table of contents">
|
||||
<h4 class="toc-title">On this page</h4>
|
||||
<div class="toc-content" id="toc-content">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-widgets">
|
||||
<WidgetArea name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{
|
||||
otherPostsWithTags.length > 0 && (
|
||||
<section class="more-posts">
|
||||
<div class="more-inner">
|
||||
<h2 class="more-title">Continue reading</h2>
|
||||
<div class="more-grid">
|
||||
{otherPostsWithTags.map(
|
||||
({ post: p, tags: postTags, bylines: postBylines }) => (
|
||||
<PostCard
|
||||
title={p.data.title}
|
||||
excerpt={p.data.excerpt}
|
||||
featuredImage={p.data.featured_image}
|
||||
href={`/posts/${p.id}`}
|
||||
date={p.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(p.data.content)}
|
||||
tags={postTags.map((t) => ({ slug: t.slug, label: t.label }))}
|
||||
bylines={postBylines}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
// Build table of contents from h2/h3 headings
|
||||
function buildToc() {
|
||||
const content = document.querySelector(".article-content");
|
||||
const tocContainer = document.getElementById("toc-content");
|
||||
if (!content || !tocContainer) return;
|
||||
|
||||
const headings = content.querySelectorAll("h2, h3");
|
||||
if (headings.length === 0) {
|
||||
// Hide TOC if no headings
|
||||
const toc = document.querySelector(".toc") as HTMLElement | null;
|
||||
if (toc) toc.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "toc-list";
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
// Add ID if missing
|
||||
if (!heading.id) {
|
||||
heading.id = `heading-${index}`;
|
||||
}
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.className =
|
||||
heading.tagName === "H3" ? "toc-item toc-item--nested" : "toc-item";
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = `#${heading.id}`;
|
||||
link.className = "toc-link";
|
||||
link.textContent = heading.textContent;
|
||||
|
||||
li.appendChild(link);
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(list);
|
||||
|
||||
// Highlight current section on scroll
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const id = entry.target.id;
|
||||
const link = tocContainer.querySelector(`a[href="#${id}"]`);
|
||||
if (link) {
|
||||
if (entry.isIntersecting) {
|
||||
tocContainer
|
||||
.querySelectorAll(".toc-link")
|
||||
.forEach((l) => l.classList.remove("active"));
|
||||
link.classList.add("active");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-80px 0px -80% 0px" }
|
||||
);
|
||||
|
||||
headings.forEach((heading) => observer.observe(heading));
|
||||
}
|
||||
|
||||
buildToc();
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* Article container */
|
||||
.article {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Hero image - full width within container */
|
||||
.article-hero {
|
||||
margin: var(--spacing-16) var(--spacing-6);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-hero img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 500px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Three-column grid */
|
||||
.article-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--meta-col-width) minmax(0, var(--content-width))
|
||||
var(--gutter-width);
|
||||
gap: var(--spacing-10);
|
||||
justify-content: center;
|
||||
padding: 0 var(--spacing-6);
|
||||
margin: var(--spacing-16) 0;
|
||||
}
|
||||
|
||||
/* Left column: Meta */
|
||||
.article-meta-col {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.meta-sticky {
|
||||
position: sticky;
|
||||
top: calc(var(--nav-height) + var(--spacing-8));
|
||||
}
|
||||
|
||||
.meta-block {
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.meta-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Byline styles */
|
||||
.bylines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.byline-avatar {
|
||||
width: var(--avatar-size-lg);
|
||||
height: var(--avatar-size-lg);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.byline-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.byline-name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.byline-role {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Main content column */
|
||||
.article-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-header {
|
||||
margin-bottom: var(--spacing-10);
|
||||
}
|
||||
|
||||
.article-header .article-meta {
|
||||
display: none;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-3);
|
||||
row-gap: var(--spacing-1);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.article-meta-byline {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: clamp(2rem, 5vw, var(--font-size-5xl));
|
||||
font-weight: 700;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.article-excerpt {
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Article content typography */
|
||||
.article-content {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.article-content :global(p) {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.article-content :global(h2) {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-top: 2.5em;
|
||||
margin-bottom: 0.75em;
|
||||
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
|
||||
}
|
||||
|
||||
.article-content :global(h3) {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.5em;
|
||||
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
|
||||
}
|
||||
|
||||
.article-content :global(blockquote) {
|
||||
margin: 2em 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border-left: 3px solid var(--color-border);
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.article-content :global(pre) {
|
||||
margin: 2em 0;
|
||||
padding: var(--spacing-5);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.article-content :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--color-surface);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.article-content :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.article-content :global(ul),
|
||||
.article-content :global(ol) {
|
||||
margin-bottom: 1.5em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.article-content :global(li) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.article-content :global(img) {
|
||||
margin: 2em 0;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.article-content :global(hr) {
|
||||
margin: 3em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.article-content :global(a) {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
.article-content :global(a:hover) {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Right column: TOC + Sidebar */
|
||||
.article-sidebar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: sticky;
|
||||
top: calc(var(--nav-height) + var(--spacing-8));
|
||||
}
|
||||
|
||||
.toc {
|
||||
margin-bottom: var(--spacing-8);
|
||||
padding-bottom: var(--spacing-6);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.toc-title {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-list) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-item) {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-item--nested) {
|
||||
padding-left: var(--spacing-3);
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-link) {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
padding: var(--spacing-1) 0;
|
||||
transition: color var(--transition-fast);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-link:hover),
|
||||
.toc-content :global(.toc-link.active) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Sidebar widgets */
|
||||
.sidebar-widgets :global(.widget-area) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget) {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget__title) {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget__content) {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* Sidebar search widget */
|
||||
.sidebar-widgets :global(.widget-search) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-search__input) {
|
||||
width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-search__input)::placeholder {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-search__input):focus,
|
||||
.sidebar-widgets :global(.widget-search__input):focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-ring);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-search__button) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sidebar categories widget */
|
||||
.sidebar-widgets :global(.widget-categories) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories li) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-2) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories li:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories__link) {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories__link:hover) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories__count) {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface);
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Sidebar tags widget - pill style */
|
||||
.sidebar-widgets :global(.widget-tags__cloud) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-tags__cloud li) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-tags__link) {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-tags__link:hover) {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-tags__count) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sidebar recent posts widget */
|
||||
.sidebar-widgets :global(.widget-recent-posts) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-recent-posts li) {
|
||||
padding: var(--spacing-2) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-recent-posts li:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-recent-posts a) {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-recent-posts a:hover) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Sidebar archives widget */
|
||||
.sidebar-widgets :global(.widget-archives) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives li) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-2) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives li:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives__link) {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives__link:hover) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives__count) {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface);
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Comments section */
|
||||
.article-comments {
|
||||
margin-top: var(--spacing-16);
|
||||
padding-top: var(--spacing-10);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comments) {
|
||||
--ec-comment-border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comments-heading) {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-author) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-date) {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-body) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-form-field input),
|
||||
.article-comments :global(.ec-comment-form-field textarea) {
|
||||
background: var(--color-surface) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-user-info) {
|
||||
background: var(--color-surface) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-form-submit) {
|
||||
background: var(--color-accent) !important;
|
||||
color: var(--color-on-accent) !important;
|
||||
}
|
||||
|
||||
/* More posts section */
|
||||
.more-posts {
|
||||
background: var(--color-bg-subtle);
|
||||
padding: var(--spacing-16) 0;
|
||||
margin-top: var(--spacing-16);
|
||||
}
|
||||
|
||||
.more-inner {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-6);
|
||||
}
|
||||
|
||||
.more-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-10);
|
||||
}
|
||||
|
||||
.more-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(5, auto);
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1100px) {
|
||||
.article-grid {
|
||||
grid-template-columns: minmax(0, var(--content-width));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.article-meta-col,
|
||||
.article-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.article-header .article-meta {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.article-hero {
|
||||
margin: var(--spacing-4) var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.article-grid {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.more-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.article-title {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.more-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
272
src/pages/posts/index.astro
Normal file
272
src/pages/posts/index.astro
Normal file
@@ -0,0 +1,272 @@
|
||||
---
|
||||
import { getEmDashCollection, getTermsForEntries } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import { getReadingTime } from "../../utils/reading-time";
|
||||
|
||||
// Sort in the database rather than in JS — lets the DB use its index on
|
||||
// published_at and avoids a full-table scan on the client.
|
||||
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
// Single batched query for tags across all posts, instead of one
|
||||
// getEntryTerms() call per post (which would be N round-trips).
|
||||
// Bylines are already hydrated on entry.data.bylines.
|
||||
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",
|
||||
});
|
||||
---
|
||||
|
||||
<Base title="All Posts" description="Browse all blog posts">
|
||||
<div class="posts-page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">All Posts</h1>
|
||||
<p class="page-description">
|
||||
{posts.length}
|
||||
{posts.length === 1 ? "article" : "articles"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<p class="empty">No posts yet.</p>
|
||||
) : (
|
||||
<div class="posts-list">
|
||||
{postsWithTags.map(({ post, tags, bylines }) => (
|
||||
<article class="post-item">
|
||||
<a href={`/posts/${post.id}`} class="post-link">
|
||||
<div class="post-meta">
|
||||
{bylines.length > 0 && (
|
||||
<>
|
||||
<div class="post-bylines">
|
||||
{bylines.slice(0, 2).map((credit, index) => (
|
||||
<>
|
||||
{index > 0 && <span class="byline-sep">,</span>}
|
||||
<span class="post-byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="post-byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<span class="post-byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
{bylines.length > 2 && (
|
||||
<span class="byline-more">+{bylines.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="meta-dot" />
|
||||
</>
|
||||
)}
|
||||
{post.data.publishedAt && (
|
||||
<time>{formatDate(post.data.publishedAt)}</time>
|
||||
)}
|
||||
{post.data.publishedAt && <span class="meta-dot" />}
|
||||
<span>{getReadingTime(post.data.content)} min read</span>
|
||||
</div>
|
||||
<h2 class="post-title">{post.data.title}</h2>
|
||||
{post.data.excerpt && (
|
||||
<p class="post-excerpt">{post.data.excerpt}</p>
|
||||
)}
|
||||
</a>
|
||||
{tags.length > 0 && (
|
||||
<div class="post-tags">
|
||||
{tags.slice(0, 3).map((t) => (
|
||||
<a href={`/tag/${t.slug}`} class="post-tag">
|
||||
{t.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.posts-page {
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-12);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
padding: var(--spacing-8) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.post-item:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.post-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.post-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Post bylines */
|
||||
.post-bylines {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.post-byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.post-byline-avatar {
|
||||
width: var(--avatar-size-sm);
|
||||
height: var(--avatar-size-sm);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.post-byline-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.byline-sep {
|
||||
color: var(--color-muted);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.byline-more {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-snug);
|
||||
letter-spacing: var(--tracking-snug);
|
||||
margin-bottom: var(--spacing-2);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.post-link:hover .post-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.post-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.post-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.posts-page {
|
||||
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
70
src/pages/rss.xml.ts
Normal file
70
src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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>
|
||||
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(rss, {
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const XML_ESCAPE_PATTERNS = [
|
||||
[/&/g, "&"],
|
||||
[/</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;
|
||||
}
|
||||
182
src/pages/search.astro
Normal file
182
src/pages/search.astro
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { search } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
|
||||
const query = Astro.url.searchParams.get("q")?.trim() || "";
|
||||
|
||||
// Use the FTS-backed search() API instead of loading every post and
|
||||
// filtering in JS. FTS scales as the post count grows, returns ranked
|
||||
// results, and handles tokenization/stemming. Templates that grep all
|
||||
// post bodies in JS quickly become unusable past a few hundred posts.
|
||||
const { items: results } = query
|
||||
? await search(query, { collections: ["posts"], limit: 30 })
|
||||
: { items: [] };
|
||||
---
|
||||
|
||||
<Base
|
||||
title={query ? `Search: ${query}` : "Search"}
|
||||
description="Search blog posts"
|
||||
>
|
||||
<section class="search-page">
|
||||
<h1 class="search-title">Search</h1>
|
||||
|
||||
<form method="get" action="/search" class="search-form">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={query}
|
||||
placeholder="Search posts..."
|
||||
class="search-input"
|
||||
autofocus
|
||||
/>
|
||||
<button type="submit" class="search-button">Search</button>
|
||||
</form>
|
||||
|
||||
{
|
||||
query && (
|
||||
<p class="search-summary">
|
||||
{results.length === 0
|
||||
? `No results for "${query}"`
|
||||
: `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
results.length > 0 && (
|
||||
<ol class="search-results">
|
||||
{results.map((result) => (
|
||||
<li class="search-result">
|
||||
<a
|
||||
href={`/posts/${result.slug ?? result.id}`}
|
||||
class="result-link"
|
||||
>
|
||||
<h2 class="result-title">
|
||||
{result.title ?? "Untitled"}
|
||||
</h2>
|
||||
{result.snippet && (
|
||||
<p class="result-snippet" set:html={result.snippet} />
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
{!query && <p class="search-hint">Enter a search term to find posts.</p>}
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.search-page {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--font-size-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.search-button {
|
||||
padding: var(--spacing-2) var(--spacing-6);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.search-summary {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.search-hint {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding: var(--spacing-6) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.search-result:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.search-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-snug);
|
||||
margin-bottom: var(--spacing-2);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.result-link:hover .result-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.result-snippet {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* FTS returns <mark> wrapping the matched terms */
|
||||
.result-snippet :global(mark) {
|
||||
background: var(--color-accent-ring, rgba(99, 102, 241, 0.2));
|
||||
color: inherit;
|
||||
padding: 0 0.1em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
131
src/pages/tag/[slug].astro
Normal file
131
src/pages/tag/[slug].astro
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
import {
|
||||
getTerm,
|
||||
getEmDashCollection,
|
||||
getTermsForEntries,
|
||||
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 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);
|
||||
|
||||
// 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>
|
||||
<p class="archive-count">
|
||||
{filteredPosts.length}
|
||||
{filteredPosts.length === 1 ? "post" : "posts"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{
|
||||
filteredPosts.length === 0 ? (
|
||||
<p class="no-posts">No posts with this tag yet.</p>
|
||||
) : (
|
||||
<div class="posts-grid">
|
||||
{filteredPosts.map(({ post, tags }) => (
|
||||
<PostCard
|
||||
title={post.data.title}
|
||||
excerpt={post.data.excerpt}
|
||||
featuredImage={post.data.featured_image}
|
||||
href={`/posts/${post.id}`}
|
||||
date={post.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(post.data.content)}
|
||||
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.archive-section {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
}
|
||||
|
||||
.archive-header {
|
||||
margin-bottom: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.archive-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.archive-title {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.archive-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-12) var(--spacing-8);
|
||||
}
|
||||
|
||||
.no-posts {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
src/styles/theme.css
Normal file
108
src/styles/theme.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
theme.css -- override any :root variable here to retheme the blog.
|
||||
|
||||
This is the only file you need to edit to customize the site's visual
|
||||
appearance. All defaults are listed below as comments. Uncomment and
|
||||
change any value to override it.
|
||||
|
||||
Base.astro puts its defaults inside @layer base, so declarations here
|
||||
(which are unlayered) always take priority -- no specificity tricks needed.
|
||||
|
||||
Note: this template defines explicit dark mode colors in Base.astro.
|
||||
Overriding light-mode --color-* variables here won't affect dark mode.
|
||||
To customize dark mode, also override --color-* variables inside a
|
||||
@media (prefers-color-scheme: dark) block and/or in the :root.dark rule.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Colors ---
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-subtle: #fafafa;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-secondary: #525252;
|
||||
--color-muted: #8b8b8b;
|
||||
--color-border: #e5e5e5;
|
||||
--color-border-subtle: #f0f0f0;
|
||||
--color-surface: #f7f7f7;
|
||||
--color-accent: #0066cc;
|
||||
--color-accent-hover: #0052a3;
|
||||
--color-on-accent: white;
|
||||
--color-accent-ring: color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
*/
|
||||
|
||||
/* --- Type scale ---
|
||||
--font-size-xs: 0.8125rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 2rem;
|
||||
--font-size-4xl: 2.5rem;
|
||||
--font-size-5xl: 3.5rem;
|
||||
*/
|
||||
|
||||
/* --- Line heights ---
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.7;
|
||||
*/
|
||||
|
||||
/* --- Letter spacing ---
|
||||
--tracking-tight: -0.03em; used on h1 and large titles
|
||||
--tracking-snug: -0.02em; used on h2–h6, site/card titles
|
||||
--tracking-wide: 0.06em; used on meta labels, TOC/widget titles
|
||||
--tracking-wider: 0.08em; used on footer headings, section labels
|
||||
*/
|
||||
|
||||
/* --- Spacing ---
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-3: 0.75rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-5: 1.25rem;
|
||||
--spacing-6: 1.5rem;
|
||||
--spacing-8: 2rem;
|
||||
--spacing-10: 2.5rem;
|
||||
--spacing-12: 3rem;
|
||||
--spacing-16: 4rem;
|
||||
--spacing-20: 5rem;
|
||||
--spacing-24: 6rem;
|
||||
*/
|
||||
|
||||
/* --- Layout ---
|
||||
--content-width: 680px; article/page body column width
|
||||
--wide-width: 1200px; max container width (home, archives)
|
||||
--gutter-width: 200px; right sidebar column (TOC) on article pages
|
||||
--meta-col-width: 180px; left meta column on article pages
|
||||
--nav-height: 64px; sticky header height
|
||||
--search-input-width: 180px; nav search box width
|
||||
*/
|
||||
|
||||
/* --- Borders & radius ---
|
||||
--radius: 4px;
|
||||
--radius-lg: 8px;
|
||||
*/
|
||||
|
||||
/* --- Transitions ---
|
||||
--transition-fast: 120ms ease;
|
||||
--transition-base: 180ms ease;
|
||||
*/
|
||||
|
||||
/* --- Avatars ---
|
||||
--avatar-size-xs: 18px; card byline avatars
|
||||
--avatar-size-sm: 20px; post list byline avatars
|
||||
--avatar-size-md: 24px; featured post byline avatars
|
||||
--avatar-size-lg: 32px; single post byline avatars
|
||||
*/
|
||||
|
||||
/* --- Shadows ---
|
||||
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
*/
|
||||
|
||||
/* --- Misc ---
|
||||
--tag-padding-y: 2px; vertical padding on tag pills
|
||||
*/
|
||||
}
|
||||
66
src/utils/reading-time.ts
Normal file
66
src/utils/reading-time.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from Portable Text blocks
|
||||
*/
|
||||
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(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate reading time in minutes from Portable Text content
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reading time for display
|
||||
*/
|
||||
export function formatReadingTime(minutes: number): string {
|
||||
return `${minutes} min read`;
|
||||
}
|
||||
25
src/utils/site-identity.ts
Normal file
25
src/utils/site-identity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** Resolved media reference from getSiteSettings() */
|
||||
export interface MediaReference {
|
||||
mediaId: string;
|
||||
alt?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
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