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>
262 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|