diff --git a/.gitignore b/.gitignore index 0915fbeb7..c555d6b2f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ assets/templates/*/[0-9]* # Vitest browser-mode failure screenshots __screenshots__/ +.vitest-attachments/ # AI review artifacts *-review.md diff --git a/README.md b/README.md index baee656d9..3971dcb9c 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 | diff --git a/demos/cloudflare/emdash-env.d.ts b/demos/cloudflare/emdash-env.d.ts index abb26262f..e6d7d2e85 100644 --- a/demos/cloudflare/emdash-env.d.ts +++ b/demos/cloudflare/emdash-env.d.ts @@ -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 }; content?: PortableTextBlock[]; excerpt?: string; createdAt: Date; diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index b7b3a0ddb..a210700fc 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -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, @@ -2346,7 +2347,12 @@ export function PortableTextEditor({ aria-labelledby={ariaLabelledby} > {!minimal && ( - + setSectionPickerOpen(true)} + /> )} @@ -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); @@ -2998,6 +3006,9 @@ function EditorToolbar({ setMediaPickerOpen(true)} title={t`Insert Image`}> + + editor.chain().focus().setHorizontalRule().run()} title={t`Insert Horizontal Rule`} diff --git a/packages/admin/src/components/SectionEditor.tsx b/packages/admin/src/components/SectionEditor.tsx index 595a5939f..7753683a5 100644 --- a/packages/admin/src/components/SectionEditor.tsx +++ b/packages/admin/src/components/SectionEditor.tsx @@ -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(); @@ -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(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(() => @@ -191,13 +195,46 @@ function SectionEditorForm({ section, isSaving, onSave }: SectionEditorFormProps
{/* Content editor */}
- - [0]["value"]} - onChange={(value) => setContent(value as unknown[])} - onBlockSidebarOpen={handleBlockSidebarOpen} - onBlockSidebarClose={handleBlockSidebarClose} - /> +
+ +
+ + +
+
+ {contentView === "edit" ? ( + [0]["value"]} + onChange={(value) => setContent(value as unknown[])} + pluginBlocks={sectionPluginBlocks} + onBlockSidebarOpen={handleBlockSidebarOpen} + onBlockSidebarClose={handleBlockSidebarClose} + /> + ) : ( + + )}
{/* Save action at the bottom of the main column so users hit diff --git a/packages/admin/src/components/SectionPickerModal.tsx b/packages/admin/src/components/SectionPickerModal.tsx index 9f99ec0be..a75f0cda8 100644 --- a/packages/admin/src/components/SectionPickerModal.tsx +++ b/packages/admin/src/components/SectionPickerModal.tsx @@ -6,14 +6,23 @@ import { Button, Dialog, Input } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; -import { MagnifyingGlass, Stack, FolderOpen } from "@phosphor-icons/react"; +import { MagnifyingGlass, Stack, FolderOpen, Tag } from "@phosphor-icons/react"; import { X } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import * as React from "react"; import { fetchSections, type Section } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks"; +import { + SECTION_STARTER_TEMPLATES, + SECTION_CATEGORIES, + matchesSectionTemplate, + templateToSection, + getCategoryById, + type SectionCategoryId, +} from "../lib/sectionTemplates"; import { cn } from "../lib/utils"; +import { SectionVisualPreview } from "./SectionVisualPreview.js"; interface SectionPickerModalProps { open: boolean; @@ -24,6 +33,7 @@ interface SectionPickerModalProps { export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPickerModalProps) { const { t } = useLingui(); const [searchQuery, setSearchQuery] = React.useState(""); + const [selectedCategory, setSelectedCategory] = React.useState(null); const debouncedSearch = useDebouncedValue(searchQuery, 300); const { data: sectionsData, isLoading: sectionsLoading } = useQuery({ @@ -35,11 +45,22 @@ export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPick enabled: open, }); const sections = sectionsData?.items ?? []; + const starterSections = React.useMemo( + () => + SECTION_STARTER_TEMPLATES.filter( + (template) => + matchesSectionTemplate(template, debouncedSearch) && + (!selectedCategory || template.category === selectedCategory), + ).map(templateToSection), + [debouncedSearch, selectedCategory], + ); + const hasAnySections = starterSections.length > 0 || sections.length > 0 || sectionsLoading; // Reset search when modal opens React.useEffect(() => { if (open) { setSearchQuery(""); + setSelectedCategory(null); } }, [open]); @@ -73,11 +94,12 @@ export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPick />
- {/* Search */} -
-
+ {/* Search and filter */} +
+
setSearchQuery(e.target.value)} @@ -85,15 +107,64 @@ export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPick autoFocus />
+ {/* Category filter */} +
+ + {SECTION_CATEGORIES.map((cat) => ( + + ))} +
{/* Section grid */}
- {sectionsLoading ? ( -
-
{t`Loading sections...`}
+ {hasAnySections ? ( +
+ {starterSections.length > 0 && ( + + )} + {sectionsLoading ? ( +
+
{t`Loading sections...`}
+
+ ) : ( + sections.length > 0 && ( + + ) + )}
- ) : sections.length === 0 ? ( + ) : (
{searchQuery ? ( <> @@ -111,16 +182,6 @@ export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPick )}
- ) : ( -
- {sections.map((section) => ( - handleSelect(section)} - /> - ))} -
)}
@@ -135,7 +196,31 @@ export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPick ); } +function SectionGroup({ + title, + sections, + onSelect, +}: { + title: string; + sections: Section[]; + onSelect: (section: Section) => void; +}) { + return ( +
+

{title}

+
+ {sections.map((section) => ( + onSelect(section)} /> + ))} +
+
+ ); +} + function SectionCard({ section, onSelect }: { section: Section; onSelect: () => void }) { + const { t } = useLingui(); + const category = section.category ? getCategoryById(section.category) : null; + return ( diff --git a/packages/admin/src/components/SectionVisualPreview.tsx b/packages/admin/src/components/SectionVisualPreview.tsx new file mode 100644 index 000000000..75fe99321 --- /dev/null +++ b/packages/admin/src/components/SectionVisualPreview.tsx @@ -0,0 +1,1084 @@ +import { + Star, + CheckCircle, + Lightning, + Gear, + Users, + Clock, + Bell, + Shield, + ChartBar, + Heart, + Flag, + Target, + Key, + LockSimple, + Globe, + Megaphone, + CurrencyDollar, +} from "@phosphor-icons/react"; +import * as React from "react"; + +type PortableTextBlock = Record; + +interface SectionVisualPreviewProps { + value: unknown[]; +} + +export function SectionVisualPreview({ value }: SectionVisualPreviewProps) { + const blocks = Array.isArray(value) ? value : []; + + if (blocks.length === 0) { + return ( +
+ No preview content +
+ ); + } + + return ( +
+ {blocks.map((block, index) => ( + {renderBlock(block)} + ))} +
+ ); +} + +function getKey(block: unknown, index: number): string { + if (isRecord(block) && typeof block._key === "string") return block._key; + return `preview-${index}`; +} + +function renderBlock(block: unknown): React.ReactNode { + if (!isRecord(block)) return null; + + switch (block._type) { + case "block": + return renderTextBlock(block); + case "cover": + return ; + case "button": + return ; + case "pullquote": + return ; + case "image": + return ; + case "accordion": + return ; + case "banner": + return ; + case "testimonial": + return ; + case "card": + return ; + case "cardGrid": + return ; + case "tab": + return ; + case "stats": + return ; + case "featureList": + return ; + case "logoCloud": + return ; + case "steps": + return ; + case "faq": + return ; + case "videoEmbed": + return ; + case "pricingTable": + return ; + case "ctaBanner": + return ; + default: + return ; + } +} + +function renderTextBlock(block: PortableTextBlock): React.ReactNode { + const style = typeof block.style === "string" ? block.style : "normal"; + const text = renderChildren(block.children); + const listItem = typeof block.listItem === "string" ? block.listItem : ""; + + if (listItem) { + return ( +

+ {listItem === "number" ? "1. " : "- "} + {text} +

+ ); + } + + switch (style) { + case "h1": + return

{text}

; + case "h2": + return

{text}

; + case "h3": + return

{text}

; + case "blockquote": + return ( +
+ {text} +
+ ); + default: + return

{text}

; + } +} + +function renderChildren(children: unknown): React.ReactNode { + if (!Array.isArray(children)) return null; + return children.map((child, index) => { + if (!isRecord(child)) return null; + const text = typeof child.text === "string" ? child.text : ""; + const marks = Array.isArray(child.marks) ? child.marks : []; + let node: React.ReactNode = text; + + if (marks.includes("code")) { + node = {node}; + } + if (marks.includes("em")) { + node = {node}; + } + if (marks.includes("strong")) { + node = {node}; + } + + return ( + + {node} + + ); + }); +} + +function CoverPreview({ block }: { block: PortableTextBlock }) { + const heading = typeof block.heading === "string" ? block.heading : ""; + const body = typeof block.body === "string" ? block.body : ""; + const ctaText = typeof block.ctaText === "string" ? block.ctaText : ""; + const ctaUrl = typeof block.ctaUrl === "string" ? block.ctaUrl : ""; + const backgroundImage = typeof block.backgroundImage === "string" ? block.backgroundImage : ""; + const minHeight = typeof block.minHeight === "string" ? block.minHeight : "320px"; + const alignment = + block.alignment === "left" || block.alignment === "right" || block.alignment === "center" + ? block.alignment + : "center"; + const overlayOpacity = typeof block.overlayOpacity === "number" ? block.overlayOpacity : 0.45; + const structuredContent = Array.isArray(block.content) ? block.content : []; + + return ( +
+ {backgroundImage && ( + + )} +
+
+ {structuredContent.length > 0 ? ( +
+ {structuredContent.map((item, index) => ( + {renderBlock(item)} + ))} +
+ ) : ( + <> + {heading &&

{heading}

} + {body &&

{body}

} + {ctaText && ( + + {ctaText} + + )} + + )} +
+
+ ); +} + +function ButtonPreview({ block }: { block: PortableTextBlock }) { + const text = typeof block.text === "string" ? block.text : "Button"; + const href = + typeof block.url === "string" ? block.url : typeof block.id === "string" ? block.id : ""; + const style = block.style === "outline" ? "outline" : "fill"; + + return ( + + {text} + + ); +} + +function PullquotePreview({ block }: { block: PortableTextBlock }) { + const text = typeof block.text === "string" ? block.text : ""; + const citation = typeof block.citation === "string" ? block.citation : ""; + + return ( +
+
{text}
+ {citation &&
{citation}
} +
+ ); +} + +function ImagePreview({ block }: { block: PortableTextBlock }) { + const src = + typeof block.url === "string" ? block.url : typeof block.src === "string" ? block.src : ""; + const alt = typeof block.alt === "string" ? block.alt : ""; + + if (!src) return ; + + return {alt}; +} + +function AccordionPreview({ block }: { block: PortableTextBlock }) { + const items = Array.isArray(block.items) ? block.items : []; + + if (items.length === 0) { + return ( +
+ No accordion items +
+ ); + } + + return ( +
+ {items.map((item, index) => { + if (!isRecord(item)) return null; + const label = typeof item.label === "string" ? item.label : `Item ${index + 1}`; + const itemBlocks = Array.isArray(item.blocks) ? item.blocks : []; + const body = + typeof item.body === "string" + ? item.body + : typeof item.text === "string" + ? item.text + : ""; + + return ( +
+ + {label} + + + + + + +
+ {itemBlocks.length > 0 ? ( + itemBlocks.map((itemBlock, itemIndex) => ( + + {renderBlock(itemBlock)} + + )) + ) : body ? ( +

{body}

+ ) : ( +

No content

+ )} +
+
+ ); + })} +
+ ); +} + +function BannerPreview({ block }: { block: PortableTextBlock }) { + const title = typeof block.title === "string" ? block.title : ""; + const description = typeof block.description === "string" ? block.description : ""; + const variant = + block.variant === "alert" ? "alert" : block.variant === "error" ? "error" : "default"; + + const variantStyles = { + default: "border-kumo-line bg-kumo-tint", + alert: "border-kumo-brand/40 bg-kumo-brand/10", + error: "border-red-400/40 bg-red-50 dark:bg-red-950/20", + }; + + const iconColors = { + default: "text-kumo-subtle", + alert: "text-kumo-brand", + error: "text-red-500", + }; + + return ( +
+
+ {variant === "alert" && ( + + + + )} + {variant === "error" && ( + + + + )} + {variant === "default" && ( + + + + )} +
+ {title &&

{title}

} + {description && ( +

{description}

+ )} +
+
+
+ ); +} + +function TestimonialPreview({ block }: { block: PortableTextBlock }) { + const items = Array.isArray(block.items) ? block.items : []; + + if (items.length === 0) { + return ( +
+ No testimonials +
+ ); + } + + return ( +
+ {items.map((item, index) => { + if (!isRecord(item)) return null; + const quote = typeof item.quote === "string" ? item.quote : ""; + const author = typeof item.author === "string" ? item.author : ""; + const title = typeof item.title === "string" ? item.title : ""; + const company = typeof item.company === "string" ? item.company : ""; + const avatar = typeof item.avatar === "string" ? item.avatar : ""; + + return ( +
+
{quote}
+
+ {avatar && ( + {author} + )} +
+ {author &&
{author}
} + {(title || company) && ( +
+ {title} + {title && company && ", "} + {company} +
+ )} +
+
+
+ ); + })} +
+ ); +} + +function CardPreview({ block }: { block: PortableTextBlock }) { + return ; +} + +function CardGridPreview({ block }: { block: PortableTextBlock }) { + const title = typeof block.title === "string" ? block.title : ""; + const description = typeof block.description === "string" ? block.description : ""; + const columns = normalizeColumns(block.columns); + const items = Array.isArray(block.items) ? block.items : []; + const columnClass = + columns === 2 + ? "sm:grid-cols-2" + : columns === 4 + ? "sm:grid-cols-2 lg:grid-cols-4" + : "sm:grid-cols-2 lg:grid-cols-3"; + + return ( +
+ {(title || description) && ( +
+ {title && ( +

{title}

+ )} + {description &&

{description}

} +
+ )} + {items.length > 0 ? ( +
+ {items.map((item, index) => + isRecord(item) ? : null, + )} +
+ ) : ( +
+ No cards +
+ )} +
+ ); +} + +function CardShell({ item }: { item: PortableTextBlock }) { + const title = typeof item.title === "string" ? item.title : "Card title"; + const description = typeof item.description === "string" ? item.description : ""; + const image = typeof item.image === "string" ? item.image : ""; + const ctaText = typeof item.ctaText === "string" ? item.ctaText : ""; + const ctaUrl = typeof item.ctaUrl === "string" ? item.ctaUrl : ""; + + return ( +
+ {image ? ( + {title} + ) : ( +
+ Image +
+ )} +
+

{title}

+ {description &&

{description}

} + {ctaText && ( + + {ctaText} + + )} +
+
+ ); +} + +function TabsPreview({ block }: { block: PortableTextBlock }) { + const panels = Array.isArray(block.panels) ? block.panels.filter(isRecord) : []; + const defaultTab = typeof block.default_tab === "number" ? block.default_tab : 0; + const activePanel = panels[Math.min(Math.max(defaultTab, 0), Math.max(panels.length - 1, 0))]; + + if (panels.length === 0) { + return ( +
+ No tab panels +
+ ); + } + + return ( +
+
+ {panels.map((panel, index) => { + const label = typeof panel.label === "string" ? panel.label : `Tab ${index + 1}`; + const isActive = panel === activePanel; + + return ( +
+ {label} +
+ ); + })} +
+
+ {activePanel && Array.isArray(activePanel.blocks) && activePanel.blocks.length > 0 ? ( + activePanel.blocks.map((item, index) => ( + {renderBlock(item)} + )) + ) : activePanel && typeof activePanel.body === "string" ? ( +

{activePanel.body}

+ ) : ( +

No panel content

+ )} +
+
+ ); +} + +function StatsPreview({ block }: { block: PortableTextBlock }) { + const items = Array.isArray(block.items) ? block.items.filter(isRecord) : []; + + if (items.length === 0) { + return ( +
+ No stats +
+ ); + } + + return ( +
+ {items.map((item, index) => { + const label = typeof item.label === "string" ? item.label : `Metric ${index + 1}`; + const value = + typeof item.value === "string" || typeof item.value === "number" ? item.value : ""; + const description = typeof item.description === "string" ? item.description : ""; + const trend = + item.trend === "up" || item.trend === "down" || item.trend === "neutral" + ? item.trend + : "neutral"; + const trendLabel = trend === "up" ? "up" : trend === "down" ? "down" : "flat"; + const trendClass = + trend === "up" + ? "text-green-600" + : trend === "down" + ? "text-red-600" + : "text-kumo-subtle"; + + return ( +
+
{label}
+
+
{value}
+ {trendLabel} +
+ {description && ( +
{description}
+ )} +
+ ); + })} +
+ ); +} + +const FEATURE_ICON_MAP: Record> = { + star: Star, + check: CheckCircle, + lightning: Lightning, + gear: Gear, + users: Users, + clock: Clock, + bell: Bell, + shield: Shield, + chart: ChartBar, + heart: Heart, + flag: Flag, + target: Target, + key: Key, + lock: LockSimple, + globe: Globe, +}; + +function resolveFeatureIcon(iconKey?: string): React.ComponentType<{ className?: string }> { + if (iconKey && FEATURE_ICON_MAP[iconKey]) { + return FEATURE_ICON_MAP[iconKey]; + } + return Star; +} + +function FeatureListPreview({ block }: { block: PortableTextBlock }) { + const title = typeof block.title === "string" ? block.title : ""; + const description = typeof block.description === "string" ? block.description : ""; + const columns = normalizeColumns(block.columns); + const items = Array.isArray(block.items) ? block.items : []; + const columnClass = + columns === 2 + ? "sm:grid-cols-2" + : columns === 4 + ? "sm:grid-cols-2 lg:grid-cols-4" + : "sm:grid-cols-2 lg:grid-cols-3"; + + return ( +
+ {(title || description) && ( +
+ {title && ( +

{title}

+ )} + {description &&

{description}

} +
+ )} + {items.length > 0 ? ( +
+ {items.map((item, index) => + isRecord(item) ? ( +
+
+ {(() => { + const IconComponent = resolveFeatureIcon( + typeof item.icon === "string" ? item.icon : "", + ); + return ; + })()} +
+
+

+ {typeof item.title === "string" ? item.title : "Feature"} +

+ {typeof item.description === "string" && item.description && ( +

{item.description}

+ )} + {typeof item.url === "string" && item.url && ( + + Learn more + + )} +
+
+ ) : null, + )} +
+ ) : ( +
+ No features +
+ )} +
+ ); +} + +function LogoCloudPreview({ block }: { block: PortableTextBlock }) { + const title = typeof block.title === "string" ? block.title : ""; + const items = Array.isArray(block.items) ? block.items : []; + + return ( +
+ {title &&

{title}

} + {items.length > 0 ? ( +
+ {items.map((item, index) => + isRecord(item) ? ( +
+ {item.logoUrl ? ( + {typeof + ) : ( +
+ {typeof item.name === "string" ? item.name : "Logo"} +
+ )} +
+ ) : null, + )} +
+ ) : ( +
+ No logos +
+ )} +
+ ); +} + +function FaqPreview({ block }: { block: PortableTextBlock }) { + const items = Array.isArray(block.items) ? block.items : []; + + if (items.length === 0) { + return ( +
+ No FAQ items +
+ ); + } + + return ( +
+ {items.map((item, index) => + isRecord(item) ? ( +
+ + + {typeof item.question === "string" ? item.question : `Question ${index + 1}`} + + + + + + + +
+

+ {typeof item.answer === "string" ? item.answer : "No answer provided"} +

+
+
+ ) : null, + )} +
+ ); +} + +function VideoEmbedPreview({ block }: { block: PortableTextBlock }) { + const embedUrl = typeof block.embedUrl === "string" ? block.embedUrl : ""; + const title = typeof block.title === "string" ? block.title : "Video"; + const caption = typeof block.caption === "string" ? block.caption : ""; + + if (!embedUrl) { + return ( +
+ No video URL provided +
+ ); + } + + return ( +
+
+