From f38962c5892c5366df362eae65a69e9f31ba96b7 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sat, 5 Sep 2026 00:02:18 -0400 Subject: [PATCH] feat(wiki): add wiki pages Index with search/tag/category tabs, new-page form, slug detail page with create-CTA for missing pages, edit page redirecting to /wiki/new for missing pages, and revision history page. --- src/app/(frontend)/wiki/[slug]/edit/page.tsx | 118 +++++++++ .../(frontend)/wiki/[slug]/history/page.tsx | 129 ++++++++++ src/app/(frontend)/wiki/[slug]/page.tsx | 232 ++++++++++++++++++ src/app/(frontend)/wiki/new/page.tsx | 74 ++++++ src/app/(frontend)/wiki/page.tsx | 67 +++++ 5 files changed, 620 insertions(+) create mode 100644 src/app/(frontend)/wiki/[slug]/edit/page.tsx create mode 100644 src/app/(frontend)/wiki/[slug]/history/page.tsx create mode 100644 src/app/(frontend)/wiki/[slug]/page.tsx create mode 100644 src/app/(frontend)/wiki/new/page.tsx create mode 100644 src/app/(frontend)/wiki/page.tsx diff --git a/src/app/(frontend)/wiki/[slug]/edit/page.tsx b/src/app/(frontend)/wiki/[slug]/edit/page.tsx new file mode 100644 index 0000000..005f247 --- /dev/null +++ b/src/app/(frontend)/wiki/[slug]/edit/page.tsx @@ -0,0 +1,118 @@ +import config from "@payload-config"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { headers as nextHeaders } from "next/headers"; +import { getPayload } from "payload"; +import { ArrowLeftIcon, PencilIcon } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { LockdownBanner } from "@/components/frontend/wiki/LockdownBanner"; +import { WikiEditor } from "@/components/frontend/wiki/WikiEditor"; +import { listTemplates, templateMap } from "@/lib/wiki/service"; + +export const metadata = { + title: "Edit wiki page — Polaris Task Force", +}; + +export default async function EditWikiPage({ + params, +}: { + readonly params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const payload = await getPayload({ config }); + const headers = await nextHeaders(); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + const pageResult = await payload.find({ + collection: "wiki-pages", + where: { slug: { equals: slug } }, + limit: 1, + depth: 1, + overrideAccess: true, + }); + const page = pageResult.docs[0]; + + if (!page) { + redirect(`/wiki/new?title=${encodeURIComponent(slug)}`); + } + + if (page.lockdown) { + return ( +
+ + + Back to page + +
+ +

Edit {page.title}

+
+ +
+ ); + } + + const [templates, pagesResult, hasEditedRevisions] = await Promise.all([ + listTemplates(payload), + payload.find({ + collection: "wiki-pages", + sort: "title", + limit: 200, + depth: 0, + overrideAccess: true, + }), + user + ? payload + .find({ + collection: "wiki-revisions", + where: { editor: { equals: user.id } }, + limit: 1, + depth: 0, + overrideAccess: true, + }) + .then((result) => result.docs.length > 0) + : Promise.resolve(true), + ]); + const existingPages = pagesResult.docs.map((existingPage) => ({ title: existingPage.title })); + + return ( +
+
+ + + Back to page + +
+ +

Edit {page.title}

+
+

+ Update the article and leave a short note for the revision history. +

+
+ + + + + + +
+ ); +} diff --git a/src/app/(frontend)/wiki/[slug]/history/page.tsx b/src/app/(frontend)/wiki/[slug]/history/page.tsx new file mode 100644 index 0000000..90ec2e3 --- /dev/null +++ b/src/app/(frontend)/wiki/[slug]/history/page.tsx @@ -0,0 +1,129 @@ +import config from "@payload-config"; +import { headers as nextHeaders } from "next/headers"; +import Link from "next/link"; +import { getPayload } from "payload"; +import { ArrowLeftIcon, HistoryIcon } from "lucide-react"; +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 { RevisionList, type WikiRevisionSummary } from "@/components/frontend/wiki/RevisionList"; +import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; + +export const metadata = { + title: "Wiki history — Polaris Task Force", +}; + +function editorName(editor: WikiRevision["editor"]): string { + if (typeof editor === "object" && editor !== null) { + return editor.payloadDisplayName || editor.displayName || editor.username || "Unknown editor"; + } + + return typeof editor === "number" ? `User #${editor}` : "Unknown editor"; +} + +function MissingWikiPage({ slug }: { readonly slug: string }) { + return ( + + +
+

Page doesn't exist yet

+

+ There is no revision history until this page is created. +

+
+ +
+
+ ); +} + +export default async function WikiHistoryPage({ + params, +}: { + readonly params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const headers = await nextHeaders(); + const payload = await getPayload({ config }); + const { user } = await payload.auth({ + headers, + canSetHeaders: false, + }); + const pageResult = await payload.find({ + collection: "wiki-pages", + where: { slug: { equals: slug } }, + limit: 1, + depth: 1, + overrideAccess: true, + }); + const page = pageResult.docs[0]; + + if (!page) { + return ( +
+ + + Wiki + + +
+ ); + } + + const [revisionsResult, isModerator] = await Promise.all([ + payload.find({ + collection: "wiki-revisions", + where: { page: { equals: page.id } }, + sort: "-revisionNumber", + limit: 200, + depth: 1, + overrideAccess: true, + }), + hasIntelligenceQualification(payload, user), + ]); + const revisions: WikiRevisionSummary[] = revisionsResult.docs.map((revision) => ({ + id: revision.id, + revisionNumber: revision.revisionNumber, + titleSnapshot: revision.titleSnapshot, + editorName: editorName(revision.editor), + summary: revision.summary ?? "", + type: revision.type, + restoredFromRevision: revision.restoredFromRevision ?? null, + createdAt: revision.createdAt, + })); + + return ( +
+
+ + + Back to page + +
+ +

History: {page.title}

+ {revisions.length} revisions +
+

+ Review the article's edit trail and restore an earlier snapshot when needed. +

+
+ + +
+ ); +} diff --git a/src/app/(frontend)/wiki/[slug]/page.tsx b/src/app/(frontend)/wiki/[slug]/page.tsx new file mode 100644 index 0000000..1bba8ab --- /dev/null +++ b/src/app/(frontend)/wiki/[slug]/page.tsx @@ -0,0 +1,232 @@ +import config from "@payload-config"; +import { headers as nextHeaders } from "next/headers"; +import Link from "next/link"; +import { getPayload } from "payload"; +import { ArrowLeftIcon, BookOpenIcon } from "lucide-react"; +import type { WikiPage as WikiPageDocument } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { LockdownBanner } from "@/components/frontend/wiki/LockdownBanner"; +import { WikiContent } from "@/components/frontend/wiki/WikiContent"; +import { WikiToolbar } from "@/components/frontend/wiki/WikiToolbar"; +import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; +import { listTemplates, templateMap } from "@/lib/wiki/service"; +import { prepareMarkdown } from "@/lib/wiki/prepare"; + +export const metadata = { + title: "Wiki page — Polaris Task Force", +}; + +function relationName(relation: WikiPageDocument["lastEditor"]): string { + if (typeof relation === "object" && relation !== null) { + return ( + relation.payloadDisplayName || relation.displayName || relation.username || "Unknown editor" + ); + } + + return typeof relation === "number" ? `User #${relation}` : "Unknown editor"; +} + +function formatDate(iso: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(iso)); +} + +function MissingWikiPage({ slug }: { readonly slug: string }) { + return ( + + +
+

Page doesn't exist yet

+

+ This entry is not in the field guide. Start a page for this topic instead. +

+
+ +
+
+ ); +} + +export default async function WikiDetailPage({ + params, + searchParams, +}: { + readonly params: Promise<{ slug: string }>; + readonly searchParams: Promise<{ revision?: string }>; +}) { + const { slug } = await params; + const headers = await nextHeaders(); + const payload = await getPayload({ config }); + const { user } = await payload.auth({ + headers, + canSetHeaders: false, + }); + + const pageResult = await payload.find({ + collection: "wiki-pages", + where: { slug: { equals: slug } }, + limit: 1, + depth: 1, + overrideAccess: true, + }); + const page = pageResult.docs[0]; + + if (!page) { + return ( +
+ + + Wiki + + +
+ ); + } + + const [revisionNumber, templates, isModerator] = await Promise.all([ + searchParams.then((sp) => { + const n = Number(sp.revision); + return Number.isInteger(n) && n >= 1 ? n : null; + }), + listTemplates(payload), + hasIntelligenceQualification(payload, user), + ]); + const templateMapValue = templateMap(templates); + + if (revisionNumber !== null) { + const revisionResult = await payload.find({ + collection: "wiki-revisions", + where: { page: { equals: page.id }, revisionNumber: { equals: revisionNumber } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const revision = revisionResult.docs[0]; + + if (!revision) { + return ( +
+ + + Wiki + + + +
+

Revision not found

+

+ Revision {revisionNumber} of this page no longer exists. +

+
+ +
+
+
+ ); + } + + const prepared = prepareMarkdown(revision.contentSnapshot, templateMapValue); + + return ( +
+
+ + + Wiki + +
+ + You're viewing revision {revision.revisionNumber} (from{" "} + {formatDate(revision.createdAt)}). + + +
+
+ +

+ {revision.titleSnapshot} +

+
+
+ + + + + + +
+ ); + } + + const prepared = prepareMarkdown(page.body, templateMapValue); + const lockdown = page.lockdown === true; + + return ( +
+
+ + + Wiki + +
+
+
+ +

{page.title}

+
+
+ {page.category} + {(page.tags ?? []).map((tag) => ( + + {tag} + + ))} +
+
+
+ Last edited by {relationName(page.lastEditor)} + +
+
+
+ + {lockdown && } + + + + + + + + +
+ ); +} diff --git a/src/app/(frontend)/wiki/new/page.tsx b/src/app/(frontend)/wiki/new/page.tsx new file mode 100644 index 0000000..831a487 --- /dev/null +++ b/src/app/(frontend)/wiki/new/page.tsx @@ -0,0 +1,74 @@ +import config from "@payload-config"; +import Link from "next/link"; +import { headers as nextHeaders } from "next/headers"; +import { getPayload } from "payload"; +import { ArrowLeftIcon, FilePlus2Icon } from "lucide-react"; +import { WikiEditor } from "@/components/frontend/wiki/WikiEditor"; +import { listTemplates, templateMap } from "@/lib/wiki/service"; + +export const metadata = { + title: "New wiki page — Polaris Task Force", +}; + +export default async function NewWikiPage({ + searchParams, +}: { + readonly searchParams: Promise<{ title?: string | string[] }>; +}) { + const { title } = await searchParams; + const initialTitle = Array.isArray(title) ? (title[0] ?? "") : (title ?? ""); + const payload = await getPayload({ config }); + const headers = await nextHeaders(); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + const [templates, pagesResult, hasEditedRevisions] = await Promise.all([ + listTemplates(payload), + payload.find({ + collection: "wiki-pages", + sort: "title", + limit: 200, + depth: 0, + overrideAccess: true, + }), + user + ? payload + .find({ + collection: "wiki-revisions", + where: { editor: { equals: user.id } }, + limit: 1, + depth: 0, + overrideAccess: true, + }) + .then((result) => result.docs.length > 0) + : Promise.resolve(true), + ]); + const existingPages = pagesResult.docs.map((page) => ({ title: page.title })); + + return ( +
+
+ + + Wiki + +
+ +

New wiki page

+
+

+ Add a durable reference for the rest of the task force. +

+
+ + +
+ ); +} diff --git a/src/app/(frontend)/wiki/page.tsx b/src/app/(frontend)/wiki/page.tsx new file mode 100644 index 0000000..3105a98 --- /dev/null +++ b/src/app/(frontend)/wiki/page.tsx @@ -0,0 +1,67 @@ +import config from "@payload-config"; +import { headers as nextHeaders } from "next/headers"; +import { getPayload } from "payload"; +import { BookOpenIcon } from "lucide-react"; +import type { WikiPage as WikiPageDocument } from "@/payload-types"; +import { WikiIndex, type WikiIndexPage } from "@/components/frontend/wiki/WikiIndex"; +import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; + +export const metadata = { + title: "Wiki — Polaris Task Force", +}; + +function relationName(relation: WikiPageDocument["lastEditor"]): string { + if (typeof relation === "object" && relation !== null) { + return ( + relation.payloadDisplayName || relation.displayName || relation.username || "Unknown editor" + ); + } + + return typeof relation === "number" ? `User #${relation}` : "Unknown editor"; +} + +export default async function WikiPage() { + const headers = await nextHeaders(); + const payload = await getPayload({ config }); + const { user } = await payload.auth({ + headers, + canSetHeaders: false, + }); + + const [pagesResult, isModerator] = await Promise.all([ + payload.find({ + collection: "wiki-pages", + sort: "-updatedAt", + limit: 200, + depth: 1, + overrideAccess: true, + }), + hasIntelligenceQualification(payload, user), + ]); + + const pages: WikiIndexPage[] = pagesResult.docs.map((page) => ({ + id: page.id, + title: page.title, + slug: page.slug, + category: page.category, + tags: page.tags ?? [], + updatedAt: page.updatedAt, + lastEditorName: relationName(page.lastEditor), + })); + + return ( +
+
+
+ +

Wiki

+
+

+ A shared field guide for campaigns, characters, places, and the stories around them. +

+
+ + +
+ ); +}