Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ assets/templates/*/[0-9]*

# Vitest browser-mode failure screenshots
__screenshots__/
.vitest-attachments/

# AI review artifacts
*-review.md
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ Or deploy directly to your Cloudflare account:

EmDash runs on Cloudflare (D1 + R2 + Workers) or any Node.js server with SQLite. No PHP, no separate hosting tier -- just deploy your Astro site.

## Public Preview

The repository includes a static public-service overview for Cloudflare Pages at [`public-site/`](public-site/). It explains EmDash for civic, institutional, research, campaign, and nonprofit publishing teams that need inspectable open-source infrastructure.

Cloudflare Pages can host the static overview, project narrative, and documentation links. The full CMS admin, authentication, database, media storage, and plugin runtime require a server environment such as Cloudflare Workers with D1/R2 or a Node.js deployment with SQLite.

## Templates

EmDash ships with three starter templates:
Expand Down Expand Up @@ -145,6 +151,8 @@ const { entries: posts } = await getEmDashCollection("posts");

**WordPress migration** -- Import posts, pages, media, and taxonomies from WXR exports, the WordPress REST API, or WordPress.com. Agent skills help port plugins and themes.

**Visual sections** -- A growing section library for editorial page building, including feature lists, icons, logo clouds, steps, FAQs, video embeds, pricing tables, CTA banners, cards, testimonials, stats, tabs, and accordions. The admin supports visual previews, category filtering, registry coverage checks, and schema-constrained draft generation from editor intent.

## Portable Platforms

| Layer | Cloudflare | Also works with |
Expand Down
2 changes: 1 addition & 1 deletion demos/cloudflare/emdash-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Post {
slug: string | null;
status: string;
title: string;
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> };
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
Expand Down
13 changes: 12 additions & 1 deletion packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2013,6 +2013,7 @@ export function PortableTextEditor({
// Add plugin block commands (API labels/descriptions: plain strings, not msg-wrapped).
// Plugins can supply a custom `category` (plain string) — falls back to "Embeds".
for (const block of pluginBlocks) {
if (block.insertable === false) continue;
cmds.push({
id: `plugin-${block.pluginId}-${block.type}`,
title: block.label,
Expand Down Expand Up @@ -2346,7 +2347,12 @@ export function PortableTextEditor({
aria-labelledby={ariaLabelledby}
>
{!minimal && (
<EditorToolbar editor={editor} focusMode={focusMode} onFocusModeChange={setFocusMode} />
<EditorToolbar
editor={editor}
focusMode={focusMode}
onFocusModeChange={setFocusMode}
onInsertSection={() => setSectionPickerOpen(true)}
/>
)}
<EditorBubbleMenu editor={editor} />
<TableBubbleMenu editor={editor} />
Expand Down Expand Up @@ -2659,10 +2665,12 @@ function EditorToolbar({
editor,
focusMode,
onFocusModeChange,
onInsertSection,
}: {
editor: Editor;
focusMode: FocusMode;
onFocusModeChange: (mode: FocusMode) => void;
onInsertSection: () => void;
}) {
const { t } = useLingui();
const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false);
Expand Down Expand Up @@ -2998,6 +3006,9 @@ function EditorToolbar({
<ToolbarButton onClick={() => setMediaPickerOpen(true)} title={t`Insert Image`}>
<ImageIcon className="h-4 w-4" aria-hidden="true" />
</ToolbarButton>
<ToolbarButton onClick={onInsertSection} title={t`Insert Section`}>
<Stack className="h-4 w-4" aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title={t`Insert Horizontal Rule`}
Expand Down
53 changes: 45 additions & 8 deletions packages/admin/src/components/SectionEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import { useParams, useNavigate } from "@tanstack/react-router";
import * as React from "react";

import { fetchSection, updateSection, type Section, type UpdateSectionInput } from "../lib/api";
import { slugify } from "../lib/utils";
import { getSectionTemplatePluginBlocks } from "../lib/pluginBlocks";
import { cn, slugify } from "../lib/utils";
import { ArrowPrev } from "./ArrowIcons.js";
import { ImageDetailPanel, type ImageAttributes } from "./editor/ImageDetailPanel";
import { EditorHeader } from "./EditorHeader";
import { PortableTextEditor, type BlockSidebarPanel } from "./PortableTextEditor";
import { RouterLinkButton } from "./RouterLinkButton.js";
import { SaveButton } from "./SaveButton";
import { SectionVisualPreview } from "./SectionVisualPreview";

export function SectionEditor() {
const { t } = useLingui();
Expand Down Expand Up @@ -110,6 +112,8 @@ function SectionEditorForm({ section, isSaving, onSave }: SectionEditorFormProps
const [description, setDescription] = React.useState(section.description || "");
const [keywords, setKeywords] = React.useState(section.keywords.join(", "));
const [content, setContent] = React.useState<unknown[]>(section.content);
const [contentView, setContentView] = React.useState<"edit" | "visual">("edit");
const sectionPluginBlocks = React.useMemo(() => getSectionTemplatePluginBlocks(), []);

// Track initial state for dirty checking
const [lastSavedData] = React.useState(() =>
Expand Down Expand Up @@ -191,13 +195,46 @@ function SectionEditorForm({ section, isSaving, onSave }: SectionEditorFormProps
<div className="col-span-8 space-y-6">
{/* Content editor */}
<div className="rounded-lg border bg-kumo-base p-6">
<Label className="text-lg font-semibold mb-4 block">{t`Content`}</Label>
<PortableTextEditor
value={content as Parameters<typeof PortableTextEditor>[0]["value"]}
onChange={(value) => setContent(value as unknown[])}
onBlockSidebarOpen={handleBlockSidebarOpen}
onBlockSidebarClose={handleBlockSidebarClose}
/>
<div className="mb-4 flex items-center justify-between gap-4">
<Label className="text-lg font-semibold">{t`Content`}</Label>
<div className="flex rounded-md border bg-kumo-tint/50 p-0.5" role="tablist">
<button
type="button"
className={cn(
"rounded px-3 py-1.5 text-sm",
contentView === "edit"
? "bg-kumo-base shadow-sm"
: "text-kumo-subtle hover:text-kumo-default",
)}
onClick={() => setContentView("edit")}
>
{t`Edit`}
</button>
<button
type="button"
className={cn(
"rounded px-3 py-1.5 text-sm",
contentView === "visual"
? "bg-kumo-base shadow-sm"
: "text-kumo-subtle hover:text-kumo-default",
)}
onClick={() => setContentView("visual")}
>
{t`Visual`}
</button>
</div>
</div>
{contentView === "edit" ? (
<PortableTextEditor
value={content as Parameters<typeof PortableTextEditor>[0]["value"]}
onChange={(value) => setContent(value as unknown[])}
pluginBlocks={sectionPluginBlocks}
onBlockSidebarOpen={handleBlockSidebarOpen}
onBlockSidebarClose={handleBlockSidebarClose}
/>
) : (
<SectionVisualPreview value={content} />
)}
</div>

{/* Save action at the bottom of the main column so users hit
Expand Down
Loading
Loading