feat(wiki): add wiki UI components
Frontend components for the wiki: content renderer (WikiContent), editor with live preview and formatting toolbar (WikiEditor, WikiEditorBody, WikiEditorToolbar, WikiLinkDialog, MarkdownGuide), index/browse (WikiIndex, WikiStandardsDialog), page toolbar with lock/delete/history (WikiToolbar, LockdownBanner), and revision history (RevisionList).
This commit is contained in:
parent
3c6a9c8a42
commit
1dac6a0f9a
11 changed files with 1316 additions and 0 deletions
14
src/components/frontend/wiki/LockdownBanner.tsx
Normal file
14
src/components/frontend/wiki/LockdownBanner.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { LockKeyholeIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
export function LockdownBanner() {
|
||||
return (
|
||||
<Alert className="border-amber-500/40 bg-amber-500/10 [&>svg]:text-amber-400">
|
||||
<LockKeyholeIcon />
|
||||
<AlertTitle>Page locked</AlertTitle>
|
||||
<AlertDescription>
|
||||
Editing is paused while this page is under intelligence division review.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
110
src/components/frontend/wiki/MarkdownGuide.tsx
Normal file
110
src/components/frontend/wiki/MarkdownGuide.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function GuideSection({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
readonly children: ReactNode;
|
||||
readonly title: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-foreground">{title}</h3>
|
||||
<div className="flex flex-col gap-1">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Example({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<p>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownGuide() {
|
||||
return (
|
||||
<div className="flex min-h-60 max-h-96 flex-col gap-4 overflow-y-auto rounded-md border bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
<GuideSection title="Basics">
|
||||
<Example>## Heading</Example>
|
||||
<Example>**bold**</Example>
|
||||
<Example>*italic*</Example>
|
||||
<Example>~~strikethrough~~</Example>
|
||||
<Example>- bullet list / 1. numbered list</Example>
|
||||
<Example>[label](url)</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Wikilinks">
|
||||
<Example>[[Page]] / [[Page|label]]</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Code & tables">
|
||||
<Example>`code`</Example>
|
||||
<Example>```ts ... ```</Example>
|
||||
<Example>| Header | Header |</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Images">
|
||||
<Example></Example>
|
||||
<p>
|
||||
Paste any image URL, or use the toolbar's upload button to store an image in media. The
|
||||
caption is the image hover title.
|
||||
</p>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Footnotes & citations">
|
||||
<Example>Text.[^1]</Example>
|
||||
<Example>[^1]: Footnote or citation — source, URL</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Layout">
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted p-3 font-mono text-xs leading-5 text-foreground">
|
||||
<code>{`::::columns
|
||||
:::column
|
||||
Left column content
|
||||
:::
|
||||
|
||||
:::column
|
||||
Right column content
|
||||
:::
|
||||
::::`}</code>
|
||||
</pre>
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted p-3 font-mono text-xs leading-5 text-foreground">
|
||||
<code>{`::::columns
|
||||
:::column
|
||||
Left column content
|
||||
:::
|
||||
|
||||
:::column
|
||||
Center column content
|
||||
:::
|
||||
|
||||
:::column
|
||||
Right column content
|
||||
:::
|
||||
::::`}</code>
|
||||
</pre>
|
||||
<Example>:::note / :::warning</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Templates">
|
||||
<Example>{`{{TemplateName}}`}</Example>
|
||||
<p>Expands in the preview when a matching template exists.</p>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Keybinds">
|
||||
<Example>Ctrl/Cmd+B — bold</Example>
|
||||
<Example>Ctrl/Cmd+I — italic</Example>
|
||||
<Example>Ctrl/Cmd+Shift+X — strikethrough</Example>
|
||||
<Example>Ctrl/Cmd+Shift+H — heading</Example>
|
||||
<Example>Ctrl/Cmd+K — open wikilink dialog</Example>
|
||||
<Example>Ctrl/Cmd+E — inline code</Example>
|
||||
</GuideSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/components/frontend/wiki/RevisionList.tsx
Normal file
139
src/components/frontend/wiki/RevisionList.tsx
Normal file
|
|
@ -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<WikiRevision["type"], { label: string; className: string }> = {
|
||||
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<number | null>(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 (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm italic text-muted-foreground">No revisions recorded yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{revisions.map((revision) => {
|
||||
const typeMeta = REVISION_TYPE_META[revision.type];
|
||||
return (
|
||||
<Card key={revision.id}>
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">
|
||||
Revision {revision.revisionNumber}
|
||||
</span>
|
||||
<Badge variant="outline" className={typeMeta.className}>
|
||||
{typeMeta.label}
|
||||
</Badge>
|
||||
{revision.restoredFromRevision !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
from revision {revision.restoredFromRevision}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium">{revision.titleSnapshot}</p>
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{revision.summary || "No summary provided."}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Edited by {revision.editorName}</span>
|
||||
<time dateTime={revision.createdAt}>{timeAgo(revision.createdAt)}</time>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/wiki/${slug}?revision=${revision.revisionNumber}`}>
|
||||
View revision
|
||||
</Link>
|
||||
</Button>
|
||||
{isModerator && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleRestore(revision.revisionNumber)}
|
||||
disabled={restoring !== null}
|
||||
>
|
||||
{restoring === revision.revisionNumber ? "Restoring..." : "Restore"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/frontend/wiki/WikiContent.tsx
Normal file
20
src/components/frontend/wiki/WikiContent.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="wiki-markdown flex flex-col gap-4 text-sm">
|
||||
<Markdown
|
||||
remarkPlugins={wikiRemarkPlugins}
|
||||
remarkRehypeOptions={wikiRemarkRehypeOptions}
|
||||
components={wikiMarkdownComponents}
|
||||
>
|
||||
{prepared}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
src/components/frontend/wiki/WikiEditor.tsx
Normal file
248
src/components/frontend/wiki/WikiEditor.tsx
Normal file
|
|
@ -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<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,
|
||||
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);
|
||||
|
||||
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)}>
|
||||
<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>
|
||||
|
||||
<WikiEditorBody
|
||||
body={body}
|
||||
bodyRef={bodyRef}
|
||||
existingPages={existingPages}
|
||||
linkDialogOpen={linkDialogOpen}
|
||||
onBodyChange={setBody}
|
||||
onLinkDialogChange={setLinkDialogOpen}
|
||||
pending={pending}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<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="wiki-markdown flex flex-col gap-4 text-sm">
|
||||
<Markdown
|
||||
remarkPlugins={wikiRemarkPlugins}
|
||||
remarkRehypeOptions={wikiRemarkRehypeOptions}
|
||||
components={wikiMarkdownComponents}
|
||||
>
|
||||
{preparedPreview}
|
||||
</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">Nothing to preview yet.</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
292
src/components/frontend/wiki/WikiEditorBody.tsx
Normal file
292
src/components/frontend/wiki/WikiEditorBody.tsx
Normal file
|
|
@ -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<HTMLTextAreaElement | null>;
|
||||
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<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
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: ``, 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<HTMLTextAreaElement>) {
|
||||
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", <URL>'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor="wiki-body">Body</Label>
|
||||
<WikiLinkDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={onLinkDialogChange}
|
||||
existingPages={existingPages}
|
||||
onSelect={insertWikilink}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<WikiEditorToolbar groups={groups} disabled={pending || uploading} />
|
||||
<Textarea
|
||||
ref={bodyRef}
|
||||
id="wiki-body"
|
||||
rows={18}
|
||||
value={body}
|
||||
onChange={(event) => onBodyChange(event.target.value)}
|
||||
onKeyDown={handleBodyKeyDown}
|
||||
placeholder="# Situation\n\nWrite the field guide entry in Markdown..."
|
||||
disabled={pending}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/components/frontend/wiki/WikiEditorToolbar.tsx
Normal file
76
src/components/frontend/wiki/WikiEditorToolbar.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export interface WikiEditorToolbarAction {
|
||||
readonly label: string;
|
||||
readonly shortcut?: string;
|
||||
readonly icon: LucideIcon;
|
||||
readonly onClick: () => void;
|
||||
}
|
||||
|
||||
interface WikiEditorToolbarProps {
|
||||
readonly disabled: boolean;
|
||||
readonly groups: readonly (readonly WikiEditorToolbarAction[])[];
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
shortcut,
|
||||
}: WikiEditorToolbarAction & { readonly disabled: boolean }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="size-8 p-0"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{shortcut ? `${label} (${shortcut})` : label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function WikiEditorToolbar({ disabled, groups }: WikiEditorToolbarProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-center gap-1 rounded-md border bg-muted/20 p-1"
|
||||
role="toolbar"
|
||||
aria-label="Markdown formatting toolbar"
|
||||
>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<Fragment key={`toolbar-group-${groupIndex}`}>
|
||||
{groupIndex > 0 && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="my-1 h-px w-full shrink-0 sm:my-0 sm:h-6 sm:w-px"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{group.map((action) => (
|
||||
<ToolbarButton key={action.label} {...action} disabled={disabled} />
|
||||
))}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
187
src/components/frontend/wiki/WikiIndex.tsx
Normal file
187
src/components/frontend/wiki/WikiIndex.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRightIcon, PlusIcon, SearchIcon, TagIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { WIKI_CATEGORIES, type WikiCategory } from "@/lib/wiki/categories";
|
||||
|
||||
export interface WikiIndexPage {
|
||||
readonly id: number;
|
||||
readonly title: string;
|
||||
readonly slug: string;
|
||||
readonly category: WikiCategory;
|
||||
readonly tags: readonly string[];
|
||||
readonly updatedAt: string;
|
||||
readonly lastEditorName: string;
|
||||
}
|
||||
|
||||
type CategoryFilter = "all" | WikiCategory;
|
||||
|
||||
interface WikiIndexProps {
|
||||
readonly pages: readonly WikiIndexPage[];
|
||||
readonly isModerator: boolean;
|
||||
}
|
||||
|
||||
function isCategoryFilter(value: string): value is CategoryFilter {
|
||||
return value === "all" || WIKI_CATEGORIES.some((category) => category === value);
|
||||
}
|
||||
|
||||
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);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
export function WikiIndex({ pages, isModerator }: WikiIndexProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
const [category, setCategory] = useState<CategoryFilter>("all");
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const tagQuery = tagFilter.trim().toLowerCase();
|
||||
|
||||
return [...pages]
|
||||
.sort(
|
||||
(left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||
)
|
||||
.filter((page) => {
|
||||
if (category !== "all" && page.category !== category) return false;
|
||||
if (query && !`${page.title} ${page.slug}`.toLowerCase().includes(query)) return false;
|
||||
if (tagQuery && !page.tags.some((tag) => tag.toLowerCase().includes(tagQuery)))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}, [category, pages, search, tagFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="wiki-search">Search</Label>
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="wiki-search"
|
||||
aria-label="Search wiki pages"
|
||||
className="w-full pl-8 sm:w-64"
|
||||
placeholder="Search title or slug..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="wiki-tag-filter">Tag</Label>
|
||||
<div className="relative">
|
||||
<TagIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="wiki-tag-filter"
|
||||
aria-label="Filter wiki tags"
|
||||
className="w-full pl-8 sm:w-48"
|
||||
placeholder="Filter tags..."
|
||||
value={tagFilter}
|
||||
onChange={(event) => setTagFilter(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/wiki/new">
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
New page
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={category}
|
||||
onValueChange={(value) => {
|
||||
if (isCategoryFilter(value)) setCategory(value);
|
||||
}}
|
||||
>
|
||||
<TabsList className="max-w-full justify-start gap-1 overflow-x-auto">
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
{WIKI_CATEGORIES.map((option) => (
|
||||
<TabsTrigger key={option} value={option}>
|
||||
{option}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Field guide
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filteredPages.length} page{filteredPages.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{isModerator && <Badge variant="outline">Intel editor</Badge>}
|
||||
</div>
|
||||
|
||||
{filteredPages.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 p-6">
|
||||
<p className="font-semibold">No pages match those filters.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Try a different title, tag, or category, or start a new field guide entry.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{filteredPages.map((page) => (
|
||||
<Card key={page.id} className="transition-colors hover:bg-accent/50">
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="min-w-0 text-base">
|
||||
<Link
|
||||
href={`/wiki/${page.slug}`}
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<span className="truncate">{page.title}</span>
|
||||
<ArrowUpRightIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{page.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{page.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="font-normal">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{page.tags.length === 0 && (
|
||||
<span className="text-xs italic text-muted-foreground">No tags</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>Edited by {page.lastEditorName}</span>
|
||||
<time dateTime={page.updatedAt}>{timeAgo(page.updatedAt)}</time>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/frontend/wiki/WikiLinkDialog.tsx
Normal file
73
src/components/frontend/wiki/WikiLinkDialog.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { BookOpenIcon, LinkIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface WikiLinkDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly existingPages: readonly { title: string }[];
|
||||
readonly onSelect: (title: string) => void;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export function WikiLinkDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
existingPages,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
}: WikiLinkDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" size="sm" variant="outline" disabled={disabled}>
|
||||
<LinkIcon data-icon="inline-start" />
|
||||
Insert wikilink
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert wikilink</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a page to insert at the current cursor position.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex max-h-80 flex-col gap-1 overflow-y-auto">
|
||||
{existingPages.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm italic text-muted-foreground">
|
||||
No other pages exist yet.
|
||||
</p>
|
||||
) : (
|
||||
existingPages.map((page) => (
|
||||
<Button
|
||||
key={page.title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="justify-start"
|
||||
onClick={() => onSelect(page.title)}
|
||||
>
|
||||
<BookOpenIcon data-icon="inline-start" />
|
||||
{page.title}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
52
src/components/frontend/wiki/WikiStandardsDialog.tsx
Normal file
52
src/components/frontend/wiki/WikiStandardsDialog.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { BookOpenIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface WikiStandardsDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly onAccept: () => void;
|
||||
}
|
||||
|
||||
export function WikiStandardsDialog({ open, onOpenChange, onAccept }: WikiStandardsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpenIcon className="size-5 text-muted-foreground" />
|
||||
<DialogTitle>Before you write</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="flex flex-col gap-3 pt-2 text-left">
|
||||
<p>
|
||||
Every entry in the field guide is part of the task force's permanent record.
|
||||
Contribute content that is authentic, serious, and relevant to the subject at hand.
|
||||
</p>
|
||||
<p>
|
||||
This is a legitimate wiki — not a place for jokes, memes, or off-topic entries. Write
|
||||
as if the record will still be read long after today.
|
||||
</p>
|
||||
<p>
|
||||
Edits are tracked in the revision history, and moderators can revert or remove pages
|
||||
that don't meet these standards.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onAccept}>
|
||||
I understand
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
105
src/components/frontend/wiki/WikiToolbar.tsx
Normal file
105
src/components/frontend/wiki/WikiToolbar.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
HistoryIcon,
|
||||
LockKeyholeIcon,
|
||||
PencilIcon,
|
||||
Trash2Icon,
|
||||
UnlockKeyholeIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { deleteWikiPage, setPageLockdown } from "@/app/(frontend)/wiki/actions";
|
||||
|
||||
interface WikiToolbarProps {
|
||||
readonly slug: string;
|
||||
readonly id: number;
|
||||
readonly lockdown: boolean;
|
||||
readonly isModerator: boolean;
|
||||
readonly canEdit: boolean;
|
||||
}
|
||||
|
||||
export function WikiToolbar({ slug, id, lockdown, isModerator, canEdit }: WikiToolbarProps) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<"lock" | "delete" | null>(null);
|
||||
|
||||
async function handleLockdown() {
|
||||
setBusy("lock");
|
||||
const result = await setPageLockdown({ id, lockdown: !lockdown });
|
||||
setBusy(null);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Could not update the page lock.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(lockdown ? "Page unlocked" : "Page locked");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm("Delete this wiki page and its revision history?")) return;
|
||||
|
||||
setBusy("delete");
|
||||
const result = await deleteWikiPage({ id });
|
||||
setBusy(null);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Could not delete the wiki page.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Wiki page deleted");
|
||||
router.push("/wiki");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{canEdit && (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/wiki/${slug}/edit`}>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/wiki/${slug}/history`}>
|
||||
<HistoryIcon data-icon="inline-start" />
|
||||
History
|
||||
</Link>
|
||||
</Button>
|
||||
{isModerator && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleLockdown()}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{lockdown ? (
|
||||
<UnlockKeyholeIcon data-icon="inline-start" />
|
||||
) : (
|
||||
<LockKeyholeIcon data-icon="inline-start" />
|
||||
)}
|
||||
{busy === "lock" ? "Saving..." : lockdown ? "Unlock" : "Lock"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
{busy === "delete" ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue