1
0
Fork 0
polaris-task-force/src/components/frontend/wiki/WikiTemplateDialog.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

234 lines
8 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { LayoutTemplate } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { extractPlaceholders, type TemplateMap } from "@/lib/wiki/templates";
import { prepareMarkdown } from "@/lib/wiki/prepare";
import { buildTemplateStyles } from "@/lib/wiki/templateCss";
import { WikiPreparedBody } from "@/components/frontend/wiki/WikiContent";
interface WikiTemplateDialogProps {
readonly templates: TemplateMap;
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
readonly disabled?: boolean;
readonly onInsert: (body: string) => void;
readonly templateCssMap?: Record<string, string>;
readonly templateHtmlMap?: Record<string, string>;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function fillTemplate(
body: string,
values: Readonly<Record<string, string>>,
): string {
let filled = body;
for (const [name, value] of Object.entries(values)) {
if (value.length === 0) continue;
filled = filled.replace(
new RegExp(`\\{\\{\\s*${escapeRegExp(name)}\\s*\\}\\}`, "g"),
value,
);
}
return filled;
}
export function WikiTemplateDialog({
templates,
open,
onOpenChange,
disabled,
onInsert,
templateCssMap,
templateHtmlMap,
}: WikiTemplateDialogProps) {
const [selectedName, setSelectedName] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [previewMode, setPreviewMode] = useState<"rendered" | "source">(
"rendered",
);
const names = useMemo(() => Object.keys(templates).sort(), [templates]);
const selectedBody = selectedName !== null ? (templates[selectedName] ?? "") : "";
const placeholders = useMemo(
() => extractPlaceholders(selectedBody),
[selectedBody],
);
const filled = useMemo(
() => fillTemplate(selectedBody, values),
[selectedBody, values],
);
const preparedPreview = useMemo(
() =>
selectedName !== null
? prepareMarkdown(filled, templates, templateHtmlMap ?? {})
: "",
[selectedName, filled, templates, templateHtmlMap],
);
const { css: previewCss, scopeClasses: previewScopeClasses } = useMemo(
() =>
buildTemplateStyles(
filled,
selectedName !== null
? [
{
name: selectedName,
body: templates[selectedName] ?? "",
css: templateCssMap?.[selectedName] ?? null,
},
]
: [],
),
[filled, selectedName, templates, templateCssMap],
);
function handleOpenChange(next: boolean) {
if (!next) {
setSelectedName(null);
setValues({});
setPreviewMode("rendered");
}
onOpenChange(next);
}
function handleInsert() {
onInsert(filled);
handleOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Insert template</DialogTitle>
<DialogDescription>
Pick a wiki template, fill in its fields, and insert it at the
cursor. Leave a field blank to keep its {"{{marker}}"} in the article.
</DialogDescription>
</DialogHeader>
{names.length === 0 ? (
<p className="text-sm text-muted-foreground">
No templates are available yet. Templates can be created in the
admin panel.
</p>
) : selectedName === null ? (
<div className="flex flex-col gap-1.5">
{names.map((name) => (
<button
key={name}
type="button"
onClick={() => setSelectedName(name)}
className="flex items-start gap-2.5 rounded-md border p-2.5 text-left transition-colors hover:bg-muted/50"
>
<LayoutTemplate className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex flex-col gap-0.5">
<span className="text-sm font-medium">{name}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">
{templates[name]}
</span>
</span>
</button>
))}
</div>
) : (
<div className="flex flex-col gap-4">
<button
type="button"
onClick={() => setSelectedName(null)}
className="self-start text-xs text-muted-foreground underline-offset-2 hover:underline"
>
← Back to all templates
</button>
{placeholders.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{placeholders.map((name) => (
<div key={name} className="flex flex-col gap-1.5">
<Label htmlFor={`template-field-${name}`}>{name}</Label>
<Input
id={`template-field-${name}`}
value={values[name] ?? ""}
onChange={(event) =>
setValues((previous) => ({
...previous,
[name]: event.target.value,
}))
}
/>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
This template has no fields to fill in.
</p>
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
Preview
</span>
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant={previewMode === "rendered" ? "secondary" : "ghost"}
className="h-6 px-2 text-xs"
onClick={() => setPreviewMode("rendered")}
>
Rendered
</Button>
<Button
type="button"
size="sm"
variant={previewMode === "source" ? "secondary" : "ghost"}
className="h-6 px-2 text-xs"
onClick={() => setPreviewMode("source")}
>
Markdown
</Button>
</div>
</div>
{previewMode === "rendered" ? (
<div className="max-h-72 min-h-24 overflow-auto rounded-md border bg-muted/20 p-3">
{previewCss ? (
<style dangerouslySetInnerHTML={{ __html: previewCss }} />
) : null}
<div className={cn("wiki-markdown", previewScopeClasses)}>
<WikiPreparedBody
prepared={preparedPreview}
htmlTemplates={templateHtmlMap}
/>
</div>
</div>
) : (
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">
{filled}
</pre>
)}
</div>
<div className={cn("flex justify-end gap-2")}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleInsert}>Insert</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}