1
0
Fork 0

Compare commits

...

9 commits

Author SHA1 Message Date
93f61d5ef8 docs(wiki): document wiki system
Add the Wiki section to the agent context covering collections, service
lib, markdown pipeline, editor, pages, event targets, migration batch 28,
and gotchas. Includes minor markdown formatting cleanups in existing
sections.
2026-09-05 00:02:28 -04:00
d8f7c06ca7 test(wiki): add wiki integration tests
Integration coverage for the wiki service layer: page create/update with
slug collision handling, revision restore, lockdown enforcement, template
expansion, and wikilink preprocessing.
2026-09-05 00:02:25 -04:00
696bd9b392 feat(wiki): enable image uploads via server actions
Raise the serverActions request body limit to 10mb so wiki image uploads
(capped at 8mb in uploadWikiImage) pass through with room for multipart
overhead.
2026-09-05 00:02:22 -04:00
28ced660c0 feat(wiki): add wiki navigation and styles
Extract the static sidebar nav data into sidebarData.ts and add a Wiki
entry (BookOpen) to the main nav. Adds scoped markdown styles
(columns, footnotes, note/warning callouts) under .wiki-markdown.
2026-09-05 00:02:20 -04:00
f38962c589 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.
2026-09-05 00:02:18 -04:00
1dac6a0f9a 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).
2026-09-05 00:02:09 -04:00
3c6a9c8a42 feat(wiki): add wiki server actions and event logging
Server actions for page create/update, revision restore, lockdown, and
delete (moderator-gated via intelligence qualification) plus wiki image
uploads. Adds wiki event types (wiki:page-create/edit/restore/lock/
unlock/delete) and registers wiki-pages/revisions/templates as valid event
log targets.
2026-09-05 00:02:04 -04:00
3ec364a8b9 feat(wiki): add wiki service library and markdown pipeline
Service layer (createPage/updatePage/restoreRevision/setPageLockdown/
deletePage, slugification, template expansion, wikilink preprocessing,
category constants) plus the client-safe markdown rendering pipeline
(react-markdown + remark-gfm + remark-directive) shared by the server
component and the editor's live preview. Adds the three markdown
dependencies to package.json and bun.lock.
2026-09-05 00:02:01 -04:00
01b1a73158 feat(wiki): add wiki collections and database migration
Add WikiPages, WikiRevisions, and WikiTemplates collections (admin group
Wiki), register them in the Payload config, and add migration batch 28
creating the wiki_pages/_texts, wiki_revisions, and wiki_templates tables
plus the three wiki values on the game_event_logs target_collection enum.
Includes the regenerated payload-types for the new collections.
2026-09-05 00:01:58 -04:00
44 changed files with 25495 additions and 118 deletions

View file

@ -1,6 +1,7 @@
# AGENTS.md — Polaris Task Force # AGENTS.md — Polaris Task Force
> **Sub-AGENTS.md files** (read these for domain-specific context): > **Sub-AGENTS.md files** (read these for domain-specific context):
>
> - `src/components/frontend/AGENTS.md` — Frontend component patterns, server/client split, Item primitive > - `src/components/frontend/AGENTS.md` — Frontend component patterns, server/client split, Item primitive
> - `src/collections/AGENTS.md` — Payload collection map, RBAC, hooks, relationship graph > - `src/collections/AGENTS.md` — Payload collection map, RBAC, hooks, relationship graph
> - `src/lib/AGENTS.md` — Shared business logic, domain services, dependency graph > - `src/lib/AGENTS.md` — Shared business logic, domain services, dependency graph
@ -125,6 +126,7 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R
### UI ### UI
`EventLedger` component (`src/components/frontend/storage/EventLedger.tsx`): `EventLedger` component (`src/components/frontend/storage/EventLedger.tsx`):
- Queries the REST API (`/api/game-event-logs`) for a given `structureId` and renders a scrollable feed. - Queries the REST API (`/api/game-event-logs`) for a given `structureId` and renders a scrollable feed.
- Accepts optional `refreshKey` prop — parent passes an incrementing counter to trigger re-fetch after mutations. - Accepts optional `refreshKey` prop — parent passes an incrementing counter to trigger re-fetch after mutations.
- Auto-polls every 30s for background updates. - Auto-polls every 30s for background updates.
@ -134,12 +136,14 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R
- On re-fetch, preserves existing entries while loading to avoid flicker. - On re-fetch, preserves existing entries while loading to avoid flicker.
### Adding event logging for new actions ### Adding event logging for new actions
1. Add event type constant to `src/utils/event-log/eventTypes.ts` if it doesn't exist yet. 1. Add event type constant to `src/utils/event-log/eventTypes.ts` if it doesn't exist yet.
2. If the target collection slug isn't in the `TARGET_COLLECTIONS` array in `GameEventLogs.ts`, add it. 2. If the target collection slug isn't in the `TARGET_COLLECTIONS` array in `GameEventLogs.ts`, add it.
3. Call `emitGameEvent(payload, { type: EventTypes.xxx, message: "...", ... })` after the successful mutation in the server action. 3. Call `emitGameEvent(payload, { type: EventTypes.xxx, message: "...", ... })` after the successful mutation in the server action.
4. `EventLedger` will pick it up automatically if it's scoped to the same `structureId`. 4. `EventLedger` will pick it up automatically if it's scoped to the same `structureId`.
### Non-structure events ### Non-structure events
The `EventLedger` currently filters by `structure.id`. For event logs scoped to other entities (e.g., faction finance ledger), a new ledger variant would need to be created that queries by `targetCollection` + `targetId` instead. The `EventLedger` currently filters by `structure.id`. For event logs scoped to other entities (e.g., faction finance ledger), a new ledger variant would need to be created that queries by `targetCollection` + `targetId` instead.
## Shipments & game tick ## Shipments & game tick
@ -204,11 +208,11 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
- `negotiationRange(listing)` → `{ min, desired }` (min = `minPrice ?? desired`); `isNegotiable(listing)` → `min < desired`. - `negotiationRange(listing)` → `{ min, desired }` (min = `minPrice ?? desired`); `isNegotiable(listing)` → `min < desired`.
- **Server actions**: `src/app/(frontend)/logistics/market/actions.ts`. `createMarketListing` (accepts optional `minPrice`; sets `desiredPrice = price`), `cancelMarketListing` (returns stock to seller), `buyMarketListing` (re-fetches listing, verifies active, delegates to `buyListing` with an optional `quantity`), plus negotiation actions `makeMarketOffer` (accepts optional `quantity`, stored on the thread and used when the deal closes) / `acceptMarketOffer` / `rejectMarketOffer` / `counterMarketOffer` / `cancelMarketOffer`. Emits `market:listing-create` / `market:listing-cancel` / `market:sale` / `market:offer*` events. - **Server actions**: `src/app/(frontend)/logistics/market/actions.ts`. `createMarketListing` (accepts optional `minPrice`; sets `desiredPrice = price`), `cancelMarketListing` (returns stock to seller), `buyMarketListing` (re-fetches listing, verifies active, delegates to `buyListing` with an optional `quantity`), plus negotiation actions `makeMarketOffer` (accepts optional `quantity`, stored on the thread and used when the deal closes) / `acceptMarketOffer` / `rejectMarketOffer` / `counterMarketOffer` / `cancelMarketOffer`. Emits `market:listing-create` / `market:listing-cancel` / `market:sale` / `market:offer*` events.
- **Negotiations**: `src/collections/market/MarketNegotiations.ts` — `market-negotiations`. One thread per listing+buyer. Fields: `listing`, `buyer`, `status` (`open`/`accepted`/`rejected`/`closed`/`cancelled`/`expired`), `amount` (per-unit price on the table), `quantity` (how many units the buyer is haggling for — set on the first offer, defaults to `1`), `proposedBy` (`buyer`/`seller`), `patience` (NPC vendor meter 0–100), `acceptedPrice`, `history[]`. Access: read/update scoped to buyer or `listing.seller` (admin/developer bypass); delete developer only. - **Negotiations**: `src/collections/market/MarketNegotiations.ts` — `market-negotiations`. One thread per listing+buyer. Fields: `listing`, `buyer`, `status` (`open`/`accepted`/`rejected`/`closed`/`cancelled`/`expired`), `amount` (per-unit price on the table), `quantity` (how many units the buyer is haggling for — set on the first offer, defaults to `1`), `proposedBy` (`buyer`/`seller`), `patience` (NPC vendor meter 0–100), `acceptedPrice`, `history[]`. Access: read/update scoped to buyer or `listing.seller` (admin/developer bypass); delete developer only.
- **Flow**: buyer `makeMarketOffer` → thread with `proposedBy: buyer`. For **player listings** the seller is notified and can accept/reject/counter. For **NPC vendor listings** (`seller == null`) the offer is auto-resolved immediately via `npcResponseToOffer` in `src/lib/market/negotiations.ts`. A party may only accept/reject/counter the *other* side's proposal. - **Flow**: buyer `makeMarketOffer` → thread with `proposedBy: buyer`. For **player listings** the seller is notified and can accept/reject/counter. For **NPC vendor listings** (`seller == null`) the offer is auto-resolved immediately via `npcResponseToOffer` in `src/lib/market/negotiations.ts`. A party may only accept/reject/counter the _other_ side's proposal.
- **Strictly-increasing buyer offers**: the buyer's offers on a listing must keep rising — a repeat of an amount or a lower offer is rejected server-side in `makeMarketOffer`/`counterMarketOffer` (`enforceHigherBuyerOffer`, driven by `lastBuyerOfferOf` over thread history) with "Your offer must be higher than your previous offer of X." The vendor's own `movedBackward` firm-hold in `npcResponseToOffer` is a second line of defense. - **Strictly-increasing buyer offers**: the buyer's offers on a listing must keep rising — a repeat of an amount or a lower offer is rejected server-side in `makeMarketOffer`/`counterMarketOffer` (`enforceHigherBuyerOffer`, driven by `lastBuyerOfferOf` over thread history) with "Your offer must be higher than your previous offer of X." The vendor's own `movedBackward` firm-hold in `npcResponseToOffer` is a second line of defense.
- **NPC vendor pricing** (`NPC_ACCEPT` constants: `concedeRatio` 0.3, `marginRatio` 0.02, `chance` 0.85): the vendor keeps a private **stance** starting at the asking price that only moves down. Offers ≥ stance are accepted outright; offers within 2% of the stance are accepted with 85% chance (else the vendor holds firm at the stance); well-below offers draw a concession — the stance moves 30% of the gap toward the buyer but never rises past its last counter and **never drops below `minPrice`** (counters are clamped to the vendor's floor), and the vendor holds firm if the buyer offers *less* than their previous offer (`lastCounter`/`lastBuyerOffer` from `npcStateOf`). Counter responses carry a `reason` (`firm`/`lowball`/`backward`/`stall`/`concede`) and accept responses a `reason` (`good`/`overpay`/`accept`) that drives the vendor's dialogue line. - **NPC vendor pricing** (`NPC_ACCEPT` constants: `concedeRatio` 0.3, `marginRatio` 0.02, `chance` 0.85): the vendor keeps a private **stance** starting at the asking price that only moves down. Offers ≥ stance are accepted outright; offers within 2% of the stance are accepted with 85% chance (else the vendor holds firm at the stance); well-below offers draw a concession — the stance moves 30% of the gap toward the buyer but never rises past its last counter and **never drops below `minPrice`** (counters are clamped to the vendor's floor), and the vendor holds firm if the buyer offers _less_ than their previous offer (`lastCounter`/`lastBuyerOffer` from `npcStateOf`). Counter responses carry a `reason` (`firm`/`lowball`/`backward`/`stall`/`concede`) and accept responses a `reason` (`good`/`overpay`/`accept`) that drives the vendor's dialogue line.
- **NPC dialogue**: `npcDialogue(kind, amount?, asking?, rng?)` in `src/lib/market/npcDialogue.ts` (pure module — safe to import client-side) produces vendor flavour lines from per-kind variant arrays (picked at random via the `rng` argument, default `Math.random`). `resolveNpcOffer`/`finalizeNegotiation` write these into the history `note`; the chat transcript is rebuilt by `buildChatBubbles(negotiation, listing, currencyLabel)` in `src/lib/market/chatBubbles.ts` (pure — vendor greeting, buyer "How does {amount} sound?", seller lines from history; accept/closed fallback lines appended only for vendor threads with no dialogue, picked deterministically from the thread id). Bubbles render via `ChatBubbleList` (`src/components/frontend/market/ChatBubbleList.tsx`, auto-scrolls, configurable buyer/seller labels). Old threads with generic notes fall back to "How about {amount}?". - **NPC dialogue**: `npcDialogue(kind, amount?, asking?, rng?)` in `src/lib/market/npcDialogue.ts` (pure module — safe to import client-side) produces vendor flavour lines from per-kind variant arrays (picked at random via the `rng` argument, default `Math.random`). `resolveNpcOffer`/`finalizeNegotiation` write these into the history `note`; the chat transcript is rebuilt by `buildChatBubbles(negotiation, listing, currencyLabel)` in `src/lib/market/chatBubbles.ts` (pure — vendor greeting, buyer "How does {amount} sound?", seller lines from history; accept/closed fallback lines appended only for vendor threads with no dialogue, picked deterministically from the thread id). Bubbles render via `ChatBubbleList` (`src/components/frontend/market/ChatBubbleList.tsx`, auto-scrolls, configurable buyer/seller labels). Old threads with generic notes fall back to "How about {amount}?".
- **Patience meter (NPC vendors)**: `NPC_PATIENCE` constants (`roundCost` 12, `lowballPenalty` 25, `stallPenalty` 35, `stallRatio` 0.01, `goodOfferRelief` 9, `goodOfferRatio` 0.85, `cap` 100). Every bargaining round advances the meter on the thread: lowballs (< `minPrice`) add `roundCost + lowballPenalty`, **stalls** (offers that move up by less than `stallRatio × desired`, e.g. +$1 on a $5,000 item) add `roundCost + stallPenalty`, offers at/above `0.85 × desired` *relieve* `goodOfferRelief`, otherwise `roundCost`. At the cap the thread closes (`status: closed`) — the buyer can only buy at the asking price. One persistent thread per listing+buyer holds the meter (NPC path reuses/reopens it, never resets); `makeMarketOffer` blocks after `accepted`/`closed`. Meter UI: `PatienceMeter` (`src/components/frontend/market/PatienceMeter.tsx`, green→amber→red) in `MakeOfferDialog` + buyer-side rows of `NegotiationPanel`. Bounds (`min`/`desired`) stay server-side — never shown to buyers. - **Patience meter (NPC vendors)**: `NPC_PATIENCE` constants (`roundCost` 12, `lowballPenalty` 25, `stallPenalty` 35, `stallRatio` 0.01, `goodOfferRelief` 9, `goodOfferRatio` 0.85, `cap` 100). Every bargaining round advances the meter on the thread: lowballs (< `minPrice`) add `roundCost + lowballPenalty`, **stalls** (offers that move up by less than `stallRatio × desired`, e.g. +$1 on a $5,000 item) add `roundCost + stallPenalty`, offers at/above `0.85 × desired` _relieve_ `goodOfferRelief`, otherwise `roundCost`. At the cap the thread closes (`status: closed`) — the buyer can only buy at the asking price. One persistent thread per listing+buyer holds the meter (NPC path reuses/reopens it, never resets); `makeMarketOffer` blocks after `accepted`/`closed`. Meter UI: `PatienceMeter` (`src/components/frontend/market/PatienceMeter.tsx`, green→amber→red) in `MakeOfferDialog` + buyer-side rows of `NegotiationPanel`. Bounds (`min`/`desired`) stay server-side — never shown to buyers.
- **Completion**: `finalizeNegotiation` (internal to actions) runs `buyListing` at the agreed `unitPrice` and the thread's `quantity`, marks the thread `accepted` with `acceptedPrice`, expires sibling open threads (`expireNegotiationsForListing` — notifies other interested buyers), notifies both parties, emits `market:offer-accept` + `market:sale`. Buying/cancelling/expiring a listing also expires its open threads. - **Completion**: `finalizeNegotiation` (internal to actions) runs `buyListing` at the agreed `unitPrice` and the thread's `quantity`, marks the thread `accepted` with `acceptedPrice`, expires sibling open threads (`expireNegotiationsForListing` — notifies other interested buyers), notifies both parties, emits `market:offer-accept` + `market:sale`. Buying/cancelling/expiring a listing also expires its open threads.
- **UI**: `MakeOfferDialog` (buyer-facing haggle flow on `ListingCard` — loads existing thread via REST on open, shows counter/accept/withdraw actions + `NpcChat` chat for vendor listings; after a deal closes it stays visible for a 3.5s minimum window so the result and vendor line stay readable, and the refresh is deferred until that window passes so a sold listing doesn't unmount the dialog early), `NegotiationPanel` on the market page (open threads with role-aware Accept/Counter/Reject/Withdraw + recent closed chips + a **History** button opening `NegotiationHistoryDialog`, which lists all of the user's past negotiations as expandable chat rows), `Countdown` live "expires in" timer on every card. `CreateListingDialog` has a "Allow offers below the asking price" section (min price) and only lets you select locker items that are `tradeable` **and** `isLive` (disabled otherwise; "not approved for trading yet" hint when nothing qualifies). Deal-closed feedback fires a `sonner` toast (accept in `MakeOfferDialog`/`NegotiationPanel`, plus NPC auto-accept) — `<Toaster />` is mounted in `src/app/(frontend)/layout.tsx` via `src/components/ui/sonner.tsx`. - **UI**: `MakeOfferDialog` (buyer-facing haggle flow on `ListingCard` — loads existing thread via REST on open, shows counter/accept/withdraw actions + `NpcChat` chat for vendor listings; after a deal closes it stays visible for a 3.5s minimum window so the result and vendor line stay readable, and the refresh is deferred until that window passes so a sold listing doesn't unmount the dialog early), `NegotiationPanel` on the market page (open threads with role-aware Accept/Counter/Reject/Withdraw + recent closed chips + a **History** button opening `NegotiationHistoryDialog`, which lists all of the user's past negotiations as expandable chat rows), `Countdown` live "expires in" timer on every card. `CreateListingDialog` has a "Allow offers below the asking price" section (min price) and only lets you select locker items that are `tradeable` **and** `isLive` (disabled otherwise; "not approved for trading yet" hint when nothing qualifies). Deal-closed feedback fires a `sonner` toast (accept in `MakeOfferDialog`/`NegotiationPanel`, plus NPC auto-accept) — `<Toaster />` is mounted in `src/app/(frontend)/layout.tsx` via `src/components/ui/sonner.tsx`.
- **Market tick**: `bun run payload market-tick` (bin `market-tick` in `payload.config.ts`, NOT npm script). Expires overdue listings (`expireListing` — user stock returned, auto entries just close; also expires open negotiations) then tops up auto-generated entries for any tradeable+live asset with a base buy price that has no active listing. Each entry is assigned an NPC via `resolveVendorNpc`; the price is multiplied by the NPC's `vendor.priceModifier`. Auto entries get `desiredPrice = price`, `minPrice = round(price × 0.6)`. Emits `market:listing-expire` (per listing) + `market:auto-generate` (batch summary), then notifies clients via the same `/api/game-tick/notify` SSE path. - **Market tick**: `bun run payload market-tick` (bin `market-tick` in `payload.config.ts`, NOT npm script). Expires overdue listings (`expireListing` — user stock returned, auto entries just close; also expires open negotiations) then tops up auto-generated entries for any tradeable+live asset with a base buy price that has no active listing. Each entry is assigned an NPC via `resolveVendorNpc`; the price is multiplied by the NPC's `vendor.priceModifier`. Auto entries get `desiredPrice = price`, `minPrice = round(price × 0.6)`. Emits `market:listing-expire` (per listing) + `market:auto-generate` (batch summary), then notifies clients via the same `/api/game-tick/notify` SSE path.
@ -244,6 +248,28 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
- **Dependencies**: `@xyflow/react` (v12), `radix-ui` (context-menu primitives), `lucide-react` (icons). - **Dependencies**: `@xyflow/react` (v12), `radix-ui` (context-menu primitives), `lucide-react` (icons).
- To add a new node type, create the component + register it in the `nodeTypes` object in `NarrativeFlowEditor.tsx`. - To add a new node type, create the component + register it in the `nodeTypes` object in `NarrativeFlowEditor.tsx`.
## Wiki
`src/collections/wiki/`: `wiki-pages`, `wiki-revisions`, `wiki-templates`. A user-maintained field guide for campaigns, characters, places, and the stories around them. Admin group: **Wiki**.
- **WikiPages**: `title` (text, required), `slug` (text, required, unique + indexed, generated by the service layer from the title), `category` (select, required: Campaign/World/Lore/Characters/Plot/Media/Guides/Meta), `tags` (text hasMany), `body` (textarea, required, markdown source), `lockdown` (checkbox, default false), `lockedBy`/`lockedAt` (→ users / date), `lastEditor` (→ users). Access: read/create/update any logged-in user; delete requires intelligence qualification (`hasIntelligenceQualification`).
- **WikiRevisions**: `page` (→ wiki-pages), `revisionNumber` (number, min 1), `titleSnapshot`/`contentSnapshot`, `editor` (→ users), `summary`, `type` (`create`/`edit`/`restore`), `restoredFromRevision`. Access: read logged-in; create/update/delete `false` (written internally via `overrideAccess`).
- **WikiTemplates**: `name` (unique), `body` (textarea, snippet with `{{param}}` placeholders), `description`. Read logged-in; write admin/developer.
- **Service lib**: `src/lib/wiki/`.
- `slugify.ts`: `slugify(title)` lowercases, collapses non-alphanumerics to hyphens, falls back to `"page"`.
- `templates.ts`: `expandTemplates(source, map)` expands `{{Name}}` at render time; nested templates expand up to 2 levels deep and a repeated name in the expansion chain is left literal (cycle guard). Pure module (client-safe).
- `wikilinks.ts`: `preprocessWikilinks` turns `[[Page]]` into `[Page](/wiki/<slug>)` and `[[Page|label]]` into `[label](/wiki/<slug>)`; `extractWikilinks` returns the raw target titles. Pure module.
- `prepare.ts`: `prepareMarkdown(source, templates)` = template expansion first, then wikilink preprocessing. Single entry point the frontend uses before rendering.
- `categories.ts`: `WIKI_CATEGORIES` (the 8 category values).
- `service.ts`: `createPage`/`updatePage`/`restoreRevision`/`setPageLockdown`/`deletePage`/`listTemplates`/`templateMap`. All mutations run with `overrideAccess: true`; `updatePage`/`restoreRevision` throw `"This page is locked and cannot be edited."` when `lockdown` is set; `deletePage` cascades the page's revisions; slug collisions get a `-2`, `-3`, ... suffix; revision numbers are `max + 1`. Does NOT emit events (the actions layer does).
- **Server actions**: `src/app/(frontend)/wiki/actions.ts`. `createWikiPage`, `updateWikiPage`, `restoreWikiRevision` (moderator), `setPageLockdown` (moderator), `deleteWikiPage` (moderator). Canonical repo pattern (duplicated `authenticate()`, `ActionResult<T>`, `emitGameEvent` after mutation). Moderator = `hasIntelligenceQualification(payload, user)` (intel division members + admin/developer pass). Emits `wiki:page-create` / `wiki:page-edit` / `wiki:page-restore` / `wiki:page-lock` / `wiki:page-unlock` / `wiki:page-delete`.
- **Markdown rendering**: `react-markdown@10.1.0` + `remark-gfm@4.0.1` (GFM provides footnotes natively: `[^1]` marker + `[^1]:` definition) + `remark-directive@4.0.0` (layout/callout directives). The shared pipeline lives in `src/lib/wiki/markdown.tsx` (client-safe, no Payload imports): exports `wikiRemarkPlugins` (= `[remarkGfm, remarkDirective]`), `wikiRemarkRehypeOptions` (handlers mapping containerDirective/leafDirective → div and textDirective → span), `wikiMarkdownComponents`, and re-exports `ReactMarkdown`. Wired into both the server component `WikiContent` and the editor's live preview. No raw HTML (no rehype-raw). Images are URL-only `<img>` (no upload support yet). Captioned images `![alt text](url "Caption on hover")` render the caption as a hover `title` only, no figure/figcaption (deliberate: a figure inside a `<p>` is invalid HTML and would cause a hydration mismatch). Two/three-column layout via `::::columns` / `:::column` / `::::` fences, the closing fence must use the same or more colons. `:::note` and `:::warning` callouts render as styled divs. Wikilinks `[[Page]]`/`[[Page|label]]` and `{{Template}}` expansion still run through `prepareMarkdown` before rendering.
- **Editor**: `WikiEditor` (`src/components/frontend/wiki/WikiEditor.tsx`): title/category/tags/edit-summary fields plus a markdown body with live preview (`prepareMarkdown` → react-markdown via the shared pipeline) and an "Insert wikilink" dialog (`WikiLinkDialog`, picks from existing pages at the cursor). The body (`WikiEditorBody.tsx`) sits under a formatting toolbar (`WikiEditorToolbar.tsx`): bold / italic / strikethrough / heading / inline code / code block / quote / bulleted list / numbered list / table, plus insert actions for wikilink, footnote, captioned image, 2-column, 3-column, and citation. Selection helpers live in `src/lib/wiki/editorFormatting.ts`. Keyboard shortcuts: Ctrl/Cmd+B bold, Ctrl/Cmd+I italic, Ctrl/Cmd+Shift+X strikethrough, Ctrl/Cmd+Shift+H heading, Ctrl/Cmd+K insert wikilink, Ctrl/Cmd+E inline code. An in-editor Markdown reference guide (`MarkdownGuide.tsx`) sits next to the live preview.
- **UI**: `src/app/(frontend)/wiki/`: `/wiki` index (`WikiIndex`: search, tag filter, category tabs, "New page"), `/wiki/new`, `/wiki/[slug]` (detail: `WikiContent` render + `WikiToolbar` with Edit/History/Lock/Delete; `LockdownBanner` when locked; missing pages show a "Create this page" CTA that prefills `?title=`), `/wiki/[slug]/edit` (redirects to `/wiki/new?title=` for missing pages, shows `LockdownBanner` when locked), `/wiki/[slug]/history` (`RevisionList` with type badges and moderator-only Restore). Tests: `tests/int/wiki.int.spec.ts`.
- **Event targets**: `wiki-pages`, `wiki-revisions`, `wiki-templates` added to `GameEventLogs` `TARGET_COLLECTIONS` and `emit.ts` `targetCollection` union. Wiki event types in `eventTypes.ts`.
- **Database**: migration batch 28 `src/migrations/20260904_230838_add_wiki_collections.ts`: tables `wiki_pages` (+ `_texts`), `wiki_revisions`, `wiki_templates`, plus the three wiki values added to the `game_event_logs` `target_collection` enum. Dev uses `bun run payload migrate` for schema changes (`push: false`).
- **Gotchas**: image uploads go through the `uploadWikiImage` server action in `src/app/(frontend)/wiki/actions.ts` (any logged-in user, image MIME only, 8 MB cap, stored in `media` with `read: () => true` so images are public; the editor toolbar's "Upload image" button inserts `![alt](/api/media/file/<filename>)` — requires `serverActions.bodySizeLimit` (10mb) in `next.config.mjs`); lockdown blocks edit/restore server-side (actions throw `"This page is locked and cannot be edited."`); restore/delete/lock are intelligence-qualification gated; `WikiRevisions` denies create/update/delete outright, so all revision writes go through the service layer with `overrideAccess`.
## Discord Bot ## Discord Bot
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.** A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
@ -309,6 +335,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
## Server Actions convention ## Server Actions convention
Every `actions.ts` file follows the same pattern (10+ files use it): Every `actions.ts` file follows the same pattern (10+ files use it):
1. `"use server"` directive at top 1. `"use server"` directive at top
2. `import config from "@payload-config"` + `const payload = await getPayload({ config })` 2. `import config from "@payload-config"` + `const payload = await getPayload({ config })`
3. Local `authenticate()` helper — dynamic import of `next/headers`, calls `payload.auth()` and `headers()` 3. Local `authenticate()` helper — dynamic import of `next/headers`, calls `payload.auth()` and `headers()`

View file

@ -51,6 +51,9 @@
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-markdown": "10.1.0",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"sharp": "0.34.5", "sharp": "0.34.5",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
@ -1027,6 +1030,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
@ -1151,6 +1156,8 @@
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "7.28.4", "cosmiconfig": "7.1.0", "resolve": "1.22.10" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "7.28.4", "cosmiconfig": "7.1.0", "resolve": "1.22.10" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
@ -1241,6 +1248,8 @@
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="],
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
@ -1603,6 +1612,10 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
@ -1615,6 +1628,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
@ -1641,6 +1656,8 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "hasown": "2.0.2", "side-channel": "1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "hasown": "2.0.2", "side-channel": "1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
@ -1697,6 +1714,8 @@
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
@ -1849,6 +1868,8 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@ -1857,12 +1878,34 @@
"md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="], "md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="],
"mdast-util-directive": ["mdast-util-directive@3.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "decode-named-character-reference": "1.2.0", "devlop": "1.1.0", "mdast-util-to-string": "4.0.0", "micromark": "4.0.2", "micromark-util-decode-numeric-character-reference": "2.0.2", "micromark-util-decode-string": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2", "unist-util-stringify-position": "4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "decode-named-character-reference": "1.2.0", "devlop": "1.1.0", "mdast-util-to-string": "4.0.0", "micromark": "4.0.2", "micromark-util-decode-numeric-character-reference": "2.0.2", "micromark-util-decode-string": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2", "unist-util-stringify-position": "4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.1.3", "", { "dependencies": { "@types/estree-jsx": "1.0.5", "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "ccount": "2.0.1", "devlop": "1.1.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-to-markdown": "2.1.2", "parse-entities": "4.0.2", "stringify-entities": "4.0.4", "unist-util-stringify-position": "4.0.0", "vfile-message": "4.0.3" } }, "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ=="], "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.1.3", "", { "dependencies": { "@types/estree-jsx": "1.0.5", "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "ccount": "2.0.1", "devlop": "1.1.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-to-markdown": "2.1.2", "parse-entities": "4.0.2", "stringify-entities": "4.0.4", "unist-util-stringify-position": "4.0.0", "vfile-message": "4.0.3" } }, "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "4.0.4", "unist-util-is": "6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "4.0.4", "unist-util-is": "6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "longest-streak": "3.1.0", "mdast-util-phrasing": "4.1.0", "mdast-util-to-string": "4.0.0", "micromark-util-classify-character": "2.0.1", "micromark-util-decode-string": "2.0.1", "unist-util-visit": "5.0.0", "zwitch": "2.0.4" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "longest-streak": "3.1.0", "mdast-util-phrasing": "4.1.0", "mdast-util-to-string": "4.0.0", "micromark-util-classify-character": "2.0.1", "micromark-util-decode-string": "2.0.1", "unist-util-visit": "5.0.0", "zwitch": "2.0.4" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "4.0.4" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "4.0.4" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
@ -1883,6 +1926,22 @@
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "1.2.0", "devlop": "1.1.0", "micromark-factory-destination": "2.0.1", "micromark-factory-label": "2.0.1", "micromark-factory-space": "2.0.1", "micromark-factory-title": "2.0.1", "micromark-factory-whitespace": "2.0.1", "micromark-util-character": "2.1.1", "micromark-util-chunked": "2.0.1", "micromark-util-classify-character": "2.0.1", "micromark-util-html-tag-name": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-resolve-all": "2.0.1", "micromark-util-subtokenize": "2.1.0", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "1.2.0", "devlop": "1.1.0", "micromark-factory-destination": "2.0.1", "micromark-factory-label": "2.0.1", "micromark-factory-space": "2.0.1", "micromark-factory-title": "2.0.1", "micromark-factory-whitespace": "2.0.1", "micromark-util-character": "2.1.1", "micromark-util-chunked": "2.0.1", "micromark-util-classify-character": "2.0.1", "micromark-util-html-tag-name": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-resolve-all": "2.0.1", "micromark-util-subtokenize": "2.1.0", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-directive": ["micromark-extension-directive@4.0.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.1", "", { "dependencies": { "@types/acorn": "4.0.6", "@types/estree": "1.0.8", "devlop": "1.1.0", "estree-util-is-identifier-name": "3.0.0", "micromark-factory-mdx-expression": "2.0.3", "micromark-factory-space": "2.0.1", "micromark-util-character": "2.1.1", "micromark-util-events-to-acorn": "2.0.3", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2", "vfile-message": "4.0.3" } }, "sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg=="], "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.1", "", { "dependencies": { "@types/acorn": "4.0.6", "@types/estree": "1.0.8", "devlop": "1.1.0", "estree-util-is-identifier-name": "3.0.0", "micromark-factory-mdx-expression": "2.0.3", "micromark-factory-space": "2.0.1", "micromark-util-character": "2.1.1", "micromark-util-events-to-acorn": "2.0.3", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2", "vfile-message": "4.0.3" } }, "sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "2.1.1", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "2.1.1", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
@ -2099,6 +2158,8 @@
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "1.4.0", "object-assign": "4.1.1", "react-is": "16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "1.4.0", "object-assign": "4.1.1", "react-is": "16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@ -2133,6 +2194,8 @@
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-mentions": ["react-mentions@4.4.10", "", { "dependencies": { "@babel/runtime": "7.4.5", "invariant": "^2.2.4", "prop-types": "^15.5.8", "substyle": "^9.1.0" }, "peerDependencies": { "react": ">=16.8.3", "react-dom": ">=16.8.3" } }, "sha512-JHiQlgF1oSZR7VYPjq32wy97z1w1oE4x10EuhKjPr4WUKhVzG1uFQhQjKqjQkbVqJrmahf+ldgBTv36NrkpKpA=="], "react-mentions": ["react-mentions@4.4.10", "", { "dependencies": { "@babel/runtime": "7.4.5", "invariant": "^2.2.4", "prop-types": "^15.5.8", "substyle": "^9.1.0" }, "peerDependencies": { "react": ">=16.8.3", "react-dom": ">=16.8.3" } }, "sha512-JHiQlgF1oSZR7VYPjq32wy97z1w1oE4x10EuhKjPr4WUKhVzG1uFQhQjKqjQkbVqJrmahf+ldgBTv36NrkpKpA=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
@ -2163,6 +2226,16 @@
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-errors": "1.3.0", "get-proto": "1.0.1", "gopd": "1.2.0", "set-function-name": "2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-errors": "1.3.0", "get-proto": "1.0.1", "gopd": "1.2.0", "set-function-name": "2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"remark-directive": ["remark-directive@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", "micromark-extension-directive": "^4.0.0", "unified": "^11.0.0" } }, "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
@ -2255,6 +2328,8 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "1.1.2", "source-map": "0.6.1" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "1.1.2", "source-map": "0.6.1" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
@ -2301,6 +2376,10 @@
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "optionalDependencies": { "@babel/core": "7.28.4" }, "peerDependencies": { "react": "19.2.3" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "optionalDependencies": { "@babel/core": "7.28.4" }, "peerDependencies": { "react": "19.2.3" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
@ -2357,6 +2436,10 @@
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "1.0.5" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "1.0.5" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
@ -2403,8 +2486,12 @@
"unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="], "unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
@ -2441,6 +2528,8 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-stringify-position": "4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-stringify-position": "4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "0.25.11", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.52.4", "tinyglobby": "0.2.15" }, "optionalDependencies": { "@types/node": "25.0.3", "fsevents": "2.3.3", "jiti": "2.6.1", "lightningcss": "1.30.2", "sass": "1.77.4", "tsx": "4.20.6" }, "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], "vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "0.25.11", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.52.4", "tinyglobby": "0.2.15" }, "optionalDependencies": { "@types/node": "25.0.3", "fsevents": "2.3.3", "jiti": "2.6.1", "lightningcss": "1.30.2", "sass": "1.77.4", "tsx": "4.20.6" }, "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
@ -2723,6 +2812,8 @@
"mcp-handler/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "mcp-handler/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],

View file

@ -3,13 +3,18 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import packageJson from "./package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" };
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// Your Next.js config here // Your Next.js config here
env: { env: {
version: packageJson.version, version: packageJson.version,
}, },
// Server Actions default to a 1 MB request body limit, which would reject
// most image uploads. Bump it so wiki image uploads (capped at 8 MB in the
// uploadWikiImage action) pass through with room for multipart overhead.
serverActions: {
bodySizeLimit: "10mb",
},
// Pin the workspace root to this repo dir so the standalone build emits a // Pin the workspace root to this repo dir so the standalone build emits a
// flat .next/standalone/server.js (Next otherwise walks up looking for // flat .next/standalone/server.js (Next otherwise walks up looking for
// lockfiles and can pick a parent directory — here a stray // lockfiles and can pick a parent directory — here a stray

View file

@ -75,6 +75,9 @@
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-markdown": "10.1.0",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"sharp": "0.34.5", "sharp": "0.34.5",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",

View file

@ -500,3 +500,62 @@
*::-webkit-scrollbar-corner { *::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
/* Wiki markdown — directives, footnotes, callouts (scoped to .wiki-markdown) */
.wiki-markdown .columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: 1rem;
}
.wiki-markdown .columns.cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.wiki-markdown .columns.cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.wiki-markdown .column {
min-width: 0;
}
.wiki-markdown .footnotes {
font-size: 0.875rem;
color: var(--color-muted-foreground);
margin-top: 2rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.wiki-markdown .note,
.wiki-markdown .warning {
position: relative;
border-left: 3px solid var(--color-border);
border-radius: 0.375rem;
padding: 0.75rem 1rem 0.75rem 2.5rem;
}
.wiki-markdown .note .callout-icon {
position: absolute;
left: 0.75rem;
top: 1.125rem;
color: var(--color-primary);
}
.wiki-markdown .warning .callout-icon {
position: absolute;
left: 0.75rem;
top: 1.125rem;
color: var(--color-destructive);
}
.wiki-markdown .note {
border-left-color: var(--color-primary);
background: color-mix(in oklch, var(--color-primary) 8%, transparent);
}
.wiki-markdown .warning {
border-left-color: var(--color-destructive);
background: color-mix(in oklch, var(--color-destructive) 8%, transparent);
}

View file

@ -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 (
<div className="flex flex-col gap-6 p-5">
<Link
href={`/wiki/${page.slug}`}
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Back to page
</Link>
<div className="flex items-center gap-2">
<PencilIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Edit {page.title}</h1>
</div>
<LockdownBanner />
</div>
);
}
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 (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-3">
<Link
href={`/wiki/${page.slug}`}
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Back to page
</Link>
<div className="flex items-center gap-2">
<PencilIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Edit {page.title}</h1>
</div>
<p className="text-sm text-muted-foreground">
Update the article and leave a short note for the revision history.
</p>
</div>
<Card>
<CardContent className="p-6">
<WikiEditor
mode="edit"
initial={{
id: page.id,
title: page.title,
category: page.category,
tags: page.tags ?? [],
body: page.body,
}}
templates={templateMap(templates)}
existingPages={existingPages}
isFirstEdit={!hasEditedRevisions}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="flex flex-col gap-4 p-6">
<div className="flex flex-col gap-1">
<h1 className="font-semibold">Page doesn&apos;t exist yet</h1>
<p className="text-sm text-muted-foreground">
There is no revision history until this page is created.
</p>
</div>
<Button asChild className="w-fit">
<Link href={`/wiki/new?title=${encodeURIComponent(slug)}`}>Create this page</Link>
</Button>
</CardContent>
</Card>
);
}
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 (
<div className="flex flex-col gap-6 p-5">
<Link
href="/wiki"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Wiki
</Link>
<MissingWikiPage slug={slug} />
</div>
);
}
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 (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-3">
<Link
href={`/wiki/${page.slug}`}
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Back to page
</Link>
<div className="flex flex-wrap items-center gap-2">
<HistoryIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">History: {page.title}</h1>
<Badge variant="outline">{revisions.length} revisions</Badge>
</div>
<p className="text-sm text-muted-foreground">
Review the article&apos;s edit trail and restore an earlier snapshot when needed.
</p>
</div>
<RevisionList
pageId={page.id}
slug={page.slug}
revisions={revisions}
isModerator={isModerator}
/>
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="flex flex-col gap-4 p-6">
<div className="flex flex-col gap-1">
<h1 className="font-semibold">Page doesn&apos;t exist yet</h1>
<p className="text-sm text-muted-foreground">
This entry is not in the field guide. Start a page for this topic instead.
</p>
</div>
<Button asChild className="w-fit">
<Link href={`/wiki/new?title=${encodeURIComponent(slug)}`}>Create this page</Link>
</Button>
</CardContent>
</Card>
);
}
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 (
<div className="flex flex-col gap-6 p-5">
<Link
href="/wiki"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Wiki
</Link>
<MissingWikiPage slug={slug} />
</div>
);
}
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 (
<div className="flex flex-col gap-6 p-5">
<Link
href="/wiki"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Wiki
</Link>
<Card>
<CardContent className="flex flex-col gap-4 p-6">
<div className="flex flex-col gap-1">
<h1 className="font-semibold">Revision not found</h1>
<p className="text-sm text-muted-foreground">
Revision {revisionNumber} of this page no longer exists.
</p>
</div>
<Button asChild className="w-fit">
<Link href={`/wiki/${page.slug}`}>Back to current version</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
const prepared = prepareMarkdown(revision.contentSnapshot, templateMapValue);
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-3">
<Link
href="/wiki"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Wiki
</Link>
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 px-4 py-3 text-sm">
<span className="text-muted-foreground">
You&apos;re viewing revision {revision.revisionNumber} (from{" "}
{formatDate(revision.createdAt)}).
</span>
<Button asChild size="sm" variant="outline">
<Link href={`/wiki/${page.slug}`}>Back to current version</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<BookOpenIcon className="size-5 shrink-0 text-muted-foreground" />
<h1 className="min-w-0 text-xl font-semibold leading-tight">
{revision.titleSnapshot}
</h1>
</div>
</div>
<Card>
<CardContent className="p-6">
<WikiContent prepared={prepared} />
</CardContent>
</Card>
</div>
);
}
const prepared = prepareMarkdown(page.body, templateMapValue);
const lockdown = page.lockdown === true;
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-3">
<Link
href="/wiki"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Wiki
</Link>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
<BookOpenIcon className="size-5 shrink-0 text-muted-foreground" />
<h1 className="min-w-0 text-xl font-semibold leading-tight">{page.title}</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{page.category}</Badge>
{(page.tags ?? []).map((tag) => (
<Badge key={tag} variant="secondary" className="font-normal">
{tag}
</Badge>
))}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
<span>Last edited by {relationName(page.lastEditor)}</span>
<time dateTime={page.updatedAt}>{formatDate(page.updatedAt)}</time>
</div>
</div>
</div>
{lockdown && <LockdownBanner />}
<Card>
<CardContent className="p-6">
<WikiContent prepared={prepared} />
</CardContent>
</Card>
<WikiToolbar
slug={page.slug}
id={page.id}
lockdown={lockdown}
isModerator={isModerator}
canEdit={!lockdown}
/>
</div>
);
}

View file

@ -0,0 +1,267 @@
"use server";
import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload";
import type { User, WikiPage } from "@/payload-types";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
createPage,
deletePage,
restoreRevision,
setPageLockdown as setPageLockdownService,
updatePage,
} from "@/lib/wiki/service";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
async function fetchPage(
payload: Awaited<ReturnType<typeof getPayload>>,
id: number,
): Promise<WikiPage | null> {
try {
return (await payload.findByID({
collection: "wiki-pages",
id,
depth: 0,
overrideAccess: true,
})) as unknown as WikiPage;
} catch {
return null;
}
}
async function isWikiModerator(
payload: Awaited<ReturnType<typeof getPayload>>,
user: User,
): Promise<boolean> {
return hasIntelligenceQualification(payload, user);
}
export async function createWikiPage(input: {
title: string;
category: string;
tags?: string[];
body: string;
summary?: string;
}): Promise<ActionResult<{ id: number; slug: string }>> {
try {
const { payload, user } = await authenticate();
const page = await createPage(payload, user, input);
await emitGameEvent(payload, {
type: EventTypes.WikiPageCreate,
message: `${user.displayName ?? user.username} created wiki page "${input.title}"`,
actor: user.id,
targetCollection: "wiki-pages",
targetId: page.id,
data: { slug: page.slug },
});
return { success: true, data: { id: page.id, slug: page.slug } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function updateWikiPage(input: {
id: number;
title?: string;
body?: string;
category?: string;
tags?: string[];
summary?: string;
}): Promise<ActionResult<{ id: number; slug: string }>> {
try {
const { payload, user } = await authenticate();
const page = await fetchPage(payload, input.id);
if (!page) return { success: false, error: "Wiki page not found." };
const updated = await updatePage(payload, user, page, input);
await emitGameEvent(payload, {
type: EventTypes.WikiPageEdit,
message: `${user.displayName ?? user.username} edited wiki page "${updated.title}"`,
actor: user.id,
targetCollection: "wiki-pages",
targetId: updated.id,
data: { slug: updated.slug },
});
return { success: true, data: { id: updated.id, slug: updated.slug } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function restoreWikiRevision(input: {
id: number;
revisionNumber: number;
}): Promise<ActionResult<{ id: number; slug: string }>> {
try {
const { payload, user } = await authenticate();
if (!(await isWikiModerator(payload, user))) {
return {
success: false,
error: "You need intelligence division clearance to restore wiki pages.",
};
}
const page = await fetchPage(payload, input.id);
if (!page) return { success: false, error: "Wiki page not found." };
const updated = await restoreRevision(payload, user, page, input.revisionNumber);
await emitGameEvent(payload, {
type: EventTypes.WikiPageRestore,
message: `${user.displayName ?? user.username} restored wiki page "${updated.title}" to revision ${input.revisionNumber}`,
actor: user.id,
targetCollection: "wiki-pages",
targetId: updated.id,
data: { slug: updated.slug, revisionNumber: input.revisionNumber },
});
return { success: true, data: { id: updated.id, slug: updated.slug } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function setPageLockdown(input: {
id: number;
lockdown: boolean;
}): Promise<ActionResult<{ id: number; slug: string; lockdown: boolean }>> {
try {
const { payload, user } = await authenticate();
if (!(await isWikiModerator(payload, user))) {
return {
success: false,
error: "You need intelligence division clearance to restore wiki pages.",
};
}
const page = await fetchPage(payload, input.id);
if (!page) return { success: false, error: "Wiki page not found." };
const updated = await setPageLockdownService(payload, user, page, input.lockdown);
await emitGameEvent(payload, {
type: input.lockdown ? EventTypes.WikiPageLock : EventTypes.WikiPageUnlock,
message: `${user.displayName ?? user.username} ${input.lockdown ? "locked" : "unlocked"} wiki page "${updated.title}"`,
actor: user.id,
targetCollection: "wiki-pages",
targetId: updated.id,
data: { slug: updated.slug, lockdown: input.lockdown },
});
return {
success: true,
data: { id: updated.id, slug: updated.slug, lockdown: input.lockdown },
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function deleteWikiPage(input: { id: number }): Promise<ActionResult<{ ok: true }>> {
try {
const { payload, user } = await authenticate();
if (!(await isWikiModerator(payload, user))) {
return {
success: false,
error: "You need intelligence division clearance to restore wiki pages.",
};
}
const page = await fetchPage(payload, input.id);
if (!page) return { success: false, error: "Wiki page not found." };
await deletePage(payload, user, page);
await emitGameEvent(payload, {
type: EventTypes.WikiPageDelete,
message: `${user.displayName ?? user.username} deleted wiki page "${page.title}"`,
actor: user.id,
targetCollection: "wiki-pages",
targetId: page.id,
data: { slug: page.slug },
});
return { success: true, data: { ok: true } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function uploadWikiImage(
formData: FormData,
): Promise<ActionResult<{ url: string; filename: string; alt: string }>> {
try {
const { payload } = await authenticate();
const file = formData.get("file");
if (!(file instanceof File)) {
return { success: false, error: "No image file provided." };
}
if (!file.type.startsWith("image/")) {
return { success: false, error: "Only image files can be uploaded." };
}
if (file.size > 8 * 1024 * 1024) {
return { success: false, error: "Images must be 8 MB or smaller." };
}
const alt =
file.name
.replace(/\.[^/.]+$/, "")
.replace(/[^a-zA-Z0-9 _-]/g, "")
.trim() || "Image";
const doc = await payload.create({
collection: "media",
data: { alt },
file: {
data: Buffer.from(await file.arrayBuffer()),
mimetype: file.type,
name: file.name,
size: file.size,
},
overrideAccess: true,
});
if (!doc.filename) {
return { success: false, error: "Upload failed — no file was stored." };
}
return {
success: true,
data: { url: doc.url ?? `/api/media/file/${doc.filename}`, filename: doc.filename, alt },
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}

View file

@ -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 (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-3">
<Link
href="/wiki"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Wiki
</Link>
<div className="flex items-center gap-2">
<FilePlus2Icon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">New wiki page</h1>
</div>
<p className="text-sm text-muted-foreground">
Add a durable reference for the rest of the task force.
</p>
</div>
<WikiEditor
mode="create"
initialTitle={initialTitle}
templates={templateMap(templates)}
existingPages={existingPages}
isFirstEdit={!hasEditedRevisions}
/>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<BookOpenIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Wiki</h1>
</div>
<p className="text-sm text-muted-foreground">
A shared field guide for campaigns, characters, places, and the stories around them.
</p>
</div>
<WikiIndex pages={pages} isModerator={isModerator} />
</div>
);
}

View file

@ -25,6 +25,9 @@ const TARGET_COLLECTIONS = [
{ label: "Market Negotiations", value: "market-negotiations" }, { label: "Market Negotiations", value: "market-negotiations" },
{ label: "Evaluations", value: "evaluations" }, { label: "Evaluations", value: "evaluations" },
{ label: "Tickets", value: "tickets" }, { label: "Tickets", value: "tickets" },
{ label: "Wiki Pages", value: "wiki-pages" },
{ label: "Wiki Revisions", value: "wiki-revisions" },
{ label: "Wiki Templates", value: "wiki-templates" },
{ label: "Game Servers", value: "game-servers" }, { label: "Game Servers", value: "game-servers" },
{ label: "Assignment Transfers", value: "assignment-transfers" }, { label: "Assignment Transfers", value: "assignment-transfers" },
] as const; ] as const;

View file

@ -0,0 +1,86 @@
import { CollectionConfig } from "payload";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
export const WikiPages: CollectionConfig = {
slug: "wiki-pages",
admin: {
group: "Wiki",
useAsTitle: "title",
defaultColumns: ["title", "slug", "category", "updatedAt"],
listSearchableFields: ["title", "slug"],
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: async ({ req }) => {
return await hasIntelligenceQualification(req.payload, req.user);
},
},
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
index: true,
admin: {
description: "URL slug. Generated by the service layer from the title.",
},
},
{
name: "category",
type: "select",
required: true,
options: [
{ label: "Campaign", value: "Campaign" },
{ label: "World", value: "World" },
{ label: "Lore", value: "Lore" },
{ label: "Characters", value: "Characters" },
{ label: "Plot", value: "Plot" },
{ label: "Media", value: "Media" },
{ label: "Guides", value: "Guides" },
{ label: "Meta", value: "Meta" },
],
index: true,
},
{
name: "tags",
type: "text",
hasMany: true,
},
{
name: "body",
type: "textarea",
required: true,
admin: {
description: "Markdown source. Rendered by the frontend.",
},
},
{
name: "lockdown",
type: "checkbox",
defaultValue: false,
},
{
name: "lockedBy",
type: "relationship",
relationTo: "users",
},
{
name: "lockedAt",
type: "date",
},
{
name: "lastEditor",
type: "relationship",
relationTo: "users",
},
],
};

View file

@ -0,0 +1,64 @@
import type { CollectionConfig } from "payload";
export const WikiRevisions: CollectionConfig = {
slug: "wiki-revisions",
admin: {
group: "Wiki",
defaultColumns: ["page", "revisionNumber", "type", "editor", "createdAt"],
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => Boolean(req.user),
create: () => false,
update: () => false,
delete: () => false,
},
fields: [
{
name: "page",
type: "relationship",
relationTo: "wiki-pages",
required: true,
index: true,
},
{
name: "revisionNumber",
type: "number",
required: true,
min: 1,
},
{
name: "titleSnapshot",
type: "text",
required: true,
},
{
name: "contentSnapshot",
type: "textarea",
required: true,
},
{
name: "editor",
type: "relationship",
relationTo: "users",
},
{
name: "summary",
type: "text",
},
{
name: "type",
type: "select",
required: true,
options: [
{ label: "Create", value: "create" },
{ label: "Edit", value: "edit" },
{ label: "Restore", value: "restore" },
],
},
{
name: "restoredFromRevision",
type: "number",
},
],
};

View file

@ -0,0 +1,40 @@
import { CollectionConfig } from "payload";
import hasRoles from "@/utils/access-control/hasRoles";
export const WikiTemplates: CollectionConfig = {
slug: "wiki-templates",
admin: {
group: "Wiki",
useAsTitle: "name",
defaultColumns: ["name", "updatedAt"],
listSearchableFields: ["name"],
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => hasRoles(["admin", "developer"], req.user),
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
delete: ({ req }) => hasRoles(["admin", "developer"], req.user),
},
fields: [
{
name: "name",
type: "text",
required: true,
unique: true,
index: true,
},
{
name: "body",
type: "textarea",
required: true,
admin: {
description: "Snippet text with {{param}}-style placeholders as plain markdown.",
},
},
{
name: "description",
type: "textarea",
},
],
};

View file

@ -2,22 +2,11 @@
import * as React from "react"; import * as React from "react";
import { import {
Award,
ArrowLeftRight, ArrowLeftRight,
ArrowLeftRightIcon, BookOpen,
Backpack,
Bolt, Bolt,
Crosshair,
Kanban,
LifeBuoy,
Search,
Send,
Shield, Shield,
ShoppingBag,
TrendingUp,
UserCog,
Users, Users,
Warehouse,
Maximize, Maximize,
Minimize, Minimize,
SparkleIcon, SparkleIcon,
@ -39,10 +28,11 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import XPDisplay from "@/components/frontend/blocks/XPDisplay"; import XPDisplay from "@/components/frontend/blocks/XPDisplay";
import type { ResolvedLevel } from "@/utils/xp/resolveLevel"; import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
import { Rank, User } from "@/payload-types"; import type { User } from "@/payload-types";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { updatePreferences } from "@/app/(frontend)/account/actions"; import { updatePreferences } from "@/app/(frontend)/account/actions";
import { sidebarData } from "@/components/frontend/blocks/sidebarData";
const data = { const data = {
navMain: [ navMain: [
@ -61,110 +51,13 @@ const data = {
url: "/transfers", url: "/transfers",
icon: ArrowLeftRight, icon: ArrowLeftRight,
}, },
],
navHelpdesk: [
{ {
title: "Helpdesk", title: "Wiki",
url: "/helpdesk", url: "/wiki",
icon: LifeBuoy, icon: BookOpen,
},
{
title: "Projects",
url: "/projects",
icon: Kanban,
},
{
title: "Search",
url: "/projects/search",
icon: Search,
},
],
navIntelligence: [
{
title: "Intelligence",
url: "#",
icon: Crosshair,
items: [
{
title: "Campaigns",
url: "/intelligence/campaigns",
},
{
title: "Missions",
url: "/intelligence/missions",
},
{
title: "Factions",
url: "/intelligence/factions",
},
],
},
],
navLogistics: [
{
title: "Logistics",
url: "#",
icon: Warehouse,
items: [
{
title: "Structures",
url: "/logistics",
},
{
title: "Resources",
url: "/logistics/resources",
},
{
title: "Vehicles",
url: "/logistics/vehicles",
},
{
title: "Shipments",
url: "/logistics/shipments",
},
{
title: "Deployed Vehicles",
url: "/logistics/game-vehicles",
},
],
},
],
navCommerce: [
{
title: "Banking",
url: "/logistics/banking",
icon: TrendingUp,
},
{
title: "Market",
url: "/logistics/market",
icon: ShoppingBag,
},
],
navPersonal: [
{
title: "Awards",
url: "/awards",
icon: Award,
},
{
title: "Locker",
url: "/locker",
icon: Backpack,
},
{
title: "Account",
url: "/account",
icon: UserCog,
},
],
navSecondary: [
{
title: "Feedback",
url: "https://onyxsimple.youtrack.cloud",
icon: Send,
}, },
], ],
...sidebarData,
}; };
export function AppSidebar({ export function AppSidebar({

View file

@ -0,0 +1,119 @@
import {
Award,
Backpack,
Crosshair,
Kanban,
LifeBuoy,
Search,
Send,
ShoppingBag,
TrendingUp,
UserCog,
Warehouse,
} from "lucide-react";
export const sidebarData = {
navHelpdesk: [
{
title: "Helpdesk",
url: "/helpdesk",
icon: LifeBuoy,
},
{
title: "Projects",
url: "/projects",
icon: Kanban,
},
{
title: "Search",
url: "/projects/search",
icon: Search,
},
],
navIntelligence: [
{
title: "Intelligence",
url: "#",
icon: Crosshair,
items: [
{
title: "Campaigns",
url: "/intelligence/campaigns",
},
{
title: "Missions",
url: "/intelligence/missions",
},
{
title: "Factions",
url: "/intelligence/factions",
},
],
},
],
navLogistics: [
{
title: "Logistics",
url: "#",
icon: Warehouse,
items: [
{
title: "Structures",
url: "/logistics",
},
{
title: "Resources",
url: "/logistics/resources",
},
{
title: "Vehicles",
url: "/logistics/vehicles",
},
{
title: "Shipments",
url: "/logistics/shipments",
},
{
title: "Deployed Vehicles",
url: "/logistics/game-vehicles",
},
],
},
],
navCommerce: [
{
title: "Banking",
url: "/logistics/banking",
icon: TrendingUp,
},
{
title: "Market",
url: "/logistics/market",
icon: ShoppingBag,
},
],
navPersonal: [
{
title: "Awards",
url: "/awards",
icon: Award,
},
{
title: "Locker",
url: "/locker",
icon: Backpack,
},
{
title: "Account",
url: "/account",
icon: UserCog,
},
],
navSecondary: [
{
title: "Feedback",
url: "https://onyxsimple.youtrack.cloud",
icon: Send,
},
],
};

View 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>
);
}

View 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>![alt text](https://… "Caption on hover")</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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: `![${alt}](${url})`, placeholder: alt });
toast.success("Image uploaded.");
} catch {
toast.error("Upload failed.");
} finally {
setUploading(false);
}
}
function applyBodyUpdate(update: BodyUpdate) {
onBodyChange(update.value);
requestAnimationFrame(() => {
const textarea = bodyRef.current;
textarea?.focus();
textarea?.setSelectionRange(update.selectionStart, update.selectionEnd);
});
}
function wrapSelection(format: WrappedFormat) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createWrappedUpdate(body, selection, format));
}
function prefixLines(prefix: string) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createLinePrefixUpdate(body, selection, prefix));
}
function insertSnippet(snippet: SnippetInsertion) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createSnippetUpdate(body, selection, snippet));
}
function insertReference(definitionSuffix: string) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createReferenceUpdate(body, selection, { definitionSuffix }));
}
function insertWikilink(pageTitle: string) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(
createSnippetUpdate(body, selection, {
insertion: `[[${pageTitle}]]`,
}),
);
onLinkDialogChange(false);
}
function handleBodyKeyDown(event: KeyboardEvent<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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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&apos;t meet these standards.
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" onClick={onAccept}>
I understand
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View 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>
);
}

View file

@ -0,0 +1,12 @@
export const WIKI_CATEGORIES = [
"Campaign",
"World",
"Lore",
"Characters",
"Plot",
"Media",
"Guides",
"Meta",
] as const;
export type WikiCategory = (typeof WIKI_CATEGORIES)[number];

View file

@ -0,0 +1,172 @@
import type { RefObject } from "react";
export interface BodySelection {
readonly start: number;
readonly end: number;
readonly text: string;
}
export interface BodyUpdate {
readonly value: string;
readonly selectionStart: number;
readonly selectionEnd: number;
}
export interface WrappedFormat {
readonly prefix: string;
readonly suffix: string;
readonly placeholder: string;
}
export interface SnippetInsertion {
readonly insertion: string;
readonly placeholder?: string;
}
export interface ReferenceInsertion {
readonly definitionSuffix: string;
}
export const TWO_COLUMN_SNIPPET = `::::columns
:::column
Left column content
:::
:::column
Right column content
:::
::::`;
export const THREE_COLUMN_SNIPPET = `::::columns
:::column
Left column content
:::
:::column
Center column content
:::
:::column
Right column content
:::
::::`;
export const TABLE_SNIPPET = `| Header | Header |
| --- | --- |
| Cell | Cell |`;
export const IMAGE_SNIPPET = `![Alt text](https:// "Caption on hover")`;
export function readBodySelection(
body: string,
bodyRef: RefObject<HTMLTextAreaElement | null>,
): BodySelection {
const start = bodyRef.current?.selectionStart ?? body.length;
const end = bodyRef.current?.selectionEnd ?? body.length;
return { start, end, text: body.slice(start, end) };
}
export function createWrappedUpdate(
body: string,
selection: BodySelection,
format: WrappedFormat,
): BodyUpdate {
const content = selection.text || format.placeholder;
const value = `${body.slice(0, selection.start)}${format.prefix}${content}${format.suffix}${body.slice(selection.end)}`;
const contentStart = selection.start + format.prefix.length;
return {
value,
selectionStart: contentStart,
selectionEnd: contentStart + content.length,
};
}
export function createLinePrefixUpdate(
body: string,
selection: BodySelection,
prefix: string,
): BodyUpdate {
const hasSelection = selection.start !== selection.end;
const lineStart = body.lastIndexOf("\n", selection.start - 1) + 1;
if (!hasSelection) {
return {
value: `${body.slice(0, lineStart)}${prefix}${body.slice(lineStart)}`,
selectionStart: selection.start + prefix.length,
selectionEnd: selection.start + prefix.length,
};
}
const selectedEnd =
selection.end > selection.start && body[selection.end - 1] === "\n"
? selection.end - 1
: selection.end;
const nextLine = body.indexOf("\n", selectedEnd);
const lineEnd = nextLine === -1 ? body.length : nextLine;
const formatted = body
.slice(lineStart, lineEnd)
.split("\n")
.map((line) => `${prefix}${line}`)
.join("\n");
return {
value: `${body.slice(0, lineStart)}${formatted}${body.slice(lineEnd)}`,
selectionStart: lineStart,
selectionEnd: lineStart + formatted.length,
};
}
export function createSnippetUpdate(
body: string,
selection: BodySelection,
snippet: SnippetInsertion,
): BodyUpdate {
const placeholder = snippet.placeholder;
const placeholderStart = placeholder ? snippet.insertion.indexOf(placeholder) : -1;
const selectionStart =
placeholderStart === -1
? selection.start + snippet.insertion.length
: selection.start + placeholderStart;
const selectionEnd =
placeholderStart === -1 ? selectionStart : selectionStart + (placeholder?.length ?? 0);
return {
value: `${body.slice(0, selection.start)}${snippet.insertion}${body.slice(selection.end)}`,
selectionStart,
selectionEnd,
};
}
export function createReferenceUpdate(
body: string,
selection: BodySelection,
reference: ReferenceInsertion,
): BodyUpdate {
const number = (body.match(/\[\^/g) ?? []).length + 1;
const marker = `[^${number}]`;
const bodyWithMarker = `${body.slice(0, selection.start)}${marker}${body.slice(selection.end)}`;
const definition = `${marker}: ${reference.definitionSuffix}`;
const separator =
bodyWithMarker.length === 0
? ""
: bodyWithMarker.endsWith("\n\n")
? ""
: bodyWithMarker.endsWith("\n")
? "\n"
: "\n\n";
const value = `${bodyWithMarker}${separator}${definition}`;
if (reference.definitionSuffix) {
const definitionStart =
bodyWithMarker.length + separator.length + definition.indexOf(reference.definitionSuffix);
return {
value,
selectionStart: definitionStart,
selectionEnd: definitionStart + reference.definitionSuffix.length,
};
}
const cursor = bodyWithMarker.length + separator.length + definition.length;
return { value, selectionStart: cursor, selectionEnd: cursor };
}

137
src/lib/wiki/markdown.tsx Normal file
View file

@ -0,0 +1,137 @@
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkDirective from "remark-directive";
import type { State } from "mdast-util-to-hast";
import type { ContainerDirective, LeafDirective, TextDirective } from "mdast-util-directive";
import { cn } from "@/lib/utils";
import { Info, TriangleAlert } from "lucide-react";
/**
* Shared markdown pipeline for wiki pages.
*
* Client-safe: no Payload imports, no "use client" — usable from both server
* components (WikiContent) and client components (WikiEditor live preview).
*/
/** remark plugins: GFM (tables, strikethrough, task lists, footnotes) + directives. */
export const wikiRemarkPlugins = [remarkGfm, remarkDirective];
type DirectiveNode = ContainerDirective | LeafDirective | TextDirective;
/**
* Build a remark-rehype handler for directive nodes.
*
* The div variant is the verified shape from the remark-directive docs:
* name -> className, `class` attribute tokenized into className, other
* attributes -> properties, then state.patch + state.applyData. The text
* variant renders a span (a div inside a <p> is invalid HTML).
*/
function makeDirectiveHandler(tagName: "div" | "span") {
return function directiveHandler(state: State, node: DirectiveNode) {
const result = {
type: "element",
tagName,
properties: {},
children: state.all(node),
} as any;
if (node.name) result.properties.className = [node.name];
if (node.attributes) {
for (const [k, v] of Object.entries(node.attributes)) {
if (v === null || v === undefined) continue;
if (k === "class") {
result.properties.className = [
...(result.properties.className ?? []),
...String(v).split(/\s+/),
];
} else {
result.properties[k] = v;
}
}
}
state.patch(node, result);
return state.applyData(node, result);
};
}
/** remark-rehype options: directives become divs (container/leaf) or spans (text). */
export const wikiRemarkRehypeOptions = {
handlers: {
containerDirective: makeDirectiveHandler("div"),
leafDirective: makeDirectiveHandler("div"),
textDirective: makeDirectiveHandler("span"),
},
};
/** Component overrides shared by the wiki detail page and the editor preview. */
export const wikiMarkdownComponents: Components = {
h1: ({ children }) => (
<h1 className="border-b pb-2 text-2xl font-semibold tracking-tight">{children}</h1>
),
h2: ({ children }) => (
<h2 className="border-b pb-2 text-xl font-semibold tracking-tight">{children}</h2>
),
h3: ({ children }) => <h3 className="text-lg font-semibold tracking-tight">{children}</h3>,
h4: ({ children }) => <h4 className="text-base font-semibold">{children}</h4>,
p: ({ children }) => <p className="leading-7 text-muted-foreground">{children}</p>,
ul: ({ children }) => <ul className="ml-5 list-disc text-muted-foreground">{children}</ul>,
ol: ({ children }) => <ol className="ml-5 list-decimal text-muted-foreground">{children}</ol>,
a: ({ children, href }) => (
<a
href={href}
className="font-medium text-primary underline underline-offset-4 hover:no-underline"
>
{children}
</a>
),
code: ({ children, className }) => (
<code
className={cn("rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground", className)}
>
{children}
</code>
),
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-md border bg-muted p-4 font-mono text-xs leading-6">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 pl-4 italic text-muted-foreground">{children}</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[32rem] text-left text-sm">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b bg-muted px-3 py-2 font-semibold text-foreground">{children}</th>
),
td: ({ children }) => <td className="border-b px-3 py-2 text-muted-foreground">{children}</td>,
img: ({ src, alt, title }) => {
if (!src) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt ?? ""} title={title} className="max-w-full rounded-md border" />
);
},
div: ({ className, node, children }) => {
const rawClass = node?.properties?.className;
const directiveName = Array.isArray(rawClass) ? String(rawClass[0]) : undefined;
const isCallout = directiveName === "note" || directiveName === "warning";
const Icon = directiveName === "warning" ? TriangleAlert : Info;
return (
<div className={className} data-directive={directiveName}>
{isCallout && <Icon className="callout-icon mt-0.5 size-4 shrink-0" aria-hidden="true" />}
{children}
</div>
);
},
};
// Re-export the component so consumers import the whole pipeline from one
// module. react-markdown@10.1.0's default export is the component (a named
// `Markdown` export only exists in later versions).
export { ReactMarkdown };

11
src/lib/wiki/prepare.ts Normal file
View file

@ -0,0 +1,11 @@
/**
* Single entry point the frontend uses before rendering wiki markdown:
* template expansion first, then wikilink preprocessing.
*/
import { expandTemplates, type TemplateMap } from "./templates";
import { preprocessWikilinks } from "./wikilinks";
export function prepareMarkdown(source: string, templates: TemplateMap): string {
return preprocessWikilinks(expandTemplates(source, templates));
}

257
src/lib/wiki/service.ts Normal file
View file

@ -0,0 +1,257 @@
/**
* Wiki page service — server-only domain logic.
*
* All mutations run with `overrideAccess: true` (the wiki-revisions collection
* denies create/update/delete outright; wiki-pages allows them for logged-in
* users but the service must not depend on request auth). Throws descriptive
* `Error`s on violations. Does NOT emit events — the actions layer emits.
*/
import type { Payload } from "payload";
import type { User, WikiPage, WikiRevision, WikiTemplate } from "@/payload-types";
import { slugify } from "./slugify";
import type { TemplateMap } from "./templates";
type WikiCategory = WikiPage["category"];
async function uniqueSlug(payload: Payload, base: string): Promise<string> {
let candidate = base;
let suffix = 2;
for (;;) {
const existing = (await payload.find({
collection: "wiki-pages",
where: { slug: { equals: candidate } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiPage[] };
if (existing.docs.length === 0) return candidate;
candidate = `${base}-${suffix}`;
suffix += 1;
}
}
async function nextRevisionNumber(payload: Payload, pageId: number): Promise<number> {
const revisions = (await payload.find({
collection: "wiki-revisions",
where: { page: { equals: pageId } },
sort: "-revisionNumber",
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiRevision[] };
const max = revisions.docs.length > 0 ? revisions.docs[0].revisionNumber : 0;
return max + 1;
}
export async function createPage(
payload: Payload,
user: User,
input: { title: string; category: string; tags?: string[]; body: string; summary?: string },
): Promise<WikiPage> {
const slug = await uniqueSlug(payload, slugify(input.title));
const page = (await payload.create({
collection: "wiki-pages",
data: {
title: input.title,
slug,
category: input.category as WikiCategory,
tags: input.tags ?? [],
body: input.body,
lastEditor: user.id,
},
overrideAccess: true,
depth: 0,
})) as unknown as WikiPage;
await payload.create({
collection: "wiki-revisions",
data: {
page: page.id,
revisionNumber: 1,
titleSnapshot: input.title,
contentSnapshot: input.body,
editor: user.id,
type: "create",
summary: input.summary ?? "Created page",
},
overrideAccess: true,
depth: 0,
});
return page;
}
export async function updatePage(
payload: Payload,
user: User,
page: WikiPage,
input: { title?: string; body?: string; category?: string; tags?: string[]; summary?: string },
): Promise<WikiPage> {
if (page.lockdown) {
throw new Error("This page is locked and cannot be edited.");
}
const nextTitle = input.title ?? page.title;
const nextBody = input.body ?? page.body;
const nextCategory = input.category ?? page.category;
const nextTags = input.tags ?? page.tags ?? [];
const revisionNumber = await nextRevisionNumber(payload, page.id);
await payload.create({
collection: "wiki-revisions",
data: {
page: page.id,
revisionNumber,
titleSnapshot: page.title,
contentSnapshot: page.body,
editor: user.id,
type: "edit",
summary: input.summary ?? "Edited page",
},
overrideAccess: true,
depth: 0,
});
const slug =
nextTitle !== page.title ? await uniqueSlug(payload, slugify(nextTitle)) : page.slug;
return (await payload.update({
collection: "wiki-pages",
id: page.id,
data: {
title: nextTitle,
slug,
body: nextBody,
category: nextCategory as WikiCategory,
tags: nextTags,
lastEditor: user.id,
},
overrideAccess: true,
depth: 0,
})) as unknown as WikiPage;
}
export async function restoreRevision(
payload: Payload,
user: User,
page: WikiPage,
revisionNumber: number,
): Promise<WikiPage> {
if (page.lockdown) {
throw new Error("This page is locked and cannot be edited.");
}
const found = (await payload.find({
collection: "wiki-revisions",
where: {
and: [{ page: { equals: page.id } }, { revisionNumber: { equals: revisionNumber } }],
},
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiRevision[] };
const target = found.docs[0];
if (!target) {
throw new Error(`Revision ${revisionNumber} not found for this page.`);
}
const next = await nextRevisionNumber(payload, page.id);
await payload.create({
collection: "wiki-revisions",
data: {
page: page.id,
revisionNumber: next,
titleSnapshot: target.titleSnapshot,
contentSnapshot: target.contentSnapshot,
editor: user.id,
type: "restore",
summary: `Restored from revision ${revisionNumber}`,
restoredFromRevision: revisionNumber,
},
overrideAccess: true,
depth: 0,
});
const restoredTitle = target.titleSnapshot;
const slug =
restoredTitle !== page.title ? await uniqueSlug(payload, slugify(restoredTitle)) : page.slug;
return (await payload.update({
collection: "wiki-pages",
id: page.id,
data: {
title: restoredTitle,
slug,
body: target.contentSnapshot,
lastEditor: user.id,
},
overrideAccess: true,
depth: 0,
})) as unknown as WikiPage;
}
export async function setPageLockdown(
payload: Payload,
user: User,
page: WikiPage,
lockdown: boolean,
): Promise<WikiPage> {
return (await payload.update({
collection: "wiki-pages",
id: page.id,
data: {
lockdown,
lockedBy: lockdown ? user.id : null,
lockedAt: lockdown ? new Date().toISOString() : null,
},
overrideAccess: true,
depth: 0,
})) as unknown as WikiPage;
}
export async function deletePage(payload: Payload, user: User, page: WikiPage): Promise<void> {
const revisions = (await payload.find({
collection: "wiki-revisions",
where: { page: { equals: page.id } },
limit: 500,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiRevision[] };
for (const revision of revisions.docs) {
await payload.delete({
collection: "wiki-revisions",
id: revision.id,
overrideAccess: true,
});
}
await payload.delete({
collection: "wiki-pages",
id: page.id,
overrideAccess: true,
});
}
export async function listTemplates(payload: Payload): Promise<WikiTemplate[]> {
const result = (await payload.find({
collection: "wiki-templates",
limit: 500,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiTemplate[] };
return result.docs;
}
export function templateMap(templates: WikiTemplate[]): TemplateMap {
const map: TemplateMap = {};
for (const template of templates) {
map[template.name] = template.body;
}
return map;
}

15
src/lib/wiki/slugify.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Deterministic URL slug from a page title.
*
* Lowercases, trims, collapses runs of non-alphanumeric characters into a
* single hyphen, strips leading/trailing hyphens, and falls back to "page"
* when the result would be empty.
*/
export function slugify(title: string): string {
const slug = title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "page";
}

35
src/lib/wiki/templates.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* Wiki template expansion.
*
* Pure module — safe to import client-side. Replaces `{{Name}}` placeholders
* (name trimmed, exact match on map keys) with the template body. Nested
* placeholders expand up to at most 2 levels deep: the page body is level 0,
* template bodies expanded from the page are level 1, templates inside those
* are level 2, and any `{{...}}` found at level 2+ is left as literal text.
* If a template name repeats anywhere in the current expansion chain, that
* `{{Name}}` is left literal (cycle guard).
*/
export type TemplateMap = Record<string, string>;
const PLACEHOLDER_RE = /\{\{\s*([^{}]+?)\s*\}\}/g;
function expandLevel(
text: string,
templates: TemplateMap,
depth: number,
chain: string[],
): string {
if (depth >= 2) return text;
return text.replace(PLACEHOLDER_RE, (match, rawName: string) => {
const name = rawName.trim();
const body = templates[name];
if (body === undefined) return match;
if (chain.includes(name)) return match;
return expandLevel(body, templates, depth + 1, [...chain, name]);
});
}
export function expandTemplates(source: string, templates: TemplateMap): string {
return expandLevel(source, templates, 0, []);
}

31
src/lib/wiki/wikilinks.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Wiki wikilink preprocessing.
*
* Pure module — safe to import client-side. Converts `[[Page Title]]` into
* `[Page Title](/wiki/<slug>)` and `[[Page Title|Custom Text]]` into
* `[Custom Text](/wiki/<slug>)`, where slug = slugify(trimmed Page Title).
*/
import { slugify } from "./slugify";
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
export function preprocessWikilinks(source: string): string {
return source.replace(WIKILINK_RE, (_match, title: string, custom?: string) => {
const target = title.trim();
const slug = slugify(target);
const label = custom ? custom.trim() : target;
return `[${label}](/wiki/${slug})`;
});
}
/** Returns the raw target titles (before the pipe) in document order. */
export function extractWikilinks(source: string): string[] {
const links: string[] = [];
const re = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
let match: RegExpExecArray | null;
while ((match = re.exec(source)) !== null) {
links.push(match[1].trim());
}
return links;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,113 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_wiki_pages_category" AS ENUM('Campaign', 'World', 'Lore', 'Characters', 'Plot', 'Media', 'Guides', 'Meta');
CREATE TYPE "public"."enum_wiki_revisions_type" AS ENUM('create', 'edit', 'restore');
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'wiki-pages' BEFORE 'game-servers';
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'wiki-revisions' BEFORE 'game-servers';
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'wiki-templates' BEFORE 'game-servers';
CREATE TABLE "wiki_pages" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"slug" varchar NOT NULL,
"category" "enum_wiki_pages_category" NOT NULL,
"body" varchar NOT NULL,
"lockdown" boolean DEFAULT false,
"locked_by_id" integer,
"locked_at" timestamp(3) with time zone,
"last_editor_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "wiki_pages_texts" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"text" varchar
);
CREATE TABLE "wiki_revisions" (
"id" serial PRIMARY KEY NOT NULL,
"page_id" integer NOT NULL,
"revision_number" numeric NOT NULL,
"title_snapshot" varchar NOT NULL,
"content_snapshot" varchar NOT NULL,
"editor_id" integer,
"summary" varchar,
"type" "enum_wiki_revisions_type" NOT NULL,
"restored_from_revision" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "wiki_templates" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"body" varchar NOT NULL,
"description" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "wiki_pages_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "wiki_revisions_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "wiki_templates_id" integer;
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_locked_by_id_users_id_fk" FOREIGN KEY ("locked_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_last_editor_id_users_id_fk" FOREIGN KEY ("last_editor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "wiki_pages_texts" ADD CONSTRAINT "wiki_pages_texts_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "wiki_revisions" ADD CONSTRAINT "wiki_revisions_page_id_wiki_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."wiki_pages"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "wiki_revisions" ADD CONSTRAINT "wiki_revisions_editor_id_users_id_fk" FOREIGN KEY ("editor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
CREATE UNIQUE INDEX "wiki_pages_slug_idx" ON "wiki_pages" USING btree ("slug");
CREATE INDEX "wiki_pages_category_idx" ON "wiki_pages" USING btree ("category");
CREATE INDEX "wiki_pages_locked_by_idx" ON "wiki_pages" USING btree ("locked_by_id");
CREATE INDEX "wiki_pages_last_editor_idx" ON "wiki_pages" USING btree ("last_editor_id");
CREATE INDEX "wiki_pages_updated_at_idx" ON "wiki_pages" USING btree ("updated_at");
CREATE INDEX "wiki_pages_created_at_idx" ON "wiki_pages" USING btree ("created_at");
CREATE INDEX "wiki_pages_texts_order_parent" ON "wiki_pages_texts" USING btree ("order","parent_id");
CREATE INDEX "wiki_revisions_page_idx" ON "wiki_revisions" USING btree ("page_id");
CREATE INDEX "wiki_revisions_editor_idx" ON "wiki_revisions" USING btree ("editor_id");
CREATE INDEX "wiki_revisions_updated_at_idx" ON "wiki_revisions" USING btree ("updated_at");
CREATE INDEX "wiki_revisions_created_at_idx" ON "wiki_revisions" USING btree ("created_at");
CREATE UNIQUE INDEX "wiki_templates_name_idx" ON "wiki_templates" USING btree ("name");
CREATE INDEX "wiki_templates_updated_at_idx" ON "wiki_templates" USING btree ("updated_at");
CREATE INDEX "wiki_templates_created_at_idx" ON "wiki_templates" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_wiki_pages_fk" FOREIGN KEY ("wiki_pages_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_wiki_revisions_fk" FOREIGN KEY ("wiki_revisions_id") REFERENCES "public"."wiki_revisions"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_wiki_templates_fk" FOREIGN KEY ("wiki_templates_id") REFERENCES "public"."wiki_templates"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_wiki_pages_id_idx" ON "payload_locked_documents_rels" USING btree ("wiki_pages_id");
CREATE INDEX "payload_locked_documents_rels_wiki_revisions_id_idx" ON "payload_locked_documents_rels" USING btree ("wiki_revisions_id");
CREATE INDEX "payload_locked_documents_rels_wiki_templates_id_idx" ON "payload_locked_documents_rels" USING btree ("wiki_templates_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_pages" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "wiki_pages_texts" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "wiki_revisions" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "wiki_templates" DISABLE ROW LEVEL SECURITY;
DROP TABLE "wiki_pages" CASCADE;
DROP TABLE "wiki_pages_texts" CASCADE;
DROP TABLE "wiki_revisions" CASCADE;
DROP TABLE "wiki_templates" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_wiki_pages_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_wiki_revisions_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_wiki_templates_fk";
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
DROP TYPE "public"."enum_game_event_logs_target_collection";
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'evaluations', 'tickets', 'game-servers', 'assignment-transfers');
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
DROP INDEX "payload_locked_documents_rels_wiki_pages_id_idx";
DROP INDEX "payload_locked_documents_rels_wiki_revisions_id_idx";
DROP INDEX "payload_locked_documents_rels_wiki_templates_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "wiki_pages_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "wiki_revisions_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "wiki_templates_id";
DROP TYPE "public"."enum_wiki_pages_category";
DROP TYPE "public"."enum_wiki_revisions_type";`)
}

View file

@ -21,6 +21,7 @@ import * as migration_20260903_053419 from './20260903_053419';
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats'; import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts'; import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats'; import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
export const migrations = [ export const migrations = [
{ {
@ -138,4 +139,9 @@ export const migrations = [
down: migration_20260903_062000_add_aim_trainer_stats.down, down: migration_20260903_062000_add_aim_trainer_stats.down,
name: '20260903_062000_add_aim_trainer_stats', name: '20260903_062000_add_aim_trainer_stats',
}, },
{
up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down,
name: '20260904_230838_add_wiki_collections'
},
]; ];

View file

@ -104,6 +104,9 @@ export interface Config {
'market-negotiations': MarketNegotiation; 'market-negotiations': MarketNegotiation;
tickets: Ticket; tickets: Ticket;
'ticket-votes': TicketVote; 'ticket-votes': TicketVote;
'wiki-pages': WikiPage;
'wiki-revisions': WikiRevision;
'wiki-templates': WikiTemplate;
projects: Project; projects: Project;
sprints: Sprint; sprints: Sprint;
releases: Release; releases: Release;
@ -167,6 +170,9 @@ export interface Config {
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>; 'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
tickets: TicketsSelect<false> | TicketsSelect<true>; tickets: TicketsSelect<false> | TicketsSelect<true>;
'ticket-votes': TicketVotesSelect<false> | TicketVotesSelect<true>; 'ticket-votes': TicketVotesSelect<false> | TicketVotesSelect<true>;
'wiki-pages': WikiPagesSelect<false> | WikiPagesSelect<true>;
'wiki-revisions': WikiRevisionsSelect<false> | WikiRevisionsSelect<true>;
'wiki-templates': WikiTemplatesSelect<false> | WikiTemplatesSelect<true>;
projects: ProjectsSelect<false> | ProjectsSelect<true>; projects: ProjectsSelect<false> | ProjectsSelect<true>;
sprints: SprintsSelect<false> | SprintsSelect<true>; sprints: SprintsSelect<false> | SprintsSelect<true>;
releases: ReleasesSelect<false> | ReleasesSelect<true>; releases: ReleasesSelect<false> | ReleasesSelect<true>;
@ -1677,6 +1683,9 @@ export interface GameEventLog {
| 'market-negotiations' | 'market-negotiations'
| 'evaluations' | 'evaluations'
| 'tickets' | 'tickets'
| 'wiki-pages'
| 'wiki-revisions'
| 'wiki-templates'
| 'game-servers' | 'game-servers'
| 'assignment-transfers' | 'assignment-transfers'
) )
@ -3081,6 +3090,62 @@ export interface TicketVote {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "wiki-pages".
*/
export interface WikiPage {
id: number;
title: string;
/**
* URL slug. Generated by the service layer from the title.
*/
slug: string;
category: 'Campaign' | 'World' | 'Lore' | 'Characters' | 'Plot' | 'Media' | 'Guides' | 'Meta';
tags?: string[] | null;
/**
* Markdown source. Rendered by the frontend.
*/
body: string;
lockdown?: boolean | null;
lockedBy?: (number | null) | User;
lockedAt?: string | null;
lastEditor?: (number | null) | User;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "wiki-revisions".
*/
export interface WikiRevision {
id: number;
page: number | WikiPage;
revisionNumber: number;
titleSnapshot: string;
contentSnapshot: string;
editor?: (number | null) | User;
summary?: string | null;
type: 'create' | 'edit' | 'restore';
restoredFromRevision?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "wiki-templates".
*/
export interface WikiTemplate {
id: number;
name: string;
/**
* Snippet text with {{param}}-style placeholders as plain markdown.
*/
body: string;
description?: string | null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "narrative-events". * via the `definition` "narrative-events".
@ -3436,6 +3501,18 @@ export interface PayloadLockedDocument {
relationTo: 'ticket-votes'; relationTo: 'ticket-votes';
value: number | TicketVote; value: number | TicketVote;
} | null) } | null)
| ({
relationTo: 'wiki-pages';
value: number | WikiPage;
} | null)
| ({
relationTo: 'wiki-revisions';
value: number | WikiRevision;
} | null)
| ({
relationTo: 'wiki-templates';
value: number | WikiTemplate;
} | null)
| ({ | ({
relationTo: 'projects'; relationTo: 'projects';
value: number | Project; value: number | Project;
@ -4727,6 +4804,50 @@ export interface TicketVotesSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "wiki-pages_select".
*/
export interface WikiPagesSelect<T extends boolean = true> {
title?: T;
slug?: T;
category?: T;
tags?: T;
body?: T;
lockdown?: T;
lockedBy?: T;
lockedAt?: T;
lastEditor?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "wiki-revisions_select".
*/
export interface WikiRevisionsSelect<T extends boolean = true> {
page?: T;
revisionNumber?: T;
titleSnapshot?: T;
contentSnapshot?: T;
editor?: T;
summary?: T;
type?: T;
restoredFromRevision?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "wiki-templates_select".
*/
export interface WikiTemplatesSelect<T extends boolean = true> {
name?: T;
body?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "projects_select". * via the `definition` "projects_select".

View file

@ -50,6 +50,9 @@ import { UserNotifications } from "@/collections/users/UserNotifications";
import { Evaluations } from "@/collections/users/Evaluations"; import { Evaluations } from "@/collections/users/Evaluations";
import { Tickets } from "@/collections/tickets/Tickets"; import { Tickets } from "@/collections/tickets/Tickets";
import { TicketVotes } from "@/collections/tickets/TicketVotes"; import { TicketVotes } from "@/collections/tickets/TicketVotes";
import { WikiPages } from "@/collections/wiki/WikiPages";
import { WikiRevisions } from "@/collections/wiki/WikiRevisions";
import { WikiTemplates } from "@/collections/wiki/WikiTemplates";
import { Projects } from "@/collections/projects/Projects"; import { Projects } from "@/collections/projects/Projects";
import { Sprints } from "@/collections/projects/Sprints"; import { Sprints } from "@/collections/projects/Sprints";
import { Releases } from "@/collections/projects/Releases"; import { Releases } from "@/collections/projects/Releases";
@ -194,6 +197,11 @@ const collections = [
Tickets, Tickets,
TicketVotes, TicketVotes,
// Wiki
WikiPages,
WikiRevisions,
WikiTemplates,
// Projects // Projects
Projects, Projects,
Sprints, Sprints,
@ -365,6 +373,11 @@ export default buildConfig({
// Helpdesk // Helpdesk
[Tickets.slug]: true, [Tickets.slug]: true,
// Wiki
[WikiPages.slug]: true,
[WikiRevisions.slug]: true,
[WikiTemplates.slug]: true,
// Projects // Projects
[Projects.slug]: true, [Projects.slug]: true,
[Sprints.slug]: true, [Sprints.slug]: true,

View file

@ -30,6 +30,9 @@ interface GameEventInput {
| "market-negotiations" | "market-negotiations"
| "evaluations" | "evaluations"
| "tickets" | "tickets"
| "wiki-pages"
| "wiki-revisions"
| "wiki-templates"
| "game-servers" | "game-servers"
| "assignment-transfers"; | "assignment-transfers";
targetId?: number; targetId?: number;

View file

@ -114,6 +114,14 @@ export const EventTypes = {
TicketVote: "ticket:vote", TicketVote: "ticket:vote",
TicketUnvote: "ticket:unvote", TicketUnvote: "ticket:unvote",
// Wiki
WikiPageCreate: "wiki:page-create",
WikiPageEdit: "wiki:page-edit",
WikiPageRestore: "wiki:page-restore",
WikiPageLock: "wiki:page-lock",
WikiPageUnlock: "wiki:page-unlock",
WikiPageDelete: "wiki:page-delete",
// Projects // Projects
ProjectCreate: "project:create", ProjectCreate: "project:create",
ProjectUpdate: "project:update", ProjectUpdate: "project:update",

363
tests/int/wiki.int.spec.ts Normal file
View file

@ -0,0 +1,363 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { User, WikiPage, WikiRevision, WikiTemplate } from "@/payload-types";
import {
createPage,
deletePage,
listTemplates,
restoreRevision,
setPageLockdown,
templateMap,
updatePage,
} from "@/lib/wiki/service";
import { slugify } from "@/lib/wiki/slugify";
import { expandTemplates } from "@/lib/wiki/templates";
import { preprocessWikilinks } from "@/lib/wiki/wikilinks";
import { prepareMarkdown } from "@/lib/wiki/prepare";
let payload: Payload;
const RUN = `wiki-${Date.now().toString(36)}`;
describe("Wiki", () => {
let user: User;
let userId: number;
const pageIds: number[] = [];
const templateIds: number[] = [];
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-user`,
discordUsername: `${RUN}-user`,
displayName: "WIKI TEST",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userId = user.id;
});
afterAll(async () => {
// Revisions reference the page, so delete them before the page itself.
for (const id of pageIds) {
const revisions = await payload
.find({
collection: "wiki-revisions",
where: { page: { equals: id } },
limit: 500,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const revision of revisions?.docs ?? []) {
await payload
.delete({ collection: "wiki-revisions", id: revision.id, overrideAccess: true })
.catch(() => {});
}
await payload
.delete({ collection: "wiki-pages", id, overrideAccess: true })
.catch(() => {});
}
for (const id of templateIds) {
await payload
.delete({ collection: "wiki-templates", id, overrideAccess: true })
.catch(() => {});
}
// User deletion trips FK constraints unless the hook-provisioned
// personal bank account and profile are removed first.
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await payload
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await payload
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload
.delete({ collection: "users", id: userId, overrideAccess: true })
.catch(() => {});
});
const fetchRevisions = async (pageId: number): Promise<WikiRevision[]> => {
const result = (await payload.find({
collection: "wiki-revisions",
where: { page: { equals: pageId } },
sort: "revisionNumber",
limit: 500,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiRevision[] };
return result.docs;
};
const makePage = async (
title: string,
body = "Body",
category: WikiPage["category"] = "Lore",
tags?: string[],
): Promise<WikiPage> => {
const page = await createPage(payload, user, { title, category, body, tags });
pageIds.push(page.id);
return page;
};
describe("slugify", () => {
it("lowercases, trims, and collapses non-alphanumerics into single hyphens", () => {
expect(slugify("Hello World!")).toBe("hello-world");
expect(slugify(" A--B ")).toBe("a-b");
expect(slugify("Café")).toBe("caf");
expect(slugify("123")).toBe("123");
expect(slugify("")).toBe("page");
expect(slugify("!!!")).toBe("page");
});
});
describe("expandTemplates", () => {
it("replaces a basic template placeholder", () => {
expect(expandTemplates("Hello {{T}}!", { T: "world" })).toBe("Hello world!");
});
it("leaves unknown templates literal", () => {
expect(expandTemplates("{{Nope}}", {})).toBe("{{Nope}}");
});
it("expands two templates on one line", () => {
expect(expandTemplates("{{A}} and {{B}}", { A: "x", B: "y" })).toBe("x and y");
});
it("trims whitespace inside placeholders before matching", () => {
expect(expandTemplates("{{ A }}", { A: "x" })).toBe("x");
});
it("expands 2 levels of nesting and leaves the 3rd level literal", () => {
expect(expandTemplates("{{A}}", { A: "{{B}}", B: "hello" })).toBe("hello");
expect(expandTemplates("{{A}}", { A: "{{B}}", B: "{{C}}", C: "deep" })).toBe("{{C}}");
});
it("terminates cycles leaving the cycle literal", () => {
expect(expandTemplates("{{A}}", { A: "{{A}}" })).toBe("{{A}}");
expect(expandTemplates("{{A}}", { A: "{{B}}", B: "{{A}}" })).toBe("{{A}}");
});
});
describe("preprocessWikilinks", () => {
it("converts a plain wikilink to a markdown link with the slugified target", () => {
expect(preprocessWikilinks("See [[Page Title]]")).toBe(
"See [Page Title](/wiki/page-title)",
);
});
it("uses the alias text as the link label", () => {
expect(preprocessWikilinks("[[Page Title|Custom Text]]")).toBe(
"[Custom Text](/wiki/page-title)",
);
});
it("trims whitespace around the target and alias", () => {
expect(preprocessWikilinks("[[ Page Title | Custom ]]")).toBe(
"[Custom](/wiki/page-title)",
);
});
it("handles multiple links on one line", () => {
expect(preprocessWikilinks("[[Alpha]] and [[Beta|B]]")).toBe(
"[Alpha](/wiki/alpha) and [B](/wiki/beta)",
);
});
});
describe("prepareMarkdown", () => {
it("expands templates first, then wikilinks", () => {
const templates = { T: "See [[Page Title]]" };
expect(prepareMarkdown("{{T}}", templates)).toBe("See [Page Title](/wiki/page-title)");
});
});
describe("service", () => {
it("createPage creates the page and exactly one create revision", async () => {
const title = `${RUN} Create`;
const page = await makePage(title, "Initial body", "World");
expect(page.slug).toBe(slugify(title));
expect(page.category).toBe("World");
expect(page.tags ?? []).toEqual([]);
const revisions = await fetchRevisions(page.id);
expect(revisions).toHaveLength(1);
expect(revisions[0].revisionNumber).toBe(1);
expect(revisions[0].type).toBe("create");
expect(revisions[0].titleSnapshot).toBe(title);
expect(revisions[0].contentSnapshot).toBe("Initial body");
});
it("createPage stores tags", async () => {
const page = await makePage(`${RUN} Tags`, "Body", "Guides", ["alpha", "beta"]);
expect(page.tags).toEqual(["alpha", "beta"]);
});
it("updatePage snapshots the old content as the next revision and updates the page", async () => {
const page = await makePage(`${RUN} Update`, "v1 body", "Lore");
const updated = await updatePage(payload, user, page, {
body: "v2 body",
category: "Guides",
tags: ["x", "y"],
summary: "edit",
});
expect(updated.body).toBe("v2 body");
expect(updated.category).toBe("Guides");
expect(updated.tags).toEqual(["x", "y"]);
expect(updated.lastEditor).toBe(userId);
const revisions = await fetchRevisions(page.id);
expect(revisions).toHaveLength(2);
expect(revisions[1].revisionNumber).toBe(2);
expect(revisions[1].type).toBe("edit");
expect(revisions[1].titleSnapshot).toBe(page.title);
expect(revisions[1].contentSnapshot).toBe("v1 body");
});
it("updatePage rename produces a new unique slug", async () => {
const page = await makePage(`${RUN} Rename`, "body");
const renamed = await updatePage(payload, user, page, { title: `${RUN} Renamed` });
expect(renamed.title).toBe(`${RUN} Renamed`);
expect(renamed.slug).toBe(slugify(`${RUN} Renamed`));
});
it("restoreRevision restores content from the target revision and records a restore revision", async () => {
const page = await makePage(`${RUN} Restore`, "original body");
const edited = await updatePage(payload, user, page, { body: "edited body" });
const restored = await restoreRevision(payload, user, edited, 1);
expect(restored.title).toBe(page.title);
expect(restored.body).toBe("original body");
const revisions = await fetchRevisions(page.id);
expect(revisions).toHaveLength(3);
expect(revisions[2].revisionNumber).toBe(3);
expect(revisions[2].type).toBe("restore");
expect(revisions[2].restoredFromRevision).toBe(1);
expect(revisions[2].titleSnapshot).toBe(page.title);
expect(revisions[2].contentSnapshot).toBe("original body");
});
it("restoreRevision restores the title and re-slugs when the title changed", async () => {
const page = await makePage(`${RUN} RestoreTitle`, "body");
const renamed = await updatePage(payload, user, page, {
title: `${RUN} RestoreTitle Renamed`,
});
expect(renamed.slug).toBe(slugify(`${RUN} RestoreTitle Renamed`));
const restored = await restoreRevision(payload, user, renamed, 1);
expect(restored.title).toBe(`${RUN} RestoreTitle`);
expect(restored.slug).toBe(slugify(`${RUN} RestoreTitle`));
});
it("restoreRevision throws when the revision does not exist", async () => {
const page = await makePage(`${RUN} MissingRev`, "body");
await expect(restoreRevision(payload, user, page, 99)).rejects.toThrow(
"Revision 99 not found for this page.",
);
});
it("creates a unique slug with a -2 suffix for a duplicate title", async () => {
const title = `${RUN} Duplicate`;
const first = await makePage(title, "a");
const second = await makePage(title, "b");
expect(first.slug).toBe(slugify(title));
expect(second.slug).toBe(`${slugify(title)}-2`);
});
it("setPageLockdown blocks edits and unlock allows them again", async () => {
const page = await makePage(`${RUN} Lock`, "body");
const locked = await setPageLockdown(payload, user, page, true);
expect(locked.lockdown).toBe(true);
expect(locked.lockedBy).toBe(userId);
expect(locked.lockedAt).toBeTruthy();
await expect(updatePage(payload, user, locked, { body: "nope" })).rejects.toThrow(
"This page is locked and cannot be edited.",
);
await expect(restoreRevision(payload, user, locked, 1)).rejects.toThrow(
"This page is locked and cannot be edited.",
);
const unlocked = await setPageLockdown(payload, user, locked, false);
expect(unlocked.lockdown).toBe(false);
expect(unlocked.lockedBy).toBeNull();
expect(unlocked.lockedAt).toBeNull();
const updated = await updatePage(payload, user, unlocked, { body: "yes" });
expect(updated.body).toBe("yes");
});
it("deletePage removes the page and all of its revisions", async () => {
const page = await makePage(`${RUN} Delete`, "body");
await updatePage(payload, user, page, { body: "v2" });
await deletePage(payload, user, page);
const pages = (await payload.find({
collection: "wiki-pages",
where: { id: { equals: page.id } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiPage[] };
expect(pages.docs).toHaveLength(0);
expect(await fetchRevisions(page.id)).toHaveLength(0);
});
it("listTemplates returns created templates and templateMap maps name to body", async () => {
const t1 = (await payload.create({
collection: "wiki-templates",
data: { name: `${RUN}-tpl-a`, body: "Template A body" },
overrideAccess: true,
depth: 0,
})) as unknown as WikiTemplate;
templateIds.push(t1.id);
const t2 = (await payload.create({
collection: "wiki-templates",
data: { name: `${RUN}-tpl-b`, body: "Template B body" },
overrideAccess: true,
depth: 0,
})) as unknown as WikiTemplate;
templateIds.push(t2.id);
const templates = await listTemplates(payload);
const map = templateMap(templates);
expect(map[`${RUN}-tpl-a`]).toBe("Template A body");
expect(map[`${RUN}-tpl-b`]).toBe("Template B body");
});
});
});