diff --git a/src/components/frontend/wiki/LockdownBanner.tsx b/src/components/frontend/wiki/LockdownBanner.tsx new file mode 100644 index 0000000..da456e3 --- /dev/null +++ b/src/components/frontend/wiki/LockdownBanner.tsx @@ -0,0 +1,14 @@ +import { LockKeyholeIcon } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +export function LockdownBanner() { + return ( + + + Page locked + + Editing is paused while this page is under intelligence division review. + + + ); +} diff --git a/src/components/frontend/wiki/MarkdownGuide.tsx b/src/components/frontend/wiki/MarkdownGuide.tsx new file mode 100644 index 0000000..3c21f3c --- /dev/null +++ b/src/components/frontend/wiki/MarkdownGuide.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { ReactNode } from "react"; + +function GuideSection({ + children, + title, +}: { + readonly children: ReactNode; + readonly title: string; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function Example({ children }: { readonly children: ReactNode }) { + return ( +

+ + {children} + +

+ ); +} + +export function MarkdownGuide() { + return ( +
+ + ## Heading + **bold** + *italic* + ~~strikethrough~~ + - bullet list / 1. numbered list + [label](url) + + + + [[Page]] / [[Page|label]] + + + + `code` + ```ts ... ``` + | Header | Header | + + + + ![alt text](https://… "Caption on hover") +

+ Paste any image URL, or use the toolbar's upload button to store an image in media. The + caption is the image hover title. +

+
+ + + Text.[^1] + [^1]: Footnote or citation — source, URL + + + +
+          {`::::columns
+:::column
+Left column content
+:::
+
+:::column
+Right column content
+:::
+::::`}
+        
+
+          {`::::columns
+:::column
+Left column content
+:::
+
+:::column
+Center column content
+:::
+
+:::column
+Right column content
+:::
+::::`}
+        
+ :::note / :::warning +
+ + + {`{{TemplateName}}`} +

Expands in the preview when a matching template exists.

+
+ + + Ctrl/Cmd+B — bold + Ctrl/Cmd+I — italic + Ctrl/Cmd+Shift+X — strikethrough + Ctrl/Cmd+Shift+H — heading + Ctrl/Cmd+K — open wikilink dialog + Ctrl/Cmd+E — inline code + +
+ ); +} diff --git a/src/components/frontend/wiki/RevisionList.tsx b/src/components/frontend/wiki/RevisionList.tsx new file mode 100644 index 0000000..2363f32 --- /dev/null +++ b/src/components/frontend/wiki/RevisionList.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import type { WikiRevision } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { restoreWikiRevision } from "@/app/(frontend)/wiki/actions"; + +export interface WikiRevisionSummary { + readonly id: number; + readonly revisionNumber: number; + readonly titleSnapshot: string; + readonly editorName: string; + readonly summary: string; + readonly type: WikiRevision["type"]; + readonly restoredFromRevision: number | null; + readonly createdAt: string; +} + +interface RevisionListProps { + readonly pageId: number; + readonly slug: string; + readonly revisions: readonly WikiRevisionSummary[]; + readonly isModerator: boolean; +} + +const REVISION_TYPE_META: Record = { + create: { + label: "Create", + className: "border-emerald-500/40 text-emerald-400", + }, + edit: { + label: "Edit", + className: "border-blue-500/40 text-blue-400", + }, + restore: { + label: "Restore", + className: "border-amber-500/40 text-amber-400", + }, +}; + +function timeAgo(iso: string): string { + const diff = Math.max(0, Date.now() - new Date(iso).getTime()); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function RevisionList({ pageId, slug, revisions, isModerator }: RevisionListProps) { + const router = useRouter(); + const [restoring, setRestoring] = useState(null); + + async function handleRestore(revisionNumber: number) { + setRestoring(revisionNumber); + const result = await restoreWikiRevision({ id: pageId, revisionNumber }); + setRestoring(null); + + if (!result.success) { + toast.error(result.error ?? "Could not restore the revision."); + return; + } + + toast.success(`Restored revision ${revisionNumber}`); + router.refresh(); + } + + if (revisions.length === 0) { + return ( + + +

No revisions recorded yet.

+
+
+ ); + } + + return ( +
+ {revisions.map((revision) => { + const typeMeta = REVISION_TYPE_META[revision.type]; + return ( + + +
+
+ + Revision {revision.revisionNumber} + + + {typeMeta.label} + + {revision.restoredFromRevision !== null && ( + + from revision {revision.restoredFromRevision} + + )} +
+

{revision.titleSnapshot}

+

+ {revision.summary || "No summary provided."} +

+
+ Edited by {revision.editorName} + +
+
+
+ + {isModerator && ( + + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/frontend/wiki/WikiContent.tsx b/src/components/frontend/wiki/WikiContent.tsx new file mode 100644 index 0000000..4e20d04 --- /dev/null +++ b/src/components/frontend/wiki/WikiContent.tsx @@ -0,0 +1,20 @@ +import Markdown from "react-markdown"; +import { + wikiMarkdownComponents, + wikiRemarkPlugins, + wikiRemarkRehypeOptions, +} from "@/lib/wiki/markdown"; + +export function WikiContent({ prepared }: { readonly prepared: string }) { + return ( +
+ + {prepared} + +
+ ); +} diff --git a/src/components/frontend/wiki/WikiEditor.tsx b/src/components/frontend/wiki/WikiEditor.tsx new file mode 100644 index 0000000..891ab9c --- /dev/null +++ b/src/components/frontend/wiki/WikiEditor.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import Markdown from "react-markdown"; +import { toast } from "sonner"; +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 { + wikiMarkdownComponents, + wikiRemarkPlugins, + wikiRemarkRehypeOptions, +} from "@/lib/wiki/markdown"; +import { MarkdownGuide } from "@/components/frontend/wiki/MarkdownGuide"; +import { WikiEditorBody } from "@/components/frontend/wiki/WikiEditorBody"; +import { WikiStandardsDialog } from "@/components/frontend/wiki/WikiStandardsDialog"; + +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; + 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, + existingPages, + isFirstEdit = false, +}: WikiEditorProps) { + const router = useRouter(); + const bodyRef = useRef(null); + const [title, setTitle] = useState(initial?.title ?? initialTitle ?? ""); + const [category, setCategory] = useState(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); + + function handleStandardsAccept() { + window.localStorage.setItem(STANDARDS_ACCEPTED_KEY, "1"); + setShowStandards(false); + } + + async function handleSave(event: React.FormEvent) { + 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 ( +
void handleSave(event)}> +
+
+ + setTitle(event.target.value)} + placeholder="Operation Northern Light" + disabled={pending} + autoFocus={mode === "create"} + /> +
+
+ + +
+
+ + setTags(event.target.value)} + placeholder="arctic, operation, briefing" + disabled={pending} + /> +
+
+ + setSummary(event.target.value)} + placeholder={mode === "create" ? "Created page" : "What changed?"} + disabled={pending} + /> +
+
+ + + +
+
+

+ Live preview +

+
+ {preparedPreview ? ( +
+ + {preparedPreview} + +
+ ) : ( +

Nothing to preview yet.

+ )} +
+
+
+

+ Markdown guide +

+ +
+
+ +
+ + +
+ + + + ); +} diff --git a/src/components/frontend/wiki/WikiEditorBody.tsx b/src/components/frontend/wiki/WikiEditorBody.tsx new file mode 100644 index 0000000..9882d17 --- /dev/null +++ b/src/components/frontend/wiki/WikiEditorBody.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { type ChangeEvent, type KeyboardEvent, type RefObject, useRef, useState } from "react"; +import { + Bold, + Code, + Columns2, + Columns3, + FileCode2, + Heading2, + Image, + ImageUp, + Italic, + Link2, + List, + ListOrdered, + NotebookPen, + Quote, + Strikethrough, + Table, +} from "lucide-react"; +import { toast } from "sonner"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { WikiLinkDialog } from "@/components/frontend/wiki/WikiLinkDialog"; +import { uploadWikiImage } from "@/app/(frontend)/wiki/actions"; +import { + IMAGE_SNIPPET, + TABLE_SNIPPET, + THREE_COLUMN_SNIPPET, + TWO_COLUMN_SNIPPET, + createLinePrefixUpdate, + createReferenceUpdate, + createSnippetUpdate, + createWrappedUpdate, + readBodySelection, + type BodyUpdate, + type SnippetInsertion, + type WrappedFormat, +} from "@/lib/wiki/editorFormatting"; +import { + WikiEditorToolbar, + type WikiEditorToolbarAction, +} from "@/components/frontend/wiki/WikiEditorToolbar"; + +interface WikiEditorBodyProps { + readonly body: string; + readonly bodyRef: RefObject; + readonly existingPages: readonly { title: string }[]; + readonly linkDialogOpen: boolean; + readonly onBodyChange: (value: string) => void; + readonly onLinkDialogChange: (open: boolean) => void; + readonly pending: boolean; +} + +export function WikiEditorBody({ + body, + bodyRef, + existingPages, + linkDialogOpen, + onBodyChange, + onLinkDialogChange, + pending, +}: WikiEditorBodyProps) { + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + async function handleFileChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + + setUploading(true); + try { + const formData = new FormData(); + formData.append("file", file); + const result = await uploadWikiImage(formData); + if (!result.success || !result.data) { + toast.error(result.error ?? "Upload failed."); + return; + } + const { url, alt } = result.data; + insertSnippet({ insertion: `![${alt}](${url})`, placeholder: alt }); + toast.success("Image uploaded."); + } catch { + toast.error("Upload failed."); + } finally { + setUploading(false); + } + } + + function applyBodyUpdate(update: BodyUpdate) { + onBodyChange(update.value); + requestAnimationFrame(() => { + const textarea = bodyRef.current; + textarea?.focus(); + textarea?.setSelectionRange(update.selectionStart, update.selectionEnd); + }); + } + + function wrapSelection(format: WrappedFormat) { + const selection = readBodySelection(body, bodyRef); + applyBodyUpdate(createWrappedUpdate(body, selection, format)); + } + + function prefixLines(prefix: string) { + const selection = readBodySelection(body, bodyRef); + applyBodyUpdate(createLinePrefixUpdate(body, selection, prefix)); + } + + function insertSnippet(snippet: SnippetInsertion) { + const selection = readBodySelection(body, bodyRef); + applyBodyUpdate(createSnippetUpdate(body, selection, snippet)); + } + + function insertReference(definitionSuffix: string) { + const selection = readBodySelection(body, bodyRef); + applyBodyUpdate(createReferenceUpdate(body, selection, { definitionSuffix })); + } + + function insertWikilink(pageTitle: string) { + const selection = readBodySelection(body, bodyRef); + applyBodyUpdate( + createSnippetUpdate(body, selection, { + insertion: `[[${pageTitle}]]`, + }), + ); + onLinkDialogChange(false); + } + + function handleBodyKeyDown(event: KeyboardEvent) { + const modifierPressed = event.metaKey || event.ctrlKey; + if (!modifierPressed || event.altKey) return; + + const key = event.key.toLowerCase(); + if (key === "b" && !event.shiftKey) { + event.preventDefault(); + wrapSelection({ prefix: "**", suffix: "**", placeholder: "bold text" }); + return; + } + if (key === "i" && !event.shiftKey) { + event.preventDefault(); + wrapSelection({ prefix: "*", suffix: "*", placeholder: "italic text" }); + return; + } + if (key === "x" && event.shiftKey) { + event.preventDefault(); + wrapSelection({ prefix: "~~", suffix: "~~", placeholder: "strikethrough text" }); + return; + } + if (key === "h" && event.shiftKey) { + event.preventDefault(); + prefixLines("## "); + return; + } + if (key === "k" && !event.shiftKey) { + event.preventDefault(); + onLinkDialogChange(true); + return; + } + if (key === "e" && !event.shiftKey) { + event.preventDefault(); + wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" }); + } + } + + const groups: readonly (readonly WikiEditorToolbarAction[])[] = [ + [ + { + label: "Bold", + shortcut: "Ctrl/Cmd+B", + icon: Bold, + onClick: () => wrapSelection({ prefix: "**", suffix: "**", placeholder: "bold text" }), + }, + { + label: "Italic", + shortcut: "Ctrl/Cmd+I", + icon: Italic, + onClick: () => wrapSelection({ prefix: "*", suffix: "*", placeholder: "italic text" }), + }, + { + label: "Strikethrough", + shortcut: "Ctrl/Cmd+Shift+X", + icon: Strikethrough, + onClick: () => + wrapSelection({ prefix: "~~", suffix: "~~", placeholder: "strikethrough text" }), + }, + { + label: "Heading", + shortcut: "Ctrl/Cmd+Shift+H", + icon: Heading2, + onClick: () => prefixLines("## "), + }, + ], + [ + { + label: "Inline code", + shortcut: "Ctrl/Cmd+E", + icon: Code, + onClick: () => wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" }), + }, + { + label: "Code block", + icon: FileCode2, + onClick: () => + wrapSelection({ prefix: "```\n", suffix: "\n```", placeholder: "code block" }), + }, + { label: "Quote", icon: Quote, onClick: () => prefixLines("> ") }, + ], + [ + { label: "Bulleted list", icon: List, onClick: () => prefixLines("- ") }, + { label: "Numbered list", icon: ListOrdered, onClick: () => prefixLines("1. ") }, + { + label: "Table", + icon: Table, + onClick: () => insertSnippet({ insertion: TABLE_SNIPPET, placeholder: "Header" }), + }, + ], + [ + { + label: "Wikilink", + shortcut: "Ctrl/Cmd+K", + icon: Link2, + onClick: () => onLinkDialogChange(true), + }, + { + label: "Captioned image", + icon: Image, + onClick: () => insertSnippet({ insertion: IMAGE_SNIPPET, placeholder: "Alt text" }), + }, + { + label: "Upload image", + icon: ImageUp, + onClick: () => fileInputRef.current?.click(), + }, + { label: "Footnote", icon: NotebookPen, onClick: () => insertReference("") }, + { + label: "Citation", + icon: NotebookPen, + onClick: () => insertReference('Author, "Title", '), + }, + ], + [ + { + label: "2 columns", + icon: Columns2, + onClick: () => + insertSnippet({ insertion: TWO_COLUMN_SNIPPET, placeholder: "Left column content" }), + }, + { + label: "3 columns", + icon: Columns3, + onClick: () => + insertSnippet({ insertion: THREE_COLUMN_SNIPPET, placeholder: "Left column content" }), + }, + ], + ]; + + return ( +
+ +
+ + +
+ +