1
0
Fork 0
polaris-task-force/src/components/frontend/wiki/WikiEditor.tsx
Z8MB1E c9a3d23f96 feat(wiki): add Documentation category restricted to admins and developers
New in-depth, non-developer guides for the core systems: logistics and storage rules, banking, market and negotiation, shipments, base management, and the map. Read stays open to any logged-in user; create and edit require admin or developer roles (enforced in the collection access functions and the wiki server actions, since the service layer runs with overrideAccess). Category escalation via update is blocked too. Ships the enum migration, regenerated schema/types, and the idempotent seed script with its markdown sources.
2026-09-30 18:41:07 -04:00

276 lines
9.3 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;
/**
* Whether the current user may use the Documentation category (admin and
* developer roles). The server enforces this too; this only shapes the
* category picker.
*/
readonly canEditDocumentation?: 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,
canEditDocumentation = 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) => {
if (option === "Documentation" && !canEditDocumentation) {
// Keep the option visible-but-disabled when the page being
// edited is already Documentation, so the value renders.
if (category !== "Documentation") return null;
}
return (
<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>
);
}