1
0
Fork 0
polaris-task-force/src/components/frontend/wiki/WikiEditor.tsx
Z8MB1E 28250550ab feat(wiki): add rendered template preview to template dialog
Template dialog gains a rendered preview (markdown pipeline + scoped template CSS + HTML templates) alongside the existing source preview, with Rendered/Markdown toggle; editor threads the template maps through.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00

262 lines
8.7 KiB
TypeScript

"use client";
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { createWikiPage, updateWikiPage } from "@/app/(frontend)/wiki/actions";
import { WIKI_CATEGORIES, type WikiCategory } from "@/lib/wiki/categories";
import { prepareMarkdown } from "@/lib/wiki/prepare";
import { buildTemplateStyles } from "@/lib/wiki/templateCss";
import { MarkdownGuide } from "@/components/frontend/wiki/MarkdownGuide";
import { WikiEditorBody } from "@/components/frontend/wiki/WikiEditorBody";
import { WikiStandardsDialog } from "@/components/frontend/wiki/WikiStandardsDialog";
import { WikiPreparedBody } from "@/components/frontend/wiki/WikiContent";
const STANDARDS_ACCEPTED_KEY = "ptf:wiki-standards-accepted";
export interface WikiEditorInitial {
readonly id: number;
readonly title: string;
readonly category: WikiCategory;
readonly tags: readonly string[];
readonly body: string;
}
interface WikiEditorProps {
readonly mode: "create" | "edit";
readonly initial?: WikiEditorInitial;
readonly initialTitle?: string;
readonly templates: Record<string, string>;
readonly templateCssMap?: Record<string, string>;
readonly templateHtmlMap?: Record<string, string>;
readonly existingPages: readonly { title: string }[];
readonly isFirstEdit?: boolean;
}
function isWikiCategory(value: string): value is WikiCategory {
return WIKI_CATEGORIES.some((category) => category === value);
}
function parseTags(value: string): string[] {
return Array.from(
new Set(
value
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
),
);
}
export function WikiEditor({
mode,
initial,
initialTitle,
templates,
templateCssMap = {},
templateHtmlMap = {},
existingPages,
isFirstEdit = false,
}: WikiEditorProps) {
const router = useRouter();
const bodyRef = useRef<HTMLTextAreaElement>(null);
const [title, setTitle] = useState(initial?.title ?? initialTitle ?? "");
const [category, setCategory] = useState<WikiCategory>(initial?.category ?? "Meta");
const [tags, setTags] = useState(initial?.tags.join(", ") ?? "");
const [summary, setSummary] = useState("");
const [body, setBody] = useState(initial?.body ?? "");
const [pending, setPending] = useState(false);
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [showStandards, setShowStandards] = useState(() => {
if (!isFirstEdit || typeof window === "undefined") return false;
return window.localStorage.getItem(STANDARDS_ACCEPTED_KEY) !== "1";
});
const preparedPreview = prepareMarkdown(body, templates, templateHtmlMap);
const { css: previewCss, scopeClasses: previewScopeClasses } = useMemo(
() =>
buildTemplateStyles(
body,
Object.entries(templates).map(([name, templateBody]) => ({
name,
body: templateBody,
css: templateCssMap[name] ?? null,
})),
),
[body, templates, templateCssMap],
);
function handleStandardsAccept() {
window.localStorage.setItem(STANDARDS_ACCEPTED_KEY, "1");
setShowStandards(false);
}
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) {
toast.error("Add a title before saving the page.");
return;
}
setPending(true);
const parsedTags = parseTags(tags);
const trimmedSummary = summary.trim();
const result =
mode === "create"
? await createWikiPage({
title: trimmedTitle,
category,
tags: parsedTags,
body,
...(trimmedSummary ? { summary: trimmedSummary } : {}),
})
: initial
? await updateWikiPage({
id: initial.id,
title: trimmedTitle,
category,
tags: parsedTags,
body,
...(trimmedSummary ? { summary: trimmedSummary } : {}),
})
: { success: false, error: "This page could not be loaded for editing." };
setPending(false);
if (!result.success || !result.data) {
toast.error(result.error ?? "Could not save the wiki page.");
return;
}
toast.success(mode === "create" ? "Wiki page created" : "Wiki page updated");
router.push(`/wiki/${result.data.slug}`);
}
return (
<form className="flex flex-col gap-6" onSubmit={(event) => void handleSave(event)}>
{previewCss && <style dangerouslySetInnerHTML={{ __html: previewCss }} />}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="wiki-title">Title</Label>
<Input
id="wiki-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Operation Northern Light"
disabled={pending}
autoFocus={mode === "create"}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wiki-category">Category</Label>
<Select
value={category}
onValueChange={(value) => {
if (isWikiCategory(value)) setCategory(value);
}}
disabled={pending}
>
<SelectTrigger id="wiki-category">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{WIKI_CATEGORIES.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wiki-tags">Tags</Label>
<Input
id="wiki-tags"
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="arctic, operation, briefing"
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="wiki-summary">Edit summary</Label>
<Input
id="wiki-summary"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder={mode === "create" ? "Created page" : "What changed?"}
disabled={pending}
/>
</div>
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="lg:sticky lg:top-5">
<WikiEditorBody
body={body}
bodyRef={bodyRef}
existingPages={existingPages}
linkDialogOpen={linkDialogOpen}
onBodyChange={setBody}
onLinkDialogChange={setLinkDialogOpen}
pending={pending}
templates={templates}
templateCssMap={templateCssMap}
templateHtmlMap={templateHtmlMap}
/>
</div>
<div className="flex flex-col gap-2 lg:sticky lg:top-5">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Live preview
</h2>
<div className="min-h-60 rounded-md border bg-muted/20 p-4">
{preparedPreview ? (
<div className={cn("wiki-markdown", previewScopeClasses)}>
<WikiPreparedBody prepared={preparedPreview} htmlTemplates={templateHtmlMap} />
</div>
) : (
<p className="text-sm italic text-muted-foreground">Nothing to preview yet.</p>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Markdown guide
</h2>
<MarkdownGuide />
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button asChild type="button" variant="outline" disabled={pending}>
<Link href="/wiki">Cancel</Link>
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : mode === "create" ? "Create page" : "Save changes"}
</Button>
</div>
<WikiStandardsDialog
open={showStandards}
onOpenChange={setShowStandards}
onAccept={handleStandardsAccept}
/>
</form>
);
}