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>
234 lines
8 KiB
TypeScript
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>
|
|
);
|
|
}
|