Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
15
demos/postgres/CHANGELOG.md
Normal file
15
demos/postgres/CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# @emdash-cms/demo-postgres
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
|
||||
- emdash@0.0.3
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
|
||||
- emdash@0.0.2
|
||||
37
demos/postgres/astro.config.mjs
Normal file
37
demos/postgres/astro.config.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import node from "@astrojs/node";
|
||||
import react from "@astrojs/react";
|
||||
import { defineConfig, fontProviders } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { postgres } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: postgres({
|
||||
connectionString: process.env.DATABASE_URL || "postgres://localhost:5432/emdash_dev",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
fonts: [
|
||||
{
|
||||
provider: fontProviders.google(),
|
||||
name: "Inter",
|
||||
cssVariable: "--font-sans",
|
||||
weights: [400, 500, 600, 700],
|
||||
fallbacks: ["sans-serif"],
|
||||
},
|
||||
{
|
||||
provider: fontProviders.google(),
|
||||
name: "JetBrains Mono",
|
||||
cssVariable: "--font-mono",
|
||||
weights: [400, 500],
|
||||
fallbacks: ["monospace"],
|
||||
},
|
||||
],
|
||||
devToolbar: { enabled: false },
|
||||
});
|
||||
39
demos/postgres/emdash-env.d.ts
vendored
Normal file
39
demos/postgres/emdash-env.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
// Generated by EmDash on dev server start
|
||||
// Do not edit manually
|
||||
|
||||
/// <reference types="emdash/locals" />
|
||||
|
||||
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
content?: PortableTextBlock[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
|
||||
content?: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
}
|
||||
}
|
||||
30
demos/postgres/package.json
Normal file
30
demos/postgres/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@emdash-cms/demo-postgres",
|
||||
"version": "0.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"typecheck": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "catalog:",
|
||||
"@astrojs/react": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-router": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"emdash": "workspace:*",
|
||||
"pg": "^8.0.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"emdash": {
|
||||
"seed": "seed/seed.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:"
|
||||
}
|
||||
}
|
||||
778
demos/postgres/seed/seed.json
Normal file
778
demos/postgres/seed/seed.json
Normal file
@@ -0,0 +1,778 @@
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {
|
||||
"name": "Blog Starter",
|
||||
"description": "A blog with posts and pages",
|
||||
"author": "EmDash"
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"title": "My Blog",
|
||||
"tagline": "Thoughts on building for the web"
|
||||
},
|
||||
|
||||
"collections": [
|
||||
{
|
||||
"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": "pages",
|
||||
"label": "Pages",
|
||||
"labelSingular": "Page",
|
||||
"supports": ["drafts", "revisions", "search"],
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"searchable": true
|
||||
},
|
||||
{
|
||||
"slug": "content",
|
||||
"label": "Content",
|
||||
"type": "portableText",
|
||||
"searchable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"taxonomies": [
|
||||
{
|
||||
"name": "category",
|
||||
"label": "Categories",
|
||||
"labelSingular": "Category",
|
||||
"hierarchical": true,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "development", "label": "Development" },
|
||||
{ "slug": "design", "label": "Design" },
|
||||
{ "slug": "notes", "label": "Notes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tag",
|
||||
"label": "Tags",
|
||||
"labelSingular": "Tag",
|
||||
"hierarchical": false,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "webdev", "label": "Web Development" },
|
||||
{ "slug": "opinion", "label": "Opinion" },
|
||||
{ "slug": "tools", "label": "Tools" },
|
||||
{ "slug": "creativity", "label": "Creativity" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"bylines": [
|
||||
{
|
||||
"id": "byline-editorial",
|
||||
"slug": "emdash-editorial",
|
||||
"displayName": "EmDash Editorial"
|
||||
},
|
||||
{
|
||||
"id": "byline-guest",
|
||||
"slug": "guest-contributor",
|
||||
"displayName": "Guest Contributor",
|
||||
"isGuest": true
|
||||
}
|
||||
],
|
||||
|
||||
"menus": [
|
||||
{
|
||||
"name": "primary",
|
||||
"label": "Primary Navigation",
|
||||
"items": [
|
||||
{ "type": "custom", "label": "Home", "url": "/" },
|
||||
{ "type": "custom", "label": "About", "url": "/pages/about" },
|
||||
{ "type": "custom", "label": "Posts", "url": "/posts" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"widgetAreas": [
|
||||
{
|
||||
"name": "sidebar",
|
||||
"label": "Sidebar",
|
||||
"description": "Widget area displayed on single post pages",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:search",
|
||||
"title": "Search"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:categories",
|
||||
"title": "Categories"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:tags",
|
||||
"title": "Tags"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:recent-posts",
|
||||
"title": "Recent Posts",
|
||||
"settings": {
|
||||
"count": 5,
|
||||
"showDate": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:archives",
|
||||
"title": "Archives",
|
||||
"settings": {
|
||||
"type": "monthly",
|
||||
"limit": 6
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "footer",
|
||||
"label": "Footer",
|
||||
"description": "Widget area displayed in the site footer",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "content",
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A blog about software, design, and the occasional stray thought."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"sections": [
|
||||
{
|
||||
"slug": "newsletter-signup",
|
||||
"title": "Newsletter Signup",
|
||||
"description": "A call-to-action block for newsletter subscriptions",
|
||||
"keywords": ["newsletter", "subscribe", "email", "cta"],
|
||||
"source": "theme",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h3",
|
||||
"children": [{ "_type": "span", "text": "Stay in the loop" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Get notified when new posts are published. No spam, unsubscribe anytime."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "about-author",
|
||||
"title": "About the Author",
|
||||
"description": "Brief author bio for use in posts or pages",
|
||||
"keywords": ["author", "bio", "about"],
|
||||
"source": "theme",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A software developer who writes about building things on the web. Based somewhere with good coffee and reliable internet."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"content": {
|
||||
"pages": [
|
||||
{
|
||||
"id": "about",
|
||||
"slug": "about",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A place for writing about software, design, and the occasional stray thought. No posting schedule, no newsletter funnel. Just things I wanted to write down."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Built with Astro and EmDash. The source is open if you want to see how it works."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=800&fit=crop",
|
||||
"alt": "Code on a monitor in a dark room",
|
||||
"filename": "building-long-term.jpg"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "What doesn't survive is code that was written to impress, abstractions built for problems that never materialized, and architectures designed around a framework's opinions rather than the domain's reality."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The best code I've written is boring. It reads like prose, does one thing well, and doesn't require a PhD in category theory to understand. The worst code I've written was technically impressive at the time."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"bylines": [
|
||||
{ "byline": "byline-editorial" },
|
||||
{ "byline": "byline-guest", "roleLabel": "Guest essay" }
|
||||
],
|
||||
"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": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=800&fit=crop",
|
||||
"alt": "Laptop and coffee on a wooden table",
|
||||
"filename": "case-for-static.jpg"
|
||||
}
|
||||
},
|
||||
"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": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The pitch for server-rendered everything was compelling: dynamic content, personalization, real-time data. But most sites don't need most of that most of the time. A blog post doesn't need to be rendered on every request. A product page doesn't change every second."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_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."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "And when you do need dynamic behavior, you can add it surgically. An island of interactivity in a sea of static HTML. The best of both worlds, without paying the cost of either at all times."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1432821596592-e2c18b78144f?w=1200&h=800&fit=crop",
|
||||
"alt": "Notebook and pen on a desk",
|
||||
"filename": "learning-in-public.jpg"
|
||||
}
|
||||
},
|
||||
"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": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "What I didn't expect was how much the writing itself would accelerate the learning. There's a particular kind of clarity that comes from trying to explain something to someone else. The gaps in your understanding, which you can happily ignore when the knowledge lives only in your head, become painfully obvious when you try to put it into sentences."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_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."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The posts that helped me most weren't written by experts. They were written by people one step ahead of me on the same path, in language that hadn't yet been polished into abstraction. There's a place for that kind of writing, and it's more valuable than most people realize."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["notes"],
|
||||
"tag": ["opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-4",
|
||||
"slug": "small-tools-big-impact",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Small Tools, Big Impact",
|
||||
"excerpt": "The best developer tools do one thing well and get out of your way. A love letter to focused software.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1575026615908-666710ae5e47?w=1200&h=800&fit=crop",
|
||||
"alt": "Wrenches and hand tools hanging on a workshop wall",
|
||||
"filename": "small-tools.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "There's a class of software that doesn't get enough appreciation. Not the frameworks or the platforms or the IDEs, but the small, sharp tools that solve one problem so well you stop thinking about them. They become invisible, which is the highest compliment you can pay a tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "I'm talking about things like ripgrep, which searches code so fast it changed how I think about searching. Or jq, which makes JSON feel like a first-class data format in the terminal. Or curl, which has been quietly powering the internet's plumbing for decades."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "The Unix philosophy, revisited" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Do one thing well. The advice is old enough to be a cliche, but the best modern tools still follow it. They don't try to be platforms. They don't have plugin ecosystems or configuration languages or startup wizards. They do their job and they compose with other tools that do theirs."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The temptation is always to add more. One more feature, one more option, one more integration. But every addition is a decision someone has to make, a path through the code that has to be maintained, a thing that can break. The best tools resist this. They stay small, and in staying small, they stay reliable."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["tools"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-5",
|
||||
"slug": "designing-with-constraints",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Designing with Constraints",
|
||||
"excerpt": "Limitations aren't obstacles to creativity. They're the structure that makes creativity possible.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=1200&h=800&fit=crop",
|
||||
"alt": "Pencils and design tools on a desk",
|
||||
"filename": "designing-with-constraints.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Give a designer a blank canvas and unlimited time, and they'll often produce something mediocre. Give them a tight brief, a small screen, and a deadline, and they'll surprise you. This isn't a paradox - it's how creativity actually works."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Constraints force decisions. When you can't use more than two typefaces, you have to choose carefully. When the page has to load in under a second, every element earns its place. When the interface has to work on a 320px screen, you discover what's truly essential."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Embracing the box" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The web itself is a constraint. HTML flows in one direction. CSS has a box model. Browsers have viewport sizes and font rendering quirks. You can fight these constraints or you can work with them, and the results are dramatically different."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The designs I admire most don't look like they were forced through a framework. They look like they grew naturally from the medium, respecting its grain rather than working against it. That only happens when you treat constraints as creative partners rather than enemies."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["design"],
|
||||
"tag": ["creativity"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-6",
|
||||
"slug": "a-weekend-with-a-side-project",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "A Weekend with a Side Project",
|
||||
"excerpt": "No stakeholders, no deadlines, no Jira tickets. Just you and a dumb idea that might turn into something.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1542831371-29b0f74f9713?w=1200&h=800&fit=crop",
|
||||
"alt": "Code on a screen with a dark theme",
|
||||
"filename": "weekend-side-project.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Saturday morning. Coffee's made, the house is quiet, and I've got an idea that's been nagging at me all week. Not a good idea, necessarily - just a persistent one. A small tool that does a thing I keep doing manually. How hard could it be?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "This is the best kind of programming. No requirements document, no sprint planning, no pull request reviews. Just a text editor and a problem. The freedom to make terrible architectural decisions, rewrite everything twice, and follow tangents that turn out to be dead ends."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Why side projects matter" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Side projects are where you learn things your day job would never teach you. Not because the problems are harder, but because you're free to take risks. Try a language you've never used. Build something without a framework. Deploy to a platform you've only read about. The stakes are zero, which makes the learning maximum."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "By Sunday evening, the thing sort of works. It's rough, the error handling is nonexistent, and the README is a single sentence. But it solves the problem I set out to solve, and I learned three things I didn't know on Friday. Not a bad weekend."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["creativity"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-7",
|
||||
"slug": "notes-on-simplicity",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Notes on Simplicity",
|
||||
"excerpt": "Simplicity isn't the absence of complexity. It's the result of understanding a problem well enough to solve it cleanly.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1559051668-e1fa58f25786?w=1200&h=800&fit=crop",
|
||||
"alt": "Geometric pattern carved into white paper",
|
||||
"filename": "notes-on-simplicity.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Every piece of software starts simple. A few files, a clear purpose, a small surface area. Then features get added, edge cases get handled, and before long you're looking at something that requires a diagram to understand. This isn't inevitable, but it takes discipline to prevent."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The hard part of simplicity isn't the initial design. It's the ongoing resistance to complication. Every feature request, every bug fix, every refactor is an opportunity to add complexity. Saying no is the most important design skill, and the least celebrated."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Removing as a feature" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The best version of a product often has fewer features than the previous one. Not because features were missing, but because someone had the courage to remove things that weren't earning their keep. Every feature has a cost - in maintenance, in cognitive load, in the weight of the interface."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Simplicity is a practice, not a destination. You never arrive at simple. You just keep asking: is this necessary? Could this be clearer? Is there a way to solve this problem by removing something instead of adding something? The answer is yes more often than you'd expect."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["notes"],
|
||||
"tag": ["opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-draft",
|
||||
"slug": "work-in-progress",
|
||||
"status": "draft",
|
||||
"data": {
|
||||
"title": "Work in Progress",
|
||||
"excerpt": "This post is still being written.",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "This is a draft post that won't appear in the public listing."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
279
demos/postgres/src/components/PostCard.astro
Normal file
279
demos/postgres/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-3);
|
||||
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-text-secondary);
|
||||
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
demos/postgres/src/components/TagList.astro
Normal file
45
demos/postgres/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>
|
||||
1014
demos/postgres/src/layouts/Base.astro
Normal file
1014
demos/postgres/src/layouts/Base.astro
Normal file
File diff suppressed because it is too large
Load Diff
13
demos/postgres/src/live.config.ts
Normal file
13
demos/postgres/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
demos/postgres/src/pages/404.astro
Normal file
33
demos/postgres/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
demos/postgres/src/pages/category/[slug].astro
Normal file
129
demos/postgres/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>
|
||||
463
demos/postgres/src/pages/index.astro
Normal file
463
demos/postgres/src/pages/index.astro
Normal file
@@ -0,0 +1,463 @@
|
||||
---
|
||||
import {
|
||||
getEmDashCollection,
|
||||
getTermsForEntries,
|
||||
getSiteSettings,
|
||||
} from "emdash";
|
||||
import { Image } from "emdash/ui";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import PostCard from "../components/PostCard.astro";
|
||||
import { getReadingTime } from "../utils/reading-time";
|
||||
import { resolveBlogSiteIdentity } from "../utils/site-identity";
|
||||
|
||||
// Limit to what we render (1 featured + 6 grid). The DB does the slicing
|
||||
// instead of fetching every post and discarding the tail in JS.
|
||||
const POSTS_PER_PAGE = 7;
|
||||
|
||||
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
|
||||
getEmDashCollection("posts", {
|
||||
orderBy: { published_at: "desc" },
|
||||
limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need
|
||||
}),
|
||||
getSiteSettings(),
|
||||
]);
|
||||
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
|
||||
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
// Trim the lookahead post used to detect overflow
|
||||
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
|
||||
const hasMorePosts = posts.length > POSTS_PER_PAGE;
|
||||
|
||||
// Find the first post with a featured image for the hero
|
||||
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
|
||||
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
|
||||
|
||||
// Get remaining posts (exclude featured if found, limit to 6 for grid)
|
||||
const gridPosts = visiblePosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
|
||||
|
||||
// Single batched query for tags across the featured post + grid posts.
|
||||
// Avoids the N+1 pattern of calling getEntryTerms() per entry.
|
||||
// Bylines are already hydrated on entry.data.bylines by getEmDashCollection.
|
||||
const tagEntryIds = [
|
||||
...(featuredPost ? [featuredPost.data.id] : []),
|
||||
...gridPosts.map((p) => p.data.id),
|
||||
];
|
||||
const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag");
|
||||
|
||||
const featuredTags = featuredPost
|
||||
? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({
|
||||
slug: t.slug,
|
||||
label: t.label,
|
||||
}))
|
||||
: [];
|
||||
const featuredBylines = featuredPost?.data.bylines ?? [];
|
||||
|
||||
const gridPostsWithTags = gridPosts.map((post) => ({
|
||||
post,
|
||||
tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({
|
||||
slug: t.slug,
|
||||
label: t.label,
|
||||
})),
|
||||
bylines: post.data.bylines ?? [],
|
||||
}));
|
||||
|
||||
// Format date helper
|
||||
function formatDate(date: Date | null | undefined) {
|
||||
if (!date) return null;
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
---
|
||||
|
||||
<Base title={siteTitle} description={siteTagline}>
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<section class="empty-state">
|
||||
<h2>No posts yet</h2>
|
||||
<p>Create your first post in the admin panel.</p>
|
||||
<a href="/_emdash/admin/content/posts/new" class="btn">
|
||||
Create a post
|
||||
</a>
|
||||
</section>
|
||||
) : (
|
||||
<div class="home-content">
|
||||
{/* Featured Post - Side by side */}
|
||||
{featuredPost && (
|
||||
<section class="featured-section">
|
||||
<div class="featured-grid">
|
||||
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
|
||||
<div class="featured-image">
|
||||
<Image image={featuredPost.data.featured_image} />
|
||||
</div>
|
||||
</a>
|
||||
<div class="featured-content">
|
||||
<div class="featured-meta">
|
||||
{featuredBylines.length > 0 && (
|
||||
<>
|
||||
<div class="featured-bylines">
|
||||
{featuredBylines.slice(0, 2).map((credit, index) => (
|
||||
<>
|
||||
{index > 0 && <span class="byline-sep">,</span>}
|
||||
<span class="featured-byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="featured-byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<span class="featured-byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
{featuredBylines.length > 2 && (
|
||||
<span class="byline-more">
|
||||
+{featuredBylines.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="meta-dot" />
|
||||
</>
|
||||
)}
|
||||
{formatDate(featuredPost.data.publishedAt) && (
|
||||
<time>{formatDate(featuredPost.data.publishedAt)}</time>
|
||||
)}
|
||||
<span class="meta-dot" />
|
||||
<span>
|
||||
{getReadingTime(featuredPost.data.content)} min read
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/posts/${featuredPost.id}`}
|
||||
class="featured-title-link"
|
||||
>
|
||||
<h1 class="featured-title">{featuredPost.data.title}</h1>
|
||||
</a>
|
||||
{featuredPost.data.excerpt && (
|
||||
<p class="featured-excerpt">{featuredPost.data.excerpt}</p>
|
||||
)}
|
||||
{featuredTags.length > 0 && (
|
||||
<div class="featured-tags">
|
||||
{featuredTags.map((tag) => (
|
||||
<a href={`/tag/${tag.slug}`} class="featured-tag">
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Latest Posts */}
|
||||
{gridPostsWithTags.length > 0 && (
|
||||
<section class="posts-section">
|
||||
<header class="section-header">
|
||||
<h2 class="section-title">Latest</h2>
|
||||
{hasMorePosts && (
|
||||
<a href="/posts" class="section-link">
|
||||
View all
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
<div class="posts-grid">
|
||||
{gridPostsWithTags.map(({ post, tags, bylines }) => (
|
||||
<PostCard
|
||||
title={post.data.title ?? "Untitled"}
|
||||
excerpt={post.data.excerpt}
|
||||
featuredImage={post.data.featured_image}
|
||||
href={`/posts/${post.id}`}
|
||||
date={post.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(post.data.content)}
|
||||
tags={tags}
|
||||
bylines={bylines}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.home-content {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-16) var(--spacing-6);
|
||||
}
|
||||
|
||||
/* Featured Section - Side by side */
|
||||
.featured-section {
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
.featured-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.featured-image-link {
|
||||
grid-column: 1 / 3;
|
||||
display: block;
|
||||
/* Extend to viewport edge, but cap at -6rem minimum extension */
|
||||
margin-left: min(
|
||||
-6rem,
|
||||
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
|
||||
);
|
||||
}
|
||||
|
||||
.featured-image {
|
||||
overflow: hidden;
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.featured-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.featured-image-link:hover .featured-image img,
|
||||
.featured-grid:has(.featured-title-link:hover) .featured-image img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.featured-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.featured-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-3);
|
||||
row-gap: var(--spacing-1);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Featured bylines */
|
||||
.featured-bylines {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.featured-byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.featured-byline-avatar {
|
||||
width: var(--avatar-size-md);
|
||||
height: var(--avatar-size-md);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.featured-byline-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.byline-sep {
|
||||
color: var(--color-muted);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.byline-more {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.featured-title-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.featured-title {
|
||||
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
|
||||
font-weight: 700;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.featured-title-link:hover .featured-title,
|
||||
.featured-grid:has(.featured-image-link:hover) .featured-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.featured-excerpt {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.featured-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.featured-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.featured-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Section header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: var(--spacing-8);
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.section-link {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.section-link:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Posts Grid */
|
||||
.posts-section {
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-12) var(--spacing-8);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
text-align: center;
|
||||
padding: var(--spacing-20) var(--spacing-6);
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
margin-top: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.home-content {
|
||||
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
}
|
||||
|
||||
.featured-image-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.featured-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.featured-image {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.featured-image img {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-8) var(--spacing-6);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.featured-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
demos/postgres/src/pages/pages/[slug].astro
Normal file
108
demos/postgres/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
demos/postgres/src/pages/posts/[slug].astro
Normal file
970
demos/postgres/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;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.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(--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);
|
||||
}
|
||||
|
||||
.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);
|
||||
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
demos/postgres/src/pages/posts/index.astro
Normal file
272
demos/postgres/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-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);
|
||||
}
|
||||
|
||||
.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
demos/postgres/src/pages/rss.xml.ts
Normal file
70
demos/postgres/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
demos/postgres/src/pages/search.astro
Normal file
182
demos/postgres/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
demos/postgres/src/pages/tag/[slug].astro
Normal file
131
demos/postgres/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
demos/postgres/src/styles/theme.css
Normal file
108
demos/postgres/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
demos/postgres/src/utils/reading-time.ts
Normal file
66
demos/postgres/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`;
|
||||
}
|
||||
24
demos/postgres/src/utils/site-identity.ts
Normal file
24
demos/postgres/src/utils/site-identity.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
7
demos/postgres/tsconfig.json
Normal file
7
demos/postgres/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user