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