feat(docs): add custom documentation pages with AI beautify
User-created dynamic doc pages live at /docs/custom/:slug, persisted in the new backend. The editor offers "Beautify with AI", which regenerates the page as structured HTML with Mermaid diagrams and replaces the raw markdown source (the beautified version becomes the page's canonical content and survives edits). Adds a DocHtml renderer that lazily renders Mermaid blocks, a purple design token, sidebar/topbar entries for custom pages, and routing.
This commit is contained in:
245
apps/docs/src/components/CustomPageEditor.tsx
Normal file
245
apps/docs/src/components/CustomPageEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState } from 'react';
|
||||
import { useCustomPages } from '../lib/customPagesStore';
|
||||
import type { CustomPage } from '../lib/pagesApi';
|
||||
|
||||
/**
|
||||
* Modal editor for a custom documentation page.
|
||||
*
|
||||
* Lets the author set title / icon / blurb and edit the raw content (markdown-
|
||||
* ish). Two save actions:
|
||||
* - **Save**: persists the raw content; the backend renders it to plain HTML.
|
||||
* - **Beautify with AI**: sends the content to the backend's AI endpoint,
|
||||
* which returns polished HTML with Mermaid diagrams. The beautified HTML
|
||||
* replaces the page's source content, discarding the old markdown.
|
||||
*
|
||||
* Works for both create (`page === null`) and edit modes.
|
||||
*/
|
||||
|
||||
interface CustomPageEditorProps {
|
||||
page: CustomPage | null;
|
||||
onClose: () => void;
|
||||
onSaved?: (page: CustomPage) => void;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function CustomPageEditor({ page, onClose, onSaved, onDeleted }: CustomPageEditorProps) {
|
||||
const { create, update, remove, beautify } = useCustomPages();
|
||||
|
||||
const [title, setTitle] = useState(page?.title ?? '');
|
||||
const [icon, setIcon] = useState(page?.icon ?? '◈');
|
||||
const [blurb, setBlurb] = useState(page?.blurb ?? '');
|
||||
const [content, setContent] = useState(page?.content ?? '');
|
||||
const [isBeautified, setIsBeautified] = useState(page?.beautified ?? false);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [beautifying, setBeautifying] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const overlayStyle = {
|
||||
position: 'fixed' as const,
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
padding: 'var(--sp-4)',
|
||||
};
|
||||
|
||||
const persist = async (): Promise<CustomPage | null> => {
|
||||
setErr(null);
|
||||
if (!title.trim()) {
|
||||
setErr('Title is required.');
|
||||
return null;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = { title: title.trim(), icon: icon.trim() || '◈', blurb: blurb.trim(), content };
|
||||
const saved = page ? await update(page.id, input) : await create(input);
|
||||
return saved;
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Save failed');
|
||||
return null;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const saved = await persist();
|
||||
if (saved) onSaved?.(saved);
|
||||
};
|
||||
|
||||
const handleBeautify = async () => {
|
||||
setErr(null);
|
||||
if (!content.trim()) {
|
||||
setErr('Add some content before beautifying.');
|
||||
return;
|
||||
}
|
||||
setBeautifying(true);
|
||||
try {
|
||||
// Ensure the page exists and has the latest content before beautifying.
|
||||
let target = page;
|
||||
if (!target) {
|
||||
const created = await persist();
|
||||
if (!created) return;
|
||||
target = created;
|
||||
} else {
|
||||
const updated = await update(target.id, { title: title.trim(), icon: icon.trim() || '◈', blurb: blurb.trim(), content });
|
||||
target = updated;
|
||||
}
|
||||
const beautified = await beautify(target.id);
|
||||
// The beautified HTML now lives in `content`; sync the editor so it shows
|
||||
// the replacement of the old markdown.
|
||||
setTitle(beautified.title);
|
||||
setIcon(beautified.icon);
|
||||
setBlurb(beautified.blurb);
|
||||
setContent(beautified.content);
|
||||
setIsBeautified(beautified.beautified);
|
||||
onSaved?.(beautified);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Beautify failed');
|
||||
} finally {
|
||||
setBeautifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!page) return;
|
||||
if (!confirm(`Delete "${page.title}"? This cannot be undone.`)) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await remove(page.id);
|
||||
onDeleted?.();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Delete failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-lg border border-border bg-bg px-3 py-2 text-[0.9rem] text-fg outline-none transition-colors focus:border-accent';
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div
|
||||
className="max-h-[88vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-border bg-surface p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="m-0 text-lg">{page ? 'Edit Page' : 'Create Custom Page'}</h2>
|
||||
<button
|
||||
className="cursor-pointer rounded border border-border bg-transparent px-2 py-0.5 text-muted hover:text-fg"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">Icon</span>
|
||||
<input
|
||||
className="w-16 rounded-lg border border-border bg-bg px-3 py-2 text-center text-fg outline-none focus:border-accent"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
maxLength={4}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col gap-1">
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">Title</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Faction Diplomacy"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mb-4 flex flex-col gap-1">
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">Blurb</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={blurb}
|
||||
onChange={(e) => setBlurb(e.target.value)}
|
||||
placeholder="One-line summary shown above the page."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">
|
||||
{isBeautified ? 'Content (HTML)' : 'Content (markdown)'}
|
||||
</span>
|
||||
<textarea
|
||||
className={[inputCls, 'min-h-[240px] font-mono text-[0.82rem]'].join(' ')}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={
|
||||
isBeautified
|
||||
? '<h2>Heading</h2>\n\n<p>Beautified HTML lives here…</p>\n\n<div class="mermaid">\nflowchart LR\n A --> B\n</div>'
|
||||
: '# Heading\n\nWrite your notes here. Use **bold**, *italic*, `code`,\n- bullet lists\n\n```mermaid\nflowchart LR\n A --> B\n```'
|
||||
}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!isBeautified && (
|
||||
<span className="font-mono text-[0.7rem] text-muted">
|
||||
Tip: include a <code>fenced ```mermaid</code> block to seed a diagram for the AI.
|
||||
</span>
|
||||
)}
|
||||
{isBeautified && (
|
||||
<span className="font-mono text-[0.7rem] text-muted">
|
||||
This page is AI-beautified, so its content is stored as HTML — edit it directly.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{err && (
|
||||
<p className="mt-4 rounded-lg border border-red/25 bg-red/8 px-3 py-2 font-mono text-[0.78rem] text-red">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3 border-t border-border pt-4">
|
||||
<button
|
||||
className="cursor-pointer rounded-lg border border-border bg-surface-raised px-4 py-2 text-[0.85rem] text-fg-dim transition-colors hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onClose}
|
||||
disabled={saving || beautifying}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{page && (
|
||||
<button
|
||||
className="cursor-pointer rounded-lg border border-red/25 bg-red/8 px-4 py-2 text-[0.85rem] text-red transition-colors hover:bg-red/16 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleDelete}
|
||||
disabled={saving || beautifying}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
<button
|
||||
className="cursor-pointer rounded-lg border border-purple/30 bg-purple/12 px-4 py-2 text-[0.85rem] font-medium text-purple transition-colors hover:bg-purple/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleBeautify}
|
||||
disabled={saving || beautifying || !content.trim()}
|
||||
title="Regenerate formatted HTML + diagrams from your content using AI"
|
||||
>
|
||||
{beautifying ? '✦ Beautifying…' : '✦ Beautify with AI'}
|
||||
</button>
|
||||
<button
|
||||
className="cursor-pointer rounded-lg border border-accent/30 bg-accent/12 px-4 py-2 text-[0.85rem] font-medium text-accent transition-colors hover:bg-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleSave}
|
||||
disabled={saving || beautifying || !title.trim()}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/docs/src/components/DocHtml.tsx
Normal file
103
apps/docs/src/components/DocHtml.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* Renders an HTML documentation fragment produced by the API (either a raw
|
||||
* markdown render or an AI-beautified page) and post-processes any
|
||||
* `<div class="mermaid"> … </div>` blocks into rendered SVG diagrams.
|
||||
*
|
||||
* The content comes from our own backend, so it is treated as trusted author
|
||||
* HTML and rendered with dangerouslySetInnerHTML. Mermaid is imported lazily
|
||||
* (and only configured once) so the large diagram library stays out of the
|
||||
* main bundle unless a page actually contains diagrams.
|
||||
*/
|
||||
|
||||
interface DocHtmlProps {
|
||||
html: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let diagCounter = 0;
|
||||
type MermaidRunner = { render: (id: string, src: string) => Promise<{ svg: string }> };
|
||||
let mermaidPromise: Promise<MermaidRunner> | null = null;
|
||||
|
||||
async function loadMermaid(): Promise<MermaidRunner> {
|
||||
if (!mermaidPromise) {
|
||||
mermaidPromise = import('mermaid').then((mod) => {
|
||||
const mermaid = mod.default;
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'dark',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
||||
});
|
||||
return mermaid as unknown as MermaidRunner;
|
||||
});
|
||||
}
|
||||
return mermaidPromise;
|
||||
}
|
||||
|
||||
export function DocHtml({ html, style, className }: DocHtmlProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const blocks = Array.from(root.querySelectorAll<HTMLElement>('div.mermaid'));
|
||||
if (blocks.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
let runner: MermaidRunner;
|
||||
try {
|
||||
runner = await loadMermaid();
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load diagram library');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
const source = block.textContent ?? '';
|
||||
if (!source.trim()) continue;
|
||||
const id = `mmd-${diagCounter++}`;
|
||||
try {
|
||||
const { svg } = await runner.render(id, source);
|
||||
if (cancelled) return;
|
||||
block.innerHTML = svg;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
block.innerHTML = `<pre style="color:var(--red, #f87171)">Mermaid error: ${msg}</pre>`;
|
||||
setError(msg);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [html]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div
|
||||
className="mb-4 rounded-lg border border-red/25 bg-red/8 px-4 py-2 font-mono text-[0.75rem] text-red"
|
||||
role="alert"
|
||||
>
|
||||
One or more diagrams failed to render: {error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={['doc-prose', className].filter(Boolean).join(' ')}
|
||||
style={style}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { navSections } from "../data/nav";
|
||||
import { useCustomPages } from "../lib/customPagesStore";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type SidebarProps = {
|
||||
collapsed: boolean;
|
||||
};
|
||||
|
||||
export function Sidebar({ collapsed }: SidebarProps) {
|
||||
const { pages } = useCustomPages();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
@@ -62,6 +66,56 @@ export function Sidebar({ collapsed }: SidebarProps) {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* User-created dynamic pages */}
|
||||
<div className="max-md:flex max-md:flex-none max-md:gap-2">
|
||||
<div
|
||||
className={[
|
||||
"flex items-center justify-between overflow-hidden whitespace-nowrap px-3 pb-2 pt-4 font-mono text-[0.65rem] uppercase tracking-[0.1em] text-muted max-md:hidden",
|
||||
collapsed ? "h-2 p-2 text-0" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>Custom Pages</span>
|
||||
<Link
|
||||
to="/docs/custom/new"
|
||||
className="text-accent no-underline hover:text-accent-hover"
|
||||
title="Add a custom page"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
+
|
||||
</Link>
|
||||
</div>
|
||||
<NavLink
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"mb-0.5 flex select-none items-center gap-3 overflow-hidden whitespace-nowrap rounded-lg px-3 py-2 text-[0.85rem] text-fg-dim no-underline transition-all duration-150 hover:bg-surface-raised hover:text-fg-bright max-md:mb-0 max-md:justify-start",
|
||||
collapsed ? "justify-center p-3 max-md:px-3 max-md:py-2" : "",
|
||||
isActive ? "border border-accent/25 bg-accent/8 text-accent" : "",
|
||||
].join(" ")
|
||||
}
|
||||
to="/docs/custom"
|
||||
>
|
||||
<span className="min-w-5 text-center text-base">✦</span>
|
||||
<span className={collapsed ? "hidden max-md:inline" : ""}>Custom Pages</span>
|
||||
</NavLink>
|
||||
{!collapsed &&
|
||||
pages.map((page) => (
|
||||
<NavLink
|
||||
key={page.id}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"mb-0.5 flex select-none items-center gap-3 overflow-hidden whitespace-nowrap rounded-lg px-3 py-2 pl-7 text-[0.82rem] text-fg-dim no-underline transition-all duration-150 hover:bg-surface-raised hover:text-fg-bright max-md:mb-0",
|
||||
isActive ? "border border-accent/25 bg-accent/8 text-accent" : "",
|
||||
].join(" ")
|
||||
}
|
||||
to={`/docs/custom/${page.slug}`}
|
||||
>
|
||||
<span className="min-w-5 text-center text-sm">{page.icon}</span>
|
||||
<span className="truncate">{page.title}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,14 @@ export function TopBar({ collapsed, onToggle }: TopBarProps) {
|
||||
const location = useLocation();
|
||||
const meta = pageTitles.get(location.pathname);
|
||||
const section = meta?.section?.includes("Demo") || meta?.section?.includes("Prototype") ? "Demos" : "Docs";
|
||||
const title = meta?.title ?? "Overview";
|
||||
let title = meta?.title ?? "Overview";
|
||||
|
||||
// Custom (dynamic) pages aren't in the static pageTitles registry.
|
||||
if (location.pathname.startsWith("/docs/custom")) {
|
||||
if (location.pathname === "/docs/custom") title = "Custom Pages";
|
||||
else if (location.pathname.endsWith("/new")) title = "New Page";
|
||||
else title = "Custom Page";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-topbar min-h-topbar min-w-0 items-center gap-4 border-b border-border bg-surface px-5 text-[0.8rem] max-md:gap-2 max-md:px-3">
|
||||
|
||||
Reference in New Issue
Block a user