Compare commits
No commits in common. "93f61d5ef8186dc1e686794fdf9a1cb065681c9a" and "72715c42a42c93ae2b20cd21dc15973799f7bed4" have entirely different histories.
93f61d5ef8
...
72715c42a4
44 changed files with 118 additions and 25495 deletions
33
AGENTS.md
33
AGENTS.md
|
|
@ -1,7 +1,6 @@
|
|||
# AGENTS.md — Polaris Task Force
|
||||
|
||||
> **Sub-AGENTS.md files** (read these for domain-specific context):
|
||||
>
|
||||
> - `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/lib/AGENTS.md` — Shared business logic, domain services, dependency graph
|
||||
|
|
@ -126,7 +125,6 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R
|
|||
### UI
|
||||
|
||||
`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.
|
||||
- Accepts optional `refreshKey` prop — parent passes an incrementing counter to trigger re-fetch after mutations.
|
||||
- Auto-polls every 30s for background updates.
|
||||
|
|
@ -136,14 +134,12 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R
|
|||
- On re-fetch, preserves existing entries while loading to avoid flicker.
|
||||
|
||||
### Adding event logging for new actions
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
### 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.
|
||||
|
||||
## Shipments & game tick
|
||||
|
|
@ -208,11 +204,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`.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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}?".
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
@ -248,28 +244,6 @@ 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).
|
||||
- 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 `` 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 `` — 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
|
||||
|
||||
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.**
|
||||
|
|
@ -335,7 +309,6 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
|||
## Server Actions convention
|
||||
|
||||
Every `actions.ts` file follows the same pattern (10+ files use it):
|
||||
|
||||
1. `"use server"` directive at top
|
||||
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()`
|
||||
|
|
|
|||
91
bun.lock
91
bun.lock
|
|
@ -51,9 +51,6 @@
|
|||
"radix-ui": "^1.6.7",
|
||||
"react": "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",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
|
@ -1030,8 +1027,6 @@
|
|||
|
||||
"@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-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
|
||||
|
|
@ -1156,8 +1151,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
|
@ -1248,8 +1241,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
|
@ -1612,10 +1603,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
|
@ -1628,8 +1615,6 @@
|
|||
|
||||
"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-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
|
@ -1656,8 +1641,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
|
||||
|
|
@ -1714,8 +1697,6 @@
|
|||
|
||||
"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-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
|
@ -1868,8 +1849,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
|
@ -1878,34 +1857,12 @@
|
|||
|
||||
"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-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-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-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-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "4.0.4" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
|
@ -1926,22 +1883,6 @@
|
|||
|
||||
"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-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=="],
|
||||
|
|
@ -2158,8 +2099,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
|
@ -2194,8 +2133,6 @@
|
|||
|
||||
"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-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
|
@ -2226,16 +2163,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
|
@ -2328,8 +2255,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||
|
|
@ -2376,10 +2301,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
|
||||
|
|
@ -2436,10 +2357,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
|
||||
|
|
@ -2486,12 +2403,8 @@
|
|||
|
||||
"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-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-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
|
@ -2528,8 +2441,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
|
@ -2812,8 +2723,6 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
|
|
|||
|
|
@ -3,18 +3,13 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import packageJson from "./package.json" with { type: "json" };
|
||||
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Your Next.js config here
|
||||
env: {
|
||||
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
|
||||
// flat .next/standalone/server.js (Next otherwise walks up looking for
|
||||
// lockfiles and can pick a parent directory — here a stray
|
||||
|
|
|
|||
|
|
@ -75,9 +75,6 @@
|
|||
"radix-ui": "^1.6.7",
|
||||
"react": "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",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
|
|
|||
|
|
@ -500,62 +500,3 @@
|
|||
*::-webkit-scrollbar-corner {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
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'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's edit trail and restore an earlier snapshot when needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RevisionList
|
||||
pageId={page.id}
|
||||
slug={page.slug}
|
||||
revisions={revisions}
|
||||
isModerator={isModerator}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
"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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,9 +25,6 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Market Negotiations", value: "market-negotiations" },
|
||||
{ label: "Evaluations", value: "evaluations" },
|
||||
{ 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: "Assignment Transfers", value: "assignment-transfers" },
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -2,11 +2,22 @@
|
|||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Award,
|
||||
ArrowLeftRight,
|
||||
BookOpen,
|
||||
ArrowLeftRightIcon,
|
||||
Backpack,
|
||||
Bolt,
|
||||
Crosshair,
|
||||
Kanban,
|
||||
LifeBuoy,
|
||||
Search,
|
||||
Send,
|
||||
Shield,
|
||||
ShoppingBag,
|
||||
TrendingUp,
|
||||
UserCog,
|
||||
Users,
|
||||
Warehouse,
|
||||
Maximize,
|
||||
Minimize,
|
||||
SparkleIcon,
|
||||
|
|
@ -28,11 +39,10 @@ import Image from "next/image";
|
|||
import Link from "next/link";
|
||||
import XPDisplay from "@/components/frontend/blocks/XPDisplay";
|
||||
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
|
||||
import type { User } from "@/payload-types";
|
||||
import { Rank, User } from "@/payload-types";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { updatePreferences } from "@/app/(frontend)/account/actions";
|
||||
import { sidebarData } from "@/components/frontend/blocks/sidebarData";
|
||||
|
||||
const data = {
|
||||
navMain: [
|
||||
|
|
@ -51,13 +61,110 @@ const data = {
|
|||
url: "/transfers",
|
||||
icon: ArrowLeftRight,
|
||||
},
|
||||
],
|
||||
navHelpdesk: [
|
||||
{
|
||||
title: "Wiki",
|
||||
url: "/wiki",
|
||||
icon: BookOpen,
|
||||
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,
|
||||
},
|
||||
],
|
||||
...sidebarData,
|
||||
};
|
||||
|
||||
export function AppSidebar({
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function GuideSection({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
readonly children: ReactNode;
|
||||
readonly title: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-foreground">{title}</h3>
|
||||
<div className="flex flex-col gap-1">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Example({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<p>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownGuide() {
|
||||
return (
|
||||
<div className="flex min-h-60 max-h-96 flex-col gap-4 overflow-y-auto rounded-md border bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
<GuideSection title="Basics">
|
||||
<Example>## Heading</Example>
|
||||
<Example>**bold**</Example>
|
||||
<Example>*italic*</Example>
|
||||
<Example>~~strikethrough~~</Example>
|
||||
<Example>- bullet list / 1. numbered list</Example>
|
||||
<Example>[label](url)</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Wikilinks">
|
||||
<Example>[[Page]] / [[Page|label]]</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Code & tables">
|
||||
<Example>`code`</Example>
|
||||
<Example>```ts ... ```</Example>
|
||||
<Example>| Header | Header |</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Images">
|
||||
<Example></Example>
|
||||
<p>
|
||||
Paste any image URL, or use the toolbar's upload button to store an image in media. The
|
||||
caption is the image hover title.
|
||||
</p>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Footnotes & citations">
|
||||
<Example>Text.[^1]</Example>
|
||||
<Example>[^1]: Footnote or citation — source, URL</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Layout">
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted p-3 font-mono text-xs leading-5 text-foreground">
|
||||
<code>{`::::columns
|
||||
:::column
|
||||
Left column content
|
||||
:::
|
||||
|
||||
:::column
|
||||
Right column content
|
||||
:::
|
||||
::::`}</code>
|
||||
</pre>
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted p-3 font-mono text-xs leading-5 text-foreground">
|
||||
<code>{`::::columns
|
||||
:::column
|
||||
Left column content
|
||||
:::
|
||||
|
||||
:::column
|
||||
Center column content
|
||||
:::
|
||||
|
||||
:::column
|
||||
Right column content
|
||||
:::
|
||||
::::`}</code>
|
||||
</pre>
|
||||
<Example>:::note / :::warning</Example>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Templates">
|
||||
<Example>{`{{TemplateName}}`}</Example>
|
||||
<p>Expands in the preview when a matching template exists.</p>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title="Keybinds">
|
||||
<Example>Ctrl/Cmd+B — bold</Example>
|
||||
<Example>Ctrl/Cmd+I — italic</Example>
|
||||
<Example>Ctrl/Cmd+Shift+X — strikethrough</Example>
|
||||
<Example>Ctrl/Cmd+Shift+H — heading</Example>
|
||||
<Example>Ctrl/Cmd+K — open wikilink dialog</Example>
|
||||
<Example>Ctrl/Cmd+E — inline code</Example>
|
||||
</GuideSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { type ChangeEvent, type KeyboardEvent, type RefObject, useRef, useState } from "react";
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Columns2,
|
||||
Columns3,
|
||||
FileCode2,
|
||||
Heading2,
|
||||
Image,
|
||||
ImageUp,
|
||||
Italic,
|
||||
Link2,
|
||||
List,
|
||||
ListOrdered,
|
||||
NotebookPen,
|
||||
Quote,
|
||||
Strikethrough,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { WikiLinkDialog } from "@/components/frontend/wiki/WikiLinkDialog";
|
||||
import { uploadWikiImage } from "@/app/(frontend)/wiki/actions";
|
||||
import {
|
||||
IMAGE_SNIPPET,
|
||||
TABLE_SNIPPET,
|
||||
THREE_COLUMN_SNIPPET,
|
||||
TWO_COLUMN_SNIPPET,
|
||||
createLinePrefixUpdate,
|
||||
createReferenceUpdate,
|
||||
createSnippetUpdate,
|
||||
createWrappedUpdate,
|
||||
readBodySelection,
|
||||
type BodyUpdate,
|
||||
type SnippetInsertion,
|
||||
type WrappedFormat,
|
||||
} from "@/lib/wiki/editorFormatting";
|
||||
import {
|
||||
WikiEditorToolbar,
|
||||
type WikiEditorToolbarAction,
|
||||
} from "@/components/frontend/wiki/WikiEditorToolbar";
|
||||
|
||||
interface WikiEditorBodyProps {
|
||||
readonly body: string;
|
||||
readonly bodyRef: RefObject<HTMLTextAreaElement | null>;
|
||||
readonly existingPages: readonly { title: string }[];
|
||||
readonly linkDialogOpen: boolean;
|
||||
readonly onBodyChange: (value: string) => void;
|
||||
readonly onLinkDialogChange: (open: boolean) => void;
|
||||
readonly pending: boolean;
|
||||
}
|
||||
|
||||
export function WikiEditorBody({
|
||||
body,
|
||||
bodyRef,
|
||||
existingPages,
|
||||
linkDialogOpen,
|
||||
onBodyChange,
|
||||
onLinkDialogChange,
|
||||
pending,
|
||||
}: WikiEditorBodyProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await uploadWikiImage(formData);
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.error ?? "Upload failed.");
|
||||
return;
|
||||
}
|
||||
const { url, alt } = result.data;
|
||||
insertSnippet({ insertion: ``, placeholder: alt });
|
||||
toast.success("Image uploaded.");
|
||||
} catch {
|
||||
toast.error("Upload failed.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyBodyUpdate(update: BodyUpdate) {
|
||||
onBodyChange(update.value);
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = bodyRef.current;
|
||||
textarea?.focus();
|
||||
textarea?.setSelectionRange(update.selectionStart, update.selectionEnd);
|
||||
});
|
||||
}
|
||||
|
||||
function wrapSelection(format: WrappedFormat) {
|
||||
const selection = readBodySelection(body, bodyRef);
|
||||
applyBodyUpdate(createWrappedUpdate(body, selection, format));
|
||||
}
|
||||
|
||||
function prefixLines(prefix: string) {
|
||||
const selection = readBodySelection(body, bodyRef);
|
||||
applyBodyUpdate(createLinePrefixUpdate(body, selection, prefix));
|
||||
}
|
||||
|
||||
function insertSnippet(snippet: SnippetInsertion) {
|
||||
const selection = readBodySelection(body, bodyRef);
|
||||
applyBodyUpdate(createSnippetUpdate(body, selection, snippet));
|
||||
}
|
||||
|
||||
function insertReference(definitionSuffix: string) {
|
||||
const selection = readBodySelection(body, bodyRef);
|
||||
applyBodyUpdate(createReferenceUpdate(body, selection, { definitionSuffix }));
|
||||
}
|
||||
|
||||
function insertWikilink(pageTitle: string) {
|
||||
const selection = readBodySelection(body, bodyRef);
|
||||
applyBodyUpdate(
|
||||
createSnippetUpdate(body, selection, {
|
||||
insertion: `[[${pageTitle}]]`,
|
||||
}),
|
||||
);
|
||||
onLinkDialogChange(false);
|
||||
}
|
||||
|
||||
function handleBodyKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
const modifierPressed = event.metaKey || event.ctrlKey;
|
||||
if (!modifierPressed || event.altKey) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "b" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
wrapSelection({ prefix: "**", suffix: "**", placeholder: "bold text" });
|
||||
return;
|
||||
}
|
||||
if (key === "i" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
wrapSelection({ prefix: "*", suffix: "*", placeholder: "italic text" });
|
||||
return;
|
||||
}
|
||||
if (key === "x" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
wrapSelection({ prefix: "~~", suffix: "~~", placeholder: "strikethrough text" });
|
||||
return;
|
||||
}
|
||||
if (key === "h" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
prefixLines("## ");
|
||||
return;
|
||||
}
|
||||
if (key === "k" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onLinkDialogChange(true);
|
||||
return;
|
||||
}
|
||||
if (key === "e" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" });
|
||||
}
|
||||
}
|
||||
|
||||
const groups: readonly (readonly WikiEditorToolbarAction[])[] = [
|
||||
[
|
||||
{
|
||||
label: "Bold",
|
||||
shortcut: "Ctrl/Cmd+B",
|
||||
icon: Bold,
|
||||
onClick: () => wrapSelection({ prefix: "**", suffix: "**", placeholder: "bold text" }),
|
||||
},
|
||||
{
|
||||
label: "Italic",
|
||||
shortcut: "Ctrl/Cmd+I",
|
||||
icon: Italic,
|
||||
onClick: () => wrapSelection({ prefix: "*", suffix: "*", placeholder: "italic text" }),
|
||||
},
|
||||
{
|
||||
label: "Strikethrough",
|
||||
shortcut: "Ctrl/Cmd+Shift+X",
|
||||
icon: Strikethrough,
|
||||
onClick: () =>
|
||||
wrapSelection({ prefix: "~~", suffix: "~~", placeholder: "strikethrough text" }),
|
||||
},
|
||||
{
|
||||
label: "Heading",
|
||||
shortcut: "Ctrl/Cmd+Shift+H",
|
||||
icon: Heading2,
|
||||
onClick: () => prefixLines("## "),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: "Inline code",
|
||||
shortcut: "Ctrl/Cmd+E",
|
||||
icon: Code,
|
||||
onClick: () => wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" }),
|
||||
},
|
||||
{
|
||||
label: "Code block",
|
||||
icon: FileCode2,
|
||||
onClick: () =>
|
||||
wrapSelection({ prefix: "```\n", suffix: "\n```", placeholder: "code block" }),
|
||||
},
|
||||
{ label: "Quote", icon: Quote, onClick: () => prefixLines("> ") },
|
||||
],
|
||||
[
|
||||
{ label: "Bulleted list", icon: List, onClick: () => prefixLines("- ") },
|
||||
{ label: "Numbered list", icon: ListOrdered, onClick: () => prefixLines("1. ") },
|
||||
{
|
||||
label: "Table",
|
||||
icon: Table,
|
||||
onClick: () => insertSnippet({ insertion: TABLE_SNIPPET, placeholder: "Header" }),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: "Wikilink",
|
||||
shortcut: "Ctrl/Cmd+K",
|
||||
icon: Link2,
|
||||
onClick: () => onLinkDialogChange(true),
|
||||
},
|
||||
{
|
||||
label: "Captioned image",
|
||||
icon: Image,
|
||||
onClick: () => insertSnippet({ insertion: IMAGE_SNIPPET, placeholder: "Alt text" }),
|
||||
},
|
||||
{
|
||||
label: "Upload image",
|
||||
icon: ImageUp,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
},
|
||||
{ label: "Footnote", icon: NotebookPen, onClick: () => insertReference("") },
|
||||
{
|
||||
label: "Citation",
|
||||
icon: NotebookPen,
|
||||
onClick: () => insertReference('Author, "Title", <URL>'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: "2 columns",
|
||||
icon: Columns2,
|
||||
onClick: () =>
|
||||
insertSnippet({ insertion: TWO_COLUMN_SNIPPET, placeholder: "Left column content" }),
|
||||
},
|
||||
{
|
||||
label: "3 columns",
|
||||
icon: Columns3,
|
||||
onClick: () =>
|
||||
insertSnippet({ insertion: THREE_COLUMN_SNIPPET, placeholder: "Left column content" }),
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor="wiki-body">Body</Label>
|
||||
<WikiLinkDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={onLinkDialogChange}
|
||||
existingPages={existingPages}
|
||||
onSelect={insertWikilink}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<WikiEditorToolbar groups={groups} disabled={pending || uploading} />
|
||||
<Textarea
|
||||
ref={bodyRef}
|
||||
id="wiki-body"
|
||||
rows={18}
|
||||
value={body}
|
||||
onChange={(event) => onBodyChange(event.target.value)}
|
||||
onKeyDown={handleBodyKeyDown}
|
||||
placeholder="# Situation\n\nWrite the field guide entry in Markdown..."
|
||||
disabled={pending}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { BookOpenIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface WikiStandardsDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly onAccept: () => void;
|
||||
}
|
||||
|
||||
export function WikiStandardsDialog({ open, onOpenChange, onAccept }: WikiStandardsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpenIcon className="size-5 text-muted-foreground" />
|
||||
<DialogTitle>Before you write</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="flex flex-col gap-3 pt-2 text-left">
|
||||
<p>
|
||||
Every entry in the field guide is part of the task force's permanent record.
|
||||
Contribute content that is authentic, serious, and relevant to the subject at hand.
|
||||
</p>
|
||||
<p>
|
||||
This is a legitimate wiki — not a place for jokes, memes, or off-topic entries. Write
|
||||
as if the record will still be read long after today.
|
||||
</p>
|
||||
<p>
|
||||
Edits are tracked in the revision history, and moderators can revert or remove pages
|
||||
that don't meet these standards.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onAccept}>
|
||||
I understand
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
export const WIKI_CATEGORIES = [
|
||||
"Campaign",
|
||||
"World",
|
||||
"Lore",
|
||||
"Characters",
|
||||
"Plot",
|
||||
"Media",
|
||||
"Guides",
|
||||
"Meta",
|
||||
] as const;
|
||||
|
||||
export type WikiCategory = (typeof WIKI_CATEGORIES)[number];
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
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 = ``;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
/**
|
||||
* 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));
|
||||
}
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* 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";
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
/**
|
||||
* 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, []);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* 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
|
|
@ -1,113 +0,0 @@
|
|||
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";`)
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ 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_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_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -139,9 +138,4 @@ export const migrations = [
|
|||
down: migration_20260903_062000_add_aim_trainer_stats.down,
|
||||
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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -104,9 +104,6 @@ export interface Config {
|
|||
'market-negotiations': MarketNegotiation;
|
||||
tickets: Ticket;
|
||||
'ticket-votes': TicketVote;
|
||||
'wiki-pages': WikiPage;
|
||||
'wiki-revisions': WikiRevision;
|
||||
'wiki-templates': WikiTemplate;
|
||||
projects: Project;
|
||||
sprints: Sprint;
|
||||
releases: Release;
|
||||
|
|
@ -170,9 +167,6 @@ export interface Config {
|
|||
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
|
||||
tickets: TicketsSelect<false> | TicketsSelect<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>;
|
||||
sprints: SprintsSelect<false> | SprintsSelect<true>;
|
||||
releases: ReleasesSelect<false> | ReleasesSelect<true>;
|
||||
|
|
@ -1683,9 +1677,6 @@ export interface GameEventLog {
|
|||
| 'market-negotiations'
|
||||
| 'evaluations'
|
||||
| 'tickets'
|
||||
| 'wiki-pages'
|
||||
| 'wiki-revisions'
|
||||
| 'wiki-templates'
|
||||
| 'game-servers'
|
||||
| 'assignment-transfers'
|
||||
)
|
||||
|
|
@ -3090,62 +3081,6 @@ export interface TicketVote {
|
|||
updatedAt: 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
|
||||
* via the `definition` "narrative-events".
|
||||
|
|
@ -3501,18 +3436,6 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'ticket-votes';
|
||||
value: number | TicketVote;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'wiki-pages';
|
||||
value: number | WikiPage;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'wiki-revisions';
|
||||
value: number | WikiRevision;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'wiki-templates';
|
||||
value: number | WikiTemplate;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'projects';
|
||||
value: number | Project;
|
||||
|
|
@ -4804,50 +4727,6 @@ export interface TicketVotesSelect<T extends boolean = true> {
|
|||
updatedAt?: 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
|
||||
* via the `definition` "projects_select".
|
||||
|
|
|
|||
|
|
@ -50,9 +50,6 @@ import { UserNotifications } from "@/collections/users/UserNotifications";
|
|||
import { Evaluations } from "@/collections/users/Evaluations";
|
||||
import { Tickets } from "@/collections/tickets/Tickets";
|
||||
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 { Sprints } from "@/collections/projects/Sprints";
|
||||
import { Releases } from "@/collections/projects/Releases";
|
||||
|
|
@ -197,11 +194,6 @@ const collections = [
|
|||
Tickets,
|
||||
TicketVotes,
|
||||
|
||||
// Wiki
|
||||
WikiPages,
|
||||
WikiRevisions,
|
||||
WikiTemplates,
|
||||
|
||||
// Projects
|
||||
Projects,
|
||||
Sprints,
|
||||
|
|
@ -373,11 +365,6 @@ export default buildConfig({
|
|||
// Helpdesk
|
||||
[Tickets.slug]: true,
|
||||
|
||||
// Wiki
|
||||
[WikiPages.slug]: true,
|
||||
[WikiRevisions.slug]: true,
|
||||
[WikiTemplates.slug]: true,
|
||||
|
||||
// Projects
|
||||
[Projects.slug]: true,
|
||||
[Sprints.slug]: true,
|
||||
|
|
|
|||
|
|
@ -30,9 +30,6 @@ interface GameEventInput {
|
|||
| "market-negotiations"
|
||||
| "evaluations"
|
||||
| "tickets"
|
||||
| "wiki-pages"
|
||||
| "wiki-revisions"
|
||||
| "wiki-templates"
|
||||
| "game-servers"
|
||||
| "assignment-transfers";
|
||||
targetId?: number;
|
||||
|
|
|
|||
|
|
@ -114,14 +114,6 @@ export const EventTypes = {
|
|||
TicketVote: "ticket:vote",
|
||||
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
|
||||
ProjectCreate: "project:create",
|
||||
ProjectUpdate: "project:update",
|
||||
|
|
|
|||
|
|
@ -1,363 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue