1
0
Fork 0

Compare commits

...

9 commits

Author SHA1 Message Date
56b8b9d320 feat(profile): add code, mark, and horizontal rule nodes to dossier editors
Register CodeNode/CodeHighlightNode, MarkNode, and HorizontalRuleNode in
the DossierExcerptEditors Lexical config so excerpts can use code blocks
with highlighting, inline marks, and divider rules.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 05:36:55 -04:00
db02be7ce9 fix(profile): handle blank dossier excerpt updates
Accept null content in updatePersonalExcerpt/updateIntelExcerpt and store
null when the submitted excerpt is empty instead of an empty Lexical state,
skipping the max-length check for blank submissions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 05:36:54 -04:00
33174572cc docs: refresh AGENTS.md guides
Update root and per-directory AGENTS.md files to reflect the current
state of the codebase: collection inventory, RBAC registry, base
management, personnel pages, training minigames, Discord bot transfer
and evaluation-reminder flows, banking currency config, migration
wrapper, and Playwright/Vivaldi e2e details.
2026-09-06 05:32:53 -04:00
55a03e8094 feat(wiki): add editor undo/redo, template dialog, highlighting, and formatting
- useEditorHistory: undo/redo stack over the textarea body
- WikiTemplateDialog: insert {{Template}} transclusions from the
  toolbar, with per-template {{param}} placeholder prompts
- WikiEditorHighlightLayer: mirrored overlay highlighting headings,
  directives, and template placeholders over the textarea
- editorHighlight.ts: tokenizer driving the overlay
- formatMarkdown.ts + createFormatUpdate: auto-format the document
- editorFormatting.ts: Tab/Shift+Tab indent/outdent, list
  continuation on Enter, inline bold/italic/code formatting, infobox
  snippet
- WikiEditor: editor and live preview side by side, preview renders
  template CSS/HTML via WikiPreparedBody
2026-09-06 05:32:51 -04:00
51f7c803e0 feat(wiki): add scoped template CSS and developer HTML templates
WikiTemplates gains two fields: an optional css code field injected on
pages that use the template (selectors auto-scoped to a hash-based
.ptf-tpl-* class so templates never collide with each other or the app
shell), and a developer-only html field rendered verbatim (sanitized)
in place of the markdown body when transcluded.

- templateCss.ts scopes/sanitizes CSS, incl. nested at-rules; pages
  and the editor live preview inject scoped styles and drop the Card
  chrome in favor of a full-width article
- templateHtml.ts sanitizes HTML and splits prepared markdown around
  expansion sentinels so raw HTML renders without rehype-raw
- templates.ts adds extractTemplateNames/extractPlaceholders/
  extractDirectiveNames; prepareMarkdown threads an htmlTemplates map
- markdown pipeline gains rehype-highlight for fenced code blocks
- styles.css adds wiki prose typography + infobox demo styling
- docs: demo article and Infobox template authoring guide
2026-09-06 05:32:48 -04:00
60da363a93 feat(profile): add rich-text dossier excerpt editors
Convert the dossier personal excerpt from a plain textarea to Lexical
rich text (matching the intel excerpt) with a 1000-character plain-text
limit, and replace the static read-only cards on the profile page with
inline DossierExcerptEditors: owners edit their personal excerpt,
profiles:intel_excerpt:update holders edit the intelligence record.
Changes are validated server-side (lexicalPlaintextLength) and emit
dossier:personal-excerpt-update / dossier:intel-excerpt-update events.
2026-09-06 05:32:23 -04:00
c37a848d66 refactor(nav): group minigame links into Exercises submenu
Collapse the four training minigame entries (Flight Simulator,
Minefield Clearance, Radio Traffic, Qualification Course) into a
single DropdownMenuSub under the user menu.
2026-09-06 05:32:04 -04:00
fb83250ff5 fix(sidebar): ignore toggle shortcut while typing in inputs
Skip the Cmd/Ctrl+B sidebar toggle when the event originates from a
TEXTAREA, INPUT, or contentEditable element so editor shortcuts work.
2026-09-06 05:32:04 -04:00
49bffc04a5 docs: require named migrations in AGENTS.md
Add a Database section rule to always create migrations with
'payload migrate:create <name>' so migration history stays
auditable (timestamp-only filenames obscure destructive steps
like DROP COLUMN during data-loss review).
2026-09-06 01:28:27 -04:00
46 changed files with 69999 additions and 187 deletions

View file

@ -14,7 +14,7 @@ Next.js 16 + Payload CMS 3.88.0 app for an Arma 3 unit. PostgreSQL database via
## Package manager
**Bun** is the primary package manager (`bun.lock`, `bunfig.toml`). `pnpm-lock.yaml` also exists; use Bun for installs and running scripts.
**Bun** is the primary package manager (`bun.lock`; `bunfig.toml` is empty). `package.json` declares `packageManager: pnpm@9.15.4` and several scripts internally invoke pnpm (`db`, `test`, `test:e2e`) — always invoke them via `bun run <script>`, which still works; just don't edit those scripts to use bun directly without testing. `pnpm-lock.yaml` also exists.
## Essential commands
@ -45,7 +45,7 @@ Known pre-existing errors (not yours, don't widen scope to fix them): `src/colle
## Test details
- **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires a live PostgreSQL database (connection from `.env`). Uses `dotenv/config` via `vitest.setup.ts`.
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright (Chromium only). Auto-starts `bun run dev` via `webServer` config. Currently minimal (homepage smoke test).
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. The `webServer` config auto-starts `pnpm dev` (with `reuseExistingServer: true`). Currently minimal (homepage smoke test).
- Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts`
- Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts`
@ -101,15 +101,21 @@ Break these and you get runtime errors or silently dead code:
Organized by domain under `src/collections/`:
- **users/** — Users (auth, username login), Ranks, Profiles, Awards, Qualifications, Assignments, Experience
- **intelligence/** — Missions, Campaigns, Factions, Technologies
- **logistics/** — Assets, Resources, Vehicles, Structures, Shipments
- **users/** — Users (auth, username login), Ranks, Profiles (incl. minigame stats), Awards, Qualifications, Assignments, AssignmentTransfers, Experience, Evaluations, Roles (dynamic RBAC), UserNotifications
- **intelligence/** — Missions, MissionAttendances, Campaigns, Factions, Technologies
- **logistics/** — Assets, Resources, Vehicles, Structures (with staffing defaults), Shipments
- **banking/** — BankAccounts, BankTransactions, LedgerEntries
- **market/** — MarketListings, MarketNegotiations
- **locker/** — LockerStorages, Loadouts
- **world/** — Maps, NarrativeEvents
- **server/** — MissionFiles, ModLists
- **game/** — GameRules (global), GameStructures, GameHardResources, GameEventLogs, GameVehicles, GameNpcs
- **server/** — MissionFiles, ModLists, GameServers, ArmaCommands, ArmaSyncEvents
- **game/** — GameRules (global, incl. currency display names + staff hiring flags), GameStructures, GameHardResources, GameEventLogs, GameVehicles, GameNpcs, NpcStaffing, LaborClassifications (+ shared `staffingFields.ts` factory)
- **projects/** — Projects, Labels, Releases, Sprints
- **tickets/** — Tickets, TicketVotes
- **wiki/** — WikiPages, WikiRevisions, WikiTemplates
- top-level — `Media.ts` (image uploads, used by wiki), `Shims.ts` (audience-targeted content)
Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). Roles: `guest`, `user`, `admin`, `developer`.
Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). Full RBAC lives in `src/permissions/index.ts` + the `Roles` collection. Roles: `guest`, `user`, `admin`, `developer`.
## Event Log System
@ -120,7 +126,7 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R
- **Emit**: `emitGameEvent(payload, { type, message, actor?, structure?, targetCollection?, targetId?, data? })` in `src/utils/event-log/emit.ts`. Silently catches errors (fire-and-forget). Always sets `system: true`.
- **Types**: `EventTypes` constants in `src/utils/event-log/eventTypes.ts` — add new event categories there without touching the collection. Using these constants gives TypeScript narrowing on the `type` field.
- **Wiring**: Server actions in `actions.ts` emit events after successful mutations. `GameStructures` afterChange hook catches admin-panel storage edits. `Structures` beforeChange hook emits `structure:resize`.
- **targetCollection options**: `game-structures`, `structures`, `resources`, `assets`, `vehicles`, `game-vehicles`, `game-npcs`, `factions`, `missions`, `campaigns`, `users`, `technologies`, `maps`, `shipments`, `bank-accounts`, `bank-transactions`, `ledger-entries`. If a new collection is added that should be a valid target, add an option here.
- **targetCollection options**: `game-structures`, `structures`, `resources`, `assets`, `vehicles`, `game-vehicles`, `game-npcs`, `npc-staffing`, `factions`, `missions`, `campaigns`, `users`, `technologies`, `maps`, `shipments`, `bank-accounts`, `bank-transactions`, `ledger-entries`, `market-listings`, `market-negotiations`, `locker-storages`, `loadouts`, `evaluations`, `tickets`, `wiki-pages`, `wiki-revisions`, `wiki-templates`, `game-servers`, `assignment-transfers`. If a new collection is added that should be a valid target, add an option here.
- **Narrative events**: admins/developers can create entries manually in the Payload admin panel with `system: false` for GM-written narrative events. These are visually distinct in the UI (amber accent, book icon).
### UI
@ -152,7 +158,8 @@ Shipping simulation (`src/collections/logistics/Shipments.ts`, `src/scripts/`, `
- **Shipment fields**: `origin`/`destination` → `game-structures`, `transportVehicle` → `game-vehicles`, `cargo[]` (relationship to resources/assets/vehicles + amount), `distance`, `fuelCost`/`fuelConsumed`, `status` (`pending`/`dispatched`/`in_transit`/`arrived`/`completed`/`cancelled`/`failed`/`stranded`), `autoReturn` checkbox, `failureReason`.
- **Game tick**: `bun run payload game-tick` — a `bin` registered on `payload.config.ts`, NOT an npm script. It processes active shipments + fuel consumption, then `process.exit(0)`.
- **Bin file logging**: all five bins (`game-tick`, `market-tick`, `mission-tick`, `server-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
- **Bin file logging**: all six bins (`game-tick`, `market-tick`, `mission-tick`, `server-tick`, `base-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
- **Base tick**: `bun run payload base-tick` — bin registered like the others (needs external cron). Runs `processBaseTick` (`src/lib/base/tick.ts`): pays staff salaries from the structure treasury (banking `applyTransaction` type `salary`), charges structure maintenance, flags upkeep shortages (see Base Management below). Emits `staff:salary-paid`/`staff:salary-unpaid`/`structure:maintenance-paid`/`structure:maintenance-unpaid` events. Notifies clients via the same SSE path as the other ticks (`source: "base-tick"`).
- **Mission auto-completion**: `bun run payload mission-tick` — bin registered like the others (needs external cron). Sweeps missions whose status is `Scheduled`/`Active` and whose scheduled date (`classification.startDateTime`) has fully passed — from the start of the following **server-local** day — and sets them to `Completed`, emitting a `mission:auto-complete` event per mission (logic: `src/lib/intelligence/missionLifecycle.ts`, test: `tests/int/mission-lifecycle.int.spec.ts`). Draft statuses (Concept/Planning/Ready) and terminal statuses (Completed/Cancelled) are never touched; idempotent. Notifies clients via the same SSE path as the other ticks.
- **Server presence**: `bun run payload server-tick` — bin registered like the others (needs external cron, every minute). Flips `game-servers` docs that claim `status: "online"` but whose last heartbeat (`lastSeenAt`) is older than 180s to `offline`, including online docs with no `lastSeenAt` at all; emits a `server:offline` event per flipped server (logic: `src/lib/arma-bridge/presence.ts`, test: `tests/int/server-presence.int.spec.ts`). Idempotent. Notifies clients via the same SSE path as the other ticks.
- **Arrival handling** (`src/scripts/processShipmentTick.ts`): destination storage rules are re-checked on arrival; rejected cargo bounces back to origin, shipment goes `failed`, `ShipmentFail` event logged.
@ -174,6 +181,23 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
- Enforced in: `structures/actions.ts` (`addResource`, `transferResource`, `placeResourceOnGrid`), `shipments/actions.ts` (`createShipment` destination check), and `processShipmentTick.ts` on arrival.
- UI: `ManageStorageDialog` caps deposits to `min(mass, allowance)` and filters non-whitelisted items; structure page shows an amber "whitelist mode" banner when `restrictToAllowed` is set.
## Base Management (staffing & upgrades)
Labor/staffing simulation for structures. Collections in `src/collections/game/`: `npc-staffing` (named NPC staff + labor headcount blocks on game-structures), `labor-classifications` (labor trades: name, key, `defaultUnitSalary`, `availableHeadcount`), and a shared `staffingFields.ts` factory (`createStaffingFields`) that adds `maintenanceCost` / `laborRequirements` / `staffPositions` to both GameStructures instances and Structures blueprints ("Staffing Defaults" group). A `GameStructures` beforeChange hook copies blueprint staffing defaults on create.
- **Service lib**: `src/lib/base/` — `staffing.ts` (`hireStaff`, `fireStaff`, `setLaborHeadcount`, `assertStaffHiringEnabled` — enforces position caps, labor-vs-named-staff rules), `upgrade.ts` (`upgradeStructure` — validates + consumes stored materials, swaps structure type via `upgradesInto`), `storage.ts` (`consumeStorage`, `storedAmount`, ...), `staffingDefaults.ts`, `tick.ts` (`processBaseTick`), `types.ts`.
- **Server actions**: `src/app/(frontend)/logistics/base/actions.ts` — `upgradeStructure`, `hireStaff`, `fireStaff`, `setLaborHeadcount` (logistics-qualification gated, canonical ActionResult pattern).
- **Gate**: Game Rules globals `staffHiringEnabled` + `staffHiringDisabledMessage` disable all hiring when off.
- **Base tick**: see `base-tick` bin under Shipments & game tick.
- **UI**: `src/components/frontend/baseManagement/` (`StaffRoster`, `HireStaffDialog`, `EmploymentCard`, `UpgradeCard`) mounted on the structure detail page (`logistics/structures/[id]`).
- **Events**: `staff:hired` / `staff:terminated` / `staff:headcount-set` / `staff:salary-paid` / `staff:salary-unpaid` / `structure:maintenance-paid` / `structure:maintenance-unpaid` / `structure:upgraded` / `structure:upkeep-short`. Event target: `npc-staffing`.
## Personnel (NPC directory)
- `/personnel/contacts` — NPC directory over `game-npcs` (`src/components/frontend/personnel/`: `NpcDirectory`, `NpcCard`); `/personnel/contacts/[id]` — NPC profile (faction, in-game vs generated badges, vendor listings, `NpcProfile`).
- `/personnel/statistics` — theater-wide NPC stats: faction distribution, in-game vs generated counts, vendor stall count, top 5 vendors by active auto listings.
- Sidebar entries live in `src/components/frontend/blocks/sidebarData.ts` (nav data extracted from `AppSidebar`).
## Banking System
`src/collections/banking/` — `bank-accounts`, `bank-transactions`, `ledger-entries`. Per-person money for a future market feature plus unit/faction treasuries. Admin group: **Banking**.
@ -187,7 +211,7 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
- `createAccount(payload, { name, accountType, ownerFactionId?, ownerUserId? })` — creates a zeroed account in the main currency; throws if no main currency is set in Game Rules.
- `ensurePersonalAccount(payload, userId)` — finds-or-creates a personal account for a user (dedup).
- `getMainCurrencyId` / `getMainCurrencyName` — resolve the Game Rules `mainCurrency` → resource.
- `src/lib/banking/format.ts` — `formatAmount`, `currencyLabel`, `formatDate`. Currency labels prefer the resource's `name` (e.g. "Gold") over `codeName` (e.g. "res_gold").
- `src/lib/banking/format.ts` — `formatAmount`, `currencyLabel`, `formatDate`. Takes a `CurrencyConfig` (threaded from Game Rules) — labels prefer the configured display names over the resource's `name` (e.g. "Gold") over `codeName` (e.g. "res_gold"), with pluralization.
- **Server actions**: `src/app/(frontend)/logistics/banking/actions.ts`. `createBankAccount` (regular users may only create their own personal account; treasury/faction require a manager), `ensureMyAccount`, `depositFunds`/`withdrawFunds`/`transferFunds`. Permission model: personal accounts are owner- or manager-only; **treasury/faction accounts are manager-only**. Manager = admin/developer or logistics-qualified (`hasLogisticsQualification`). Emits `finance:deposit` / `finance:withdraw` / `finance:transfer` / `bank:account-create` events.
- **UI**: `src/app/(frontend)/logistics/banking/` (overview + `[id]` detail), components in `src/components/frontend/banking/` (`BankingOverview`, `AccountCard`, `AccountDetail`, `CreateAccountDialog`, `BankTransactionDialog`, `LedgerTable`, `MyWalletCard`). Sidebar entry "Banking" under Logistics.
- **Event targets**: `bank-accounts`, `bank-transactions`, `ledger-entries` added to `GameEventLogs` `TARGET_COLLECTIONS` and `emit.ts` `targetCollection` union. Finance event types in `eventTypes.ts`.
@ -270,6 +294,15 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
- **Database**: migration batch 28 `src/migrations/20260904_230838_add_wiki_collections.ts`: tables `wiki_pages` (+ `_texts`), `wiki_revisions`, `wiki_templates`, plus the three wiki values added to the `game_event_logs` `target_collection` enum. Dev uses `bun run payload migrate` for schema changes (`push: false`).
- **Gotchas**: image uploads go through the `uploadWikiImage` server action in `src/app/(frontend)/wiki/actions.ts` (any logged-in user, image MIME only, 8 MB cap, stored in `media` with `read: () => true` so images are public; the editor toolbar's "Upload image" button inserts `![alt](/api/media/file/<filename>)` — requires `experimental.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`.
## Training Minigames
Four XP-earning minigames, each with a page + server actions + a pure client-safe game-rules lib, recording stats under Profiles `progression.minigames.*`:
- **Qualification Course (aim trainer)** — `/qualification` (`src/app/(frontend)/qualification/`), game rules in `src/lib/aim-trainer.ts` (recruit/veteran/elite difficulties, civilian targets, scoring/accuracy/XP, injected rng). Components: `src/components/frontend/qualification/` (`AimTrainerArena`, `AimTrainerGame`, `AimTrainerCanvas`, `AimTrainerLeaderboard`). Action `submitAimTrainerResult` validates result bounds, updates profile stats, awards XP, emits `minigame:aim-trainer`.
- **Radio Traffic** — `/radio`, sprint + watch modes, procedural message generation (`src/components/frontend/radio/messageGenerator.ts`). Action `submitRadioResult` emits `minigame:radio-traffic`; stats under `progression.minigames.radioTraffic` (incl. `bestSprintTimeMs`).
- **Flight Simulator** — `/flappy` (canvas game + leaderboard). **Minefield Clearance (EOD)** — `/eod`.
- Leaderboards match active players by user id (not display name) — see commit 72715c4 if leaderboard queries look wrong.
## 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.**
@ -297,7 +330,9 @@ Username-based login (no email login). Users log in via Payload admin with `user
PostgreSQL via `@payloadcms/db-postgres`. Schema defined in `src/payload-generated-schema.ts`. Migrations in `src/migrations/` (timestamp-named .ts + .json pairs, registered in `migrations/index.ts`). Drizzle config reads `DATABASE_URI` from env.
In development, `postgresAdapter` uses `push: false` — run `bun run payload migrate` after schema changes to apply them locally.
In development, `postgresAdapter` uses `push: false` — run `bun run payload migrate` after schema changes to apply them locally. The repo also ships a wrapper, `bun run migrate` (`src/scripts/run-migrations.ts`), which adds env selection (dev/stg/prd), `--status`, and a dev-only `--fresh`.
**Always name your migrations**: create them with `bun run payload migrate:create <name>` (snake_case, e.g. `migrate:create add_evaluation_reminders_field`), never bare `migrate:create`. Unnamed migrations get timestamp-only filenames (`20260905_213824.ts`) that say nothing about what they do — several older migrations suffer from this and it makes the migration history and rollback auditing (data-loss review of `DROP COLUMN`/`DROP TABLE` steps) much harder than it needs to be. Named example: `20260906_050900_add_evaluation_reminders_field.ts`.
## Code style

View file

@ -52,6 +52,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "10.1.0",
"rehype-highlight": "^7.0.2",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"sharp": "0.34.5",
@ -1612,8 +1613,12 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"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-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
"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=="],
@ -1622,6 +1627,8 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"highlight.js": ["highlight.js@11.11.2", "", {}, "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "16.13.1" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"hono": ["hono@4.13.4", "", {}, "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ=="],
@ -1858,6 +1865,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lucide-react": ["lucide-react@0.561.0", "", { "peerDependencies": { "react": "19.2.3" } }, "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A=="],
@ -2226,6 +2235,8 @@
"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=="],
"rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="],
"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=="],
@ -2488,6 +2499,8 @@
"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-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
"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=="],

204
docs/wiki-demo-article.md Normal file
View file

@ -0,0 +1,204 @@
# Operation Ashfall: Field Reference
This article exercises every styling component the wiki supports. Use it as a
live demo page, a smoke test after styling changes, or a copy-paste reference
when writing new entries.
## Inline Formatting
Body text supports **bold**, *italic*, ***bold italic***, ~~strikethrough~~,
and `inline code`. You can also link out — internally with wikilinks like
[[Campaigns]], [[Missions|mission index]], and [[Factions|faction dossiers]],
or externally to the [Arma 3 website](https://arma3.com).
There is no explicit underline in Markdown, but combined emphasis and links
cover most editorial needs.
## Lists
### Unordered
- Primary loadout
- Rifle: MX 6.5 mm
- Sidearm: P07 9 mm
- Secondary loadout
- Launcher: PCML
- AT mines: 2x
- Radio: Long-range AN/PRC-155
### Ordered
1. Brief at 0500 in the ops room
2. Gear check at 0530
3. Wheels roll at 0600
1. Alpha team leads the convoy
2. Bravo team trails at 200 m
4. First stop: FOB Reaper
### Task List
- [x] Finalize ORBAT
- [x] Submit logistics request
- [ ] Confirm air support window
- [ ] Debrief after action
## Blockquotes
> "We don't fight for the flag on the map. We fight so the person to your
> left goes home."
> — Colonel Vasquez, briefing before Ashfall
## Code
Inline commands like `hint "Hello world";` sit comfortably in prose. Fenced
blocks get their own container:
```sqf
// Spawn a fireteam at the objective
_fireteam = [getMarkerPos "obj_ashfall", independent, ["B_Soldier_TL_F", "B_Soldier_F", "B_Soldier_AR_F"]] call BIS_fnc_spawnGroup;
```
```bash
bun run payload game-tick
```
## Tables
| Callsign | Element | Vehicle | Grid | Status |
| -------- | ------- | -------------- | -------- | -------- |
| Alpha 1 | Fireteam | M2A4 Slammer | 034120 | Staged |
| Bravo 2 | Fireteam | HEMTT Fuel | 041118 | Staged |
| Reaper 6 | Air | AH-99 Blackfoot | 029133 | On station |
| Dagger 1 | Air | UH-80 Ghost Hawk | 029133 | RTB |
## Headings Hierarchy
### This is an H3
Body under an H3. Use these for sub-sections like insertion plans or CAS
briefs.
#### This is an H4
H4 is the deepest level — use it for granular notes like per-team frequency
assignments.
## Callouts
:::note
Night operations require NVGs distributed at gear check. Ask logistics the
evening before — supply will not break out NVGs after 2100.
:::
:::warning
The minefield north of Grid 038121 is live. Do not cross without EOD
escort. Last sweep found three APERS bouncing Betties at the tree line.
:::
## Infobox
The infobox is a template — insert `{{Infobox}}` near the top of an article
body (after creating the template from `docs/wiki-infobox-template.md`) and
it renders as a floated right-hand info card:
::::infobox
**Operation Ashfall**
![Task force insignia](/images/logo.webp "Unit insignia")
| Operation Summary | |
| ----------------- | ---------- |
| Conflict | Southern Campaign |
| Theater | Altis, northern coast |
| Dates | 12–19 Mar 2035 |
| Outcome | Decisive — airfield secured |
| Strength | 2x fireteams, 2x rotary |
A week-long clearing operation to seize the northern airfield and deny
opposing forces their last coastal staging area.
::::
Paragraphs flow around the card, the way Wikipedia infoboxes sit beside the
lead section. On narrow screens the card drops to full width.
## Columns
::::columns
:::column
**Left column — Ground plan.** Alpha and Bravo advance along the western
tree line, using the low rock walls as cover. Expect contact at the first
stone bridge.
:::
:::column
**Right column — Air plan.** Reaper 6 orbits south at 800 m for overwatch.
Dagger 1 holds at Landing Zone Hawk until the ground element calls the
push.
:::
::::
Two columns is the common case, but three works for briefing triads:
::::columns
:::column
**Signals.** Primary channel 3, alternate channel 5, emergency 9.
:::
:::column
**Medical.** Two combat lifers attached to Alpha, one to Bravo. Aid
station at FOB Reaper.
:::
:::column
**Resupply.** Cache at the shepherd's hut, Grid 036126, marked with a
green panel.
:::
::::
## Images
Captioned images render the caption as a hover title:
![An aerial reconnaissance photo of the objective](/images/logo.webp "AO recon — taken by Reaper 6 at 0642")
## Footnotes
The convoy plan follows the standard three-vehicle formation.[^1] The
alternate route through the wadi was rejected after the 0400 recon
sweep.[^2]
[^1]: See FM 3-21.8, Motorized Movement, Chapter 4.
[^2]: Recon report filed under ASHFALL/RECON/0400.
## Horizontal Rules
Use a rule to separate major sections of long articles.
---
## What This Article Does Not Cover
- Image uploads happen through the editor toolbar (this demo links an
existing file).
- `{{Template}}` placeholders expand at render time, but only if a template
with a matching name exists in the wiki template list — this article
intentionally skips one so you can see the fallback behavior with
`{{NonexistentTemplate}}`.
- HTML is not rendered; raw tags appear as literal text.
## Quick Styling Checklist
| Feature | Where above | Visual check |
| --------------- | ------------------ | ----------------------------------- |
| H1/H2/H3/H4 | Headings sections | Sizes shrink, H2 has underline |
| Inline emphasis | Inline Formatting | Bold/italic/strike/code distinct |
| Wikilinks | Inline Formatting | Primary color, underlined |
| Lists | Lists | Disc bullets, decimal, task boxes |
| Blockquote | Blockquotes | Muted text, left border |
| Code block | Code | Muted box, monospace, scrollable |
| Table | Tables | Bordered cells, muted header row |
| Callouts | Callouts | Blue note / red warning accent bar |
| Columns | Columns | Side-by-side layout |
| Captioned image | Images | Hover shows caption |
| Footnotes | Footnotes | Superscript links, rule, small text |
| Rule | Horizontal Rules | Thin border line |

View file

@ -0,0 +1,92 @@
# Wiki template: Infobox
A Wikipedia-style right-hand info card. Renders as a floated card on the
right of the article with a title, optional image, and a field/value
metadata table. Content wraps around it; on screens under 640px it becomes
a full-width block.
## Creating it
Payload admin panel → **Wiki** → **Wiki Templates** → *Create New*:
- **Name**: `Infobox`
- **Description**: Wikipedia-style info card. Place `{{Infobox}}` near the
top of an article body, then replace the `{{...}}` markers and table rows
with real content.
- **Body**: paste the block below exactly (the `::::` fence lines are part
of it).
- **CSS**: optional. Paste the block below (or write your own).
## Body
```
::::infobox
**{{Title}}**
![{{Image alt}}]({{Image url}} "{{Image caption}}")
| {{Section}} | {{Value}} |
| ----------- | --------- |
| {{Field 1}} | {{Value 1}} |
| {{Field 2}} | {{Value 2}} |
| {{Field 3}} | {{Value 3}} |
| {{Field 4}} | {{Value 4}} |
| {{Field 5}} | {{Value 5}} |
{{One-line summary of the subject.}}
::::
```
## CSS
```css
.infobox {
/* The shell already styles .infobox (float, card look, field table).
Add template-specific tweaks here, e.g.: */
}
.infobox table {
font-size: 0.875rem;
}
```
## The CSS scoping contract
Every template's **CSS** field is auto-scoped at render time. Each rule's
selector gets prefixed with a hash-based scope class derived from the
template **name**, e.g. for a template named `Infobox`:
```css
/* what you write in the admin panel */
.infobox table { font-size: 0.875rem; }
/* what actually ships to the browser */
.ptf-tpl-rpev9a .infobox table { font-size: 0.875rem; }
```
Consequences:
- **No collisions**: two templates defining the same class can never
override each other, and template CSS can never leak into (or override)
the app shell outside wiki article content.
- **Write plain selectors**: do **not** add `.wiki-markdown` or the scope
class yourself — the renderer adds the prefix to every selector,
including inside `@media`/`@supports` blocks.
- **@keyframes/@font-face** are passed through untouched (animation names
are global; prefer unique names).
- **Dynamic HTML**: classes added at runtime by markdown directives (e.g.
`infobox` from `::::infobox`) keep their literal names — the scoping
prefixes selectors, it does not rename classes.
- **Deliberate overrides** of other templates or shell styles belong in the
**Shims** collection, not here.
## Usage notes
- Place `{{Infobox}}` as the first line of the article body so the card
floats beside the lead paragraphs.
- Delete the image line if the article has no image.
- The header row of the table renders as a section band; use it for a
grouping label (e.g. "Service record") or keep it as Field/Value.
- Wikilinks and templates expand inside the card as usual — e.g.
`[[Factions|faction]]` works in a value cell.
- `{{...}}` markers left unedited render as literal text — replace or
delete every marker before publishing.

View file

@ -76,6 +76,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "10.1.0",
"rehype-highlight": "^7.0.2",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"sharp": "0.34.5",

View file

@ -13,6 +13,13 @@ import {
getLeaderAggregate,
upsertEvaluation,
} from "@/lib/evaluations";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { lexicalPlaintextLength } from "@/lib/lexical/text";
import type { LexicalStateValue } from "@/lib/lexical/text";
import {
INTEL_EXCERPT_MAX_LENGTH,
PERSONAL_EXCERPT_MAX_LENGTH,
} from "@/lib/profile/excerpt-limits";
interface ActionResult<T = undefined> {
success: boolean;
@ -150,3 +157,116 @@ export async function getEvaluationSummary(
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
async function findProfileOwner(
payload: Awaited<ReturnType<typeof getPayload>>,
profileId: number,
): Promise<{ ownerId: number } | null> {
const profile = await payload.findByID({
collection: "profiles",
id: profileId,
depth: 0,
select: { user: true },
overrideAccess: true,
});
if (!profile) return null;
const ownerId = typeof profile.user === "object" ? profile.user?.id : profile.user;
if (typeof ownerId !== "number") return null;
return { ownerId };
}
export async function updatePersonalExcerpt(input: {
profileId: number;
content: LexicalStateValue | null;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const profile = await findProfileOwner(payload, input.profileId);
if (!profile) {
return { success: false, error: "Profile not found." };
}
if (profile.ownerId !== user.id) {
return { success: false, error: "You can only edit your own personal excerpt." };
}
const blank = !input.content || lexicalPlaintextLength(input.content) === 0;
if (!blank && lexicalPlaintextLength(input.content) > PERSONAL_EXCERPT_MAX_LENGTH) {
return {
success: false,
error: `Personal excerpt must be ${PERSONAL_EXCERPT_MAX_LENGTH} characters or fewer.`,
};
}
await payload.update({
collection: "profiles",
id: input.profileId,
data: { dossier: { personalExcerpt: blank ? null : input.content } },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.DossierPersonalExcerptUpdate,
message: "A personal excerpt was updated.",
targetCollection: "users",
targetId: profile.ownerId,
data: { profileId: input.profileId },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") throw e;
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
export async function updateIntelExcerpt(input: {
profileId: number;
content: LexicalStateValue | null;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const permitted = await hasPermission(payload, user, "profiles:intel_excerpt:update");
if (!permitted) {
return { success: false, error: "You are not allowed to edit intelligence records." };
}
const profile = await findProfileOwner(payload, input.profileId);
if (!profile) {
return { success: false, error: "Profile not found." };
}
const blank = !input.content || lexicalPlaintextLength(input.content) === 0;
if (!blank && lexicalPlaintextLength(input.content) > INTEL_EXCERPT_MAX_LENGTH) {
return {
success: false,
error: `Intelligence record must be ${INTEL_EXCERPT_MAX_LENGTH} characters or fewer.`,
};
}
await payload.update({
collection: "profiles",
id: input.profileId,
data: { dossier: { intelExcerpt: blank ? null : input.content } },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.DossierIntelExcerptUpdate,
message: "An intelligence record was updated.",
targetCollection: "users",
targetId: profile.ownerId,
data: { profileId: input.profileId },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") throw e;
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}

View file

@ -4,11 +4,10 @@ import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { ClientDate } from "@/components/frontend/ClientDate";
import { resolveLevel } from "@/utils/xp/resolveLevel";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { isSuperuser, hasPermission } from "@/utils/access-control/hasPermission";
import { isDirectLeaderOf, isMissionEvaluable } from "@/lib/evaluations";
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
import { getEvaluationSummary } from "./actions";
@ -20,9 +19,7 @@ import {
CrosshairIcon,
FileTextIcon,
HashIcon,
HourglassIcon,
PlaneIcon,
ShieldCheck,
ShieldIcon,
SkullIcon,
SparkleIcon,
@ -43,6 +40,7 @@ import AwardCard from "@/components/frontend/account/AwardCard";
import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton";
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
import type {
EvaluationMissionOption,
@ -206,12 +204,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
viewer && !isProfileOwner
? isDirectLeaderOf(payload, viewer.id, user.id)
: Promise.resolve(false);
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader] =
const canEditIntelPromise: Promise<boolean> = viewer
? hasPermission(payload, viewer, "profiles:intel_excerpt:update")
: Promise.resolve(false);
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader, canEditIntel] =
await Promise.all([
evaluationSummaryPromise,
eligibleMissionsPromise,
privilegedViewerPromise,
directLeaderPromise,
canEditIntelPromise,
]);
const canViewSubordinate =
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
@ -449,38 +451,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
Personnel Dossier
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Item variant="outline">
<ItemHeader>
<ItemTitle>Personal Excerpt</ItemTitle>
</ItemHeader>
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
<p>{profile.dossier?.personalExcerpt || "No personal excerpt on file."}</p>
</ItemContent>
</Item>
<Item variant="outline">
<ItemHeader>
<ItemTitle>Intelligence Record</ItemTitle>
{profile.dossier?.intelExcerpt ? (
<div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300">
<span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" />
</div>
) : (
<div className="inline-flex flex-row items-center gap-1 text-xs opacity-25">
<span>Undergoing review by the Intelligence Division</span>{" "}
<HourglassIcon className="w-4" />
</div>
)}
</ItemHeader>
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
{profile.dossier?.intelExcerpt ? (
<RichText data={profile.dossier.intelExcerpt} />
) : (
<p>No intelligence record on file.</p>
)}
</ItemContent>
</Item>
</div>
<DossierExcerptEditors
profileId={profile.id}
personalExcerpt={profile.dossier?.personalExcerpt ?? null}
intelExcerpt={profile.dossier?.intelExcerpt ?? null}
canEditPersonal={isProfileOwner}
canEditIntel={canEditIntel}
/>
</section>
{/* ── Qualifications ─────────────────────────────────── */}

View file

@ -559,3 +559,280 @@
border-left-color: var(--color-destructive);
background: color-mix(in oklch, var(--color-destructive) 8%, transparent);
}
/* Wiki prose-article typography (scoped to .wiki-markdown) */
.wiki-markdown {
font-size: 0.9375rem;
line-height: 1.75;
color: var(--color-foreground);
}
.wiki-markdown > :first-child {
margin-top: 0;
}
.wiki-markdown h1,
.wiki-markdown h2,
.wiki-markdown h3,
.wiki-markdown h4 {
font-weight: 600;
line-height: 1.3;
color: var(--color-foreground);
}
.wiki-markdown h1 {
font-size: 1.5rem;
margin: 2rem 0 0.75rem;
}
.wiki-markdown h2 {
font-size: 1.25rem;
margin: 1.75rem 0 0.5rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--color-border);
}
.wiki-markdown h3 {
font-size: 1.0625rem;
margin: 1.5rem 0 0.375rem;
}
.wiki-markdown h4 {
font-size: 0.9375rem;
margin: 1.25rem 0 0.25rem;
}
.wiki-markdown p {
margin: 0.75rem 0;
}
.wiki-markdown ul,
.wiki-markdown ol {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.wiki-markdown ul {
list-style: disc;
}
.wiki-markdown ol {
list-style: decimal;
}
.wiki-markdown li + li {
margin-top: 0.25rem;
}
.wiki-markdown li > ul,
.wiki-markdown li > ol {
margin: 0.25rem 0;
}
.wiki-markdown blockquote {
margin: 1rem 0;
border-left: 3px solid var(--color-border);
padding-left: 1rem;
color: var(--color-muted-foreground);
}
.wiki-markdown hr {
margin: 1.75rem 0;
border: 0;
border-top: 1px solid var(--color-border);
}
.wiki-markdown table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
margin: 1rem 0;
}
.wiki-markdown th,
.wiki-markdown td {
border: 1px solid var(--color-border);
padding: 0.5rem 0.75rem;
text-align: left;
}
.wiki-markdown th {
background: var(--color-muted);
font-weight: 600;
}
.wiki-markdown img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
.wiki-markdown a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.wiki-markdown a:hover {
text-decoration-color: var(--color-primary);
}
.wiki-markdown :not(pre) > code {
background: var(--color-muted);
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 0.8125rem;
}
.wiki-markdown pre {
background: var(--color-muted);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.875rem 1rem;
overflow-x: auto;
margin: 1rem 0;
}
.wiki-markdown pre code {
background: transparent;
padding: 0;
font-size: 0.8125rem;
}
/* Wiki infobox — Wikipedia-style right-hand info card (scoped to .wiki-markdown) */
.wiki-markdown .infobox {
float: right;
clear: right;
width: 18rem;
margin: 0.25rem 0 1rem 1.5rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-card);
padding: 0.875rem 1rem;
font-size: 0.8125rem;
line-height: 1.55;
}
.wiki-markdown .infobox > p:first-child {
margin: 0 0 0.5rem;
text-align: center;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-foreground);
}
.wiki-markdown .infobox img {
display: block;
margin: 0.25rem auto 0.75rem;
border-radius: 0.375rem;
}
.wiki-markdown .infobox > div {
border: 0;
border-radius: 0;
overflow: visible;
}
.wiki-markdown .infobox table {
width: 100%;
min-width: 0;
font-size: 0.8125rem;
margin: 0;
}
.wiki-markdown .infobox th {
text-align: center;
}
.wiki-markdown .infobox td {
padding: 0.375rem 0.5rem;
vertical-align: top;
}
.wiki-markdown .infobox td:first-child {
width: 38%;
font-weight: 500;
white-space: nowrap;
}
@media (max-width: 640px) {
.wiki-markdown .infobox {
float: none;
width: 100%;
margin: 1rem 0;
}
}
/* Syntax-highlighted code blocks (rehype-highlight / highlight.js). */
.wiki-markdown pre code {
background: transparent;
padding: 0;
color: var(--muted-foreground);
}
.wiki-markdown pre code.hljs {
padding: 0;
background: transparent;
}
.wiki-markdown .hljs-keyword,
.wiki-markdown .hljs-selector-tag,
.wiki-markdown .hljs-literal,
.wiki-markdown .hljs-section,
.wiki-markdown .hljs-doctag,
.wiki-markdown .hljs-name {
color: #c792ea;
}
.wiki-markdown .hljs-string,
.wiki-markdown .hljs-regexp,
.wiki-markdown .hljs-addition,
.wiki-markdown .hljs-attribute,
.wiki-markdown .hljs-meta .hljs-string {
color: #a5d6a7;
}
.wiki-markdown .hljs-number,
.wiki-markdown .hljs-symbol,
.wiki-markdown .hljs-bullet,
.wiki-markdown .hljs-link,
.wiki-markdown .hljs-selector-attr,
.wiki-markdown .hljs-selector-pseudo {
color: #f78c6c;
}
.wiki-markdown .hljs-comment,
.wiki-markdown .hljs-quote {
color: #64748b;
font-style: italic;
}
.wiki-markdown .hljs-variable,
.wiki-markdown .hljs-template-variable,
.wiki-markdown .hljs-type,
.wiki-markdown .hljs-selector-class,
.wiki-markdown .hljs-selector-id,
.wiki-markdown .hljs-title,
.wiki-markdown .hljs-title.class_,
.wiki-markdown .hljs-title.function_ {
color: #82aaff;
}
.wiki-markdown .hljs-built_in,
.wiki-markdown .hljs-class .hljs-title,
.wiki-markdown .hljs-params {
color: #ffcb6b;
}
.wiki-markdown .hljs-deletion {
color: #ef5350;
}
.wiki-markdown .hljs-emphasis {
font-style: italic;
}
.wiki-markdown .hljs-strong {
font-weight: 600;
}

View file

@ -13,6 +13,26 @@ export const metadata = {
title: "Edit wiki page — Polaris Task Force",
};
function templateCssMap(
templates: readonly { name: string; css?: string | null }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const template of templates) {
if (template.css && template.css.trim().length > 0) map[template.name] = template.css;
}
return map;
}
function templateHtmlMap(
templates: readonly { name: string; html?: string | null }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const template of templates) {
if (template.html && template.html.trim().length > 0) map[template.name] = template.html;
}
return map;
}
export default async function EditWikiPage({
params,
}: {
@ -108,6 +128,8 @@ export default async function EditWikiPage({
body: page.body,
}}
templates={templateMap(templates)}
templateCssMap={templateCssMap(templates)}
templateHtmlMap={templateHtmlMap(templates)}
existingPages={existingPages}
isFirstEdit={!hasEditedRevisions}
/>

View file

@ -13,6 +13,7 @@ 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";
import { buildTemplateStyles } from "@/lib/wiki/templateCss";
export const metadata = {
title: "Wiki page — Polaris Task Force",
@ -101,6 +102,12 @@ export default async function WikiDetailPage({
hasIntelligenceQualification(payload, user),
]);
const templateMapValue = templateMap(templates);
const templateHtmlMap: Record<string, string> = {};
for (const template of templates) {
if (template.html && template.html.trim().length > 0) {
templateHtmlMap[template.name] = template.html;
}
}
if (revisionNumber !== null) {
const revisionResult = await payload.find({
@ -139,10 +146,15 @@ export default async function WikiDetailPage({
);
}
const prepared = prepareMarkdown(revision.contentSnapshot, templateMapValue);
const prepared = prepareMarkdown(revision.contentSnapshot, templateMapValue, templateHtmlMap);
const { css: templateCss, scopeClasses } = buildTemplateStyles(
revision.contentSnapshot,
templates,
);
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex w-full flex-col gap-6 p-5">
{templateCss && <style dangerouslySetInnerHTML={{ __html: templateCss }} />}
<div className="flex flex-col gap-3">
<Link
href="/wiki"
@ -168,20 +180,20 @@ export default async function WikiDetailPage({
</div>
</div>
<Card>
<CardContent className="p-6">
<WikiContent prepared={prepared} />
</CardContent>
</Card>
<article>
<WikiContent prepared={prepared} scopeClasses={scopeClasses} htmlTemplates={templateHtmlMap} />
</article>
</div>
);
}
const prepared = prepareMarkdown(page.body, templateMapValue);
const prepared = prepareMarkdown(page.body, templateMapValue, templateHtmlMap);
const { css: templateCss, scopeClasses } = buildTemplateStyles(page.body, templates);
const lockdown = page.lockdown === true;
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex w-full flex-col gap-6 p-5">
{templateCss && <style dangerouslySetInnerHTML={{ __html: templateCss }} />}
<div className="flex flex-col gap-3">
<Link
href="/wiki"
@ -214,11 +226,9 @@ export default async function WikiDetailPage({
{lockdown && <LockdownBanner />}
<Card>
<CardContent className="p-6">
<WikiContent prepared={prepared} />
</CardContent>
</Card>
<article>
<WikiContent prepared={prepared} scopeClasses={scopeClasses} htmlTemplates={templateHtmlMap} />
</article>
<WikiToolbar
slug={page.slug}

View file

@ -10,6 +10,26 @@ export const metadata = {
title: "New wiki page — Polaris Task Force",
};
function templateCssMap(
templates: readonly { name: string; css?: string | null }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const template of templates) {
if (template.css && template.css.trim().length > 0) map[template.name] = template.css;
}
return map;
}
function templateHtmlMap(
templates: readonly { name: string; html?: string | null }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const template of templates) {
if (template.html && template.html.trim().length > 0) map[template.name] = template.html;
}
return map;
}
export default async function NewWikiPage({
searchParams,
}: {
@ -66,6 +86,8 @@ export default async function NewWikiPage({
mode="create"
initialTitle={initialTitle}
templates={templateMap(templates)}
templateCssMap={templateCssMap(templates)}
templateHtmlMap={templateHtmlMap(templates)}
existingPages={existingPages}
isFirstEdit={!hasEditedRevisions}
/>

View file

@ -23,16 +23,21 @@ bot/
events/
interactionCreate.ts # Routes ptf-att: RSVP button interactions
transferInteractions.ts # Handles transfer decision buttons (approve/deny/appeal)
services/
index.ts # Service registry
missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
notificationBridge.ts # Poll user-notifications → Discord DMs
evaluationReminders.ts # Post-mission evaluation reminder DMs (one-shot marker on Missions)
signup.ts # Signup service logic
transferRequests.ts # Polls assignment transfer requests awaiting leader decision
transferDelivery.ts # Transfer leader-request / rejection / info delivery DMs
lib/
roles.ts # isStaff check
resolve.ts # discordId ↔ Payload user lookups
transferMessaging.ts # Transfer embed builders + delivery keys
```
## Command registration scope
@ -52,14 +57,24 @@ Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility
`notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick). DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`).
## Feature flow: evaluation reminders
Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start), sends a one-shot DM per linked user asking them to rate their leadership (leaders also get a "rate your subordinates" section). Recipient plan: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker `evaluationRemindersSentAt` on Missions (withheld if the 40-DM/tick cap is hit — retries next tick). DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in web UI). Staff can re-send manually via `/remind-evaluations`.
## Feature flow: assignment transfers
`transferRequests` polls `assignment-transfers` for requests awaiting leader decision; leaders approve/deny via buttons handled in `transferInteractions.ts`; DMs are built by `lib/transferMessaging.ts` and sent through `transferDelivery.ts` (leader request, requester rejection notice, decision info). Web side: `src/lib/transfers/` + `/transfers` page.
## Where to look
| Task | Path |
|------|------|
| Add new slash command | `bot/commands/<name>.ts` + register in `bot/commands/index.ts` |
| Add button interaction | `bot/events/interactionCreate.ts` |
| Add button interaction | `bot/events/interactionCreate.ts` (RSVP) or `transferInteractions.ts` (transfers) |
| Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
| Change DM bridging | `bot/services/notificationBridge.ts` |
| Evaluation reminder logic | `bot/services/evaluationReminders.ts` + `src/lib/evaluations/reminders.ts` |
| Transfer workflow | `bot/services/transferRequests.ts` + `lib/transferMessaging.ts` |
| User lookup patterns | `bot/lib/resolve.ts` |
## Anti-patterns

View file

@ -4,29 +4,31 @@
## Overview
36 collections across 12 domain groups. Access control defined in `src/permissions/index.ts` (616 lines, 100+ permissions across 25 groups). RBAC check: `hasPermission(payload, user, "collection:action")`.
54 files (~53 collections) across 15 domain groups. Access control defined in `src/permissions/index.ts` (~870 lines, permission registry across groups). RBAC check: `hasPermission(payload, user, "collection:action")`.
## Structure
```
collections/
Media.ts # Generic media upload
Shims.ts # Global: CSS/JS shims (admin-only)
Media.ts # Image uploads (public read; used by wiki image upload)
Shims.ts # Global: audience-targeted content (admin-only)
users/ 9 files # Users (auth), Ranks, Profiles, Awards,
# Qualifications, Assignments, Experience,
# Roles (dynamic RBAC), UserNotifications
intelligence/ 5 files # Missions, MissionAttendances, Campaigns,
# Factions, Technologies
logistics/ 5 files # Structures, Resources, Assets, Vehicles, Shipments
users/ 11 files # Users (auth), Ranks, Profiles (incl. minigame stats), Awards,
# Qualifications, Assignments, AssignmentTransfers, Experience,
# Evaluations, Roles (dynamic RBAC), UserNotifications
intelligence/ 5 files # Missions, MissionAttendances, Campaigns, Factions, Technologies
logistics/ 5 files # Structures (staffing defaults), Resources, Assets, Vehicles, Shipments
banking/ 3 files # BankAccounts, BankTransactions, LedgerEntries
market/ 2 files # MarketListings, MarketNegotiations
locker/ 2 files # LockerStorages, Loadouts
game/ 6 files # GameRules (global), GameStructures, GameVehicles,
# GameNpcs, GameHardResources, GameEventLogs
server/ 2 files # MissionFiles, ModLists
game/ 9 files # GameRules (global, currency + staffing flags), GameStructures,
# GameVehicles, GameNpcs, GameHardResources, GameEventLogs,
# NpcStaffing, LaborClassifications (+ shared staffingFields.ts factory)
server/ 5 files # MissionFiles, ModLists, GameServers, ArmaCommands, ArmaSyncEvents
world/ 2 files # Maps, NarrativeEvents (React Flow editor)
tickets/ 1 file # Tickets (Lexical rich text)
projects/ 4 files # Projects, Labels, Releases, Sprints (Jira-like)
tickets/ 2 files # Tickets (Lexical rich text), TicketVotes
wiki/ 3 files # WikiPages, WikiRevisions, WikiTemplates
```
## Key relationships
@ -35,11 +37,14 @@ collections/
Users ──┬── Profiles ──┬── Qualifications
│ ├── Awards
│ ├── Assignments ── Ranks
│ ├── AssignmentTransfers
│ └── Experience
├── UserNotifications
├── Evaluations
└── BankAccounts ── BankTransactions ── LedgerEntries
GameStructures ── Structures (template) ── Resources/Assets/Vehicles
GameStructures ── NpcStaffing ── LaborClassifications
GameVehicles ── Vehicles (template)
GameNpcs ── MarketListings ── MarketNegotiations
MissionAttendances ── Missions ── Campaigns
@ -57,8 +62,8 @@ Admin group access: `developer` only for destructive operations, `admin` for rea
## Hooks with side effects
- `Structures` `beforeChange`: emits `structure:resize`
- `GameStructures` `afterChange`: emits storage edit events
- `Structures` `beforeChange`: emits `structure:resize`; blueprint "Staffing Defaults" group via `staffingFields.ts`
- `GameStructures` `afterChange`: emits storage edit events; `beforeChange` copies blueprint staffing defaults on create
- `Users` `afterChange`: maintains profile sync
- `BankTransactions` `beforeValidate`: auto-generates `transactionNumber`
- `MarketNegotiations`: patience meter enforcement

View file

@ -3,6 +3,7 @@ import { Award } from "@/payload-types";
import { broadcastToUser } from "@/lib/realtime/bus";
import { notifyAwardGrants } from "@/lib/awards";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { lexicalPlaintextLength } from "@/lib/lexical/text";
export const Profiles: CollectionConfig = {
slug: "profiles",
@ -73,7 +74,14 @@ export const Profiles: CollectionConfig = {
},
{
name: "personalExcerpt",
type: "textarea",
label: "Personal Excerpt",
type: "richText",
validate: (value: unknown) => {
if (lexicalPlaintextLength(value) > 1000) {
return "Personal excerpt must be 1000 characters or fewer.";
}
return true;
},
},
{
name: "intelExcerpt",

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload";
import hasRoles from "@/utils/access-control/hasRoles";
import { isDeveloper } from "@/utils/access-control/isRole";
export const WikiTemplates: CollectionConfig = {
slug: "wiki-templates",
@ -26,9 +27,10 @@ export const WikiTemplates: CollectionConfig = {
},
{
name: "body",
type: "textarea",
type: "code",
required: true,
admin: {
language: "markdown",
description: "Snippet text with {{param}}-style placeholders as plain markdown.",
},
},
@ -36,5 +38,27 @@ export const WikiTemplates: CollectionConfig = {
name: "description",
type: "textarea",
},
{
name: "css",
type: "code",
admin: {
language: "css",
description:
"Optional CSS injected on wiki pages while this template exists. Write plain selectors — they are automatically prefixed with a hash-based scope class (e.g. .ptf-tpl-3afb1c .my-class) so templates never collide with each other or the app shell.",
},
},
{
name: "html",
type: "textarea",
access: {
read: isDeveloper,
create: isDeveloper,
update: isDeveloper,
},
admin: {
description:
"Developer-only. Raw HTML rendered verbatim (sanitized) when this template is transcluded — replaces the markdown body.",
},
},
],
};

View file

@ -11,20 +11,35 @@
```
components/frontend/
auth/ 2 files LoginForm, LoginLink (returnTo via usePathname)
account/ 4 files Profile, password, preferences, Discord link
account/ 16 files Profile, password, preferences, Discord link
awards/ 4 files Medal/ribbon display, award dialogs
banking/ 9 files Account cards, ledger, deposit/withdraw/transfer dialogs
blocks/ 6 files AppSidebar, NavCore, NavUser, XPDisplay
baseManagement/ 4 files StaffRoster, HireStaffDialog, EmploymentCard, UpgradeCard (structure page)
blocks/ 7 files AppSidebar (data in sidebarData.ts), NavCore, NavUser, XPDisplay
dashboard/ 6 files ProfileSummary, QuickStats, MissionBriefing, RecentEvents
flappy/ 4 files Canvas game, leaderboard, sounds
helpdesk/ 6 files Ticket list/detail, create dialog, timeline
intelligence/ 13 files Mission cards/attendance/comms, campaign/faction cards
locker/ 12 files Grid, equipment editor, loadouts, wardrobe
eod/ 4 files Minefield Clearance minigame
evaluations/ 0 files EMPTY directory (placeholder — no components yet)
flappy/ 4 files Flight Simulator canvas game, leaderboard, sounds
helpdesk/ 8 files Ticket list/detail, create dialog, timeline
impersonation/ 2 files Admin impersonation banner/controls
intelligence/ 17 files Mission cards/attendance/comms, campaign/faction cards
locker/ 13 files Grid, equipment editor, loadouts, wardrobe
logistics/ 8 files Shipment cards/actions, toast notifications
market/ 10 files Listing cards, negotiation flow, NPC chat, patience meter
notifications/ 2 files Bell (polling), inbox page
personnel/ 3 files NpcDirectory, NpcCard, NpcProfile (/personnel/contacts)
profile/ 3 files Profile page components (incl. rating dialog)
projects/ 12 files Jira-like boards, releases, sprints
qualification/ 6 files Aim trainer arena/game/canvas/leaderboard, sounds
radio/ 5 files Radio Traffic game, message generator, leaderboard
realtime/ 1 file GameTickRealtime (SSE -> router.refresh)
roster/ 1 file Org chart view
session/ 1 file Session expiry UI (pairs with useSessionExpired hook)
shims/ 1 file Frontend shim renderer
storage/ 14 files Structure grid, storage dialogs, event ledger
transfers/ 7 files Assignment transfer request/decision UI
wiki/ 11 files Index, editor + toolbar, content renderer, revisions, standards dialog
+ root files: LandingPage.tsx, SiteHeader.tsx, ClientDate.tsx, PayloadButtons.tsx
```
## Conventions
@ -51,11 +66,13 @@ Market negotiation uses `MakeOfferDialog` nested inside `ListingCard` dialog. Lo
| Task | Path |
|------|------|
| Add a new page | `src/app/(frontend)/<domain>/page.tsx` + create client component here |
| Add nav entry | `src/components/frontend/blocks/NavCore.tsx` |
| Add nav entry | `src/components/frontend/blocks/sidebarData.ts` (nav data extracted from `AppSidebar`) |
| Modify auth gate | `src/app/(frontend)/layout.tsx` (conditional renders `LandingPage` or shell) |
| Add SSE consumer | `src/hooks/useGameTick.ts` + mount in layout |
| NPC dialogue/chatter | `src/lib/market/npcDialogue.ts` (pure, client-safe import) |
| Minigame game rules | `src/lib/aim-trainer.ts` etc. — pure libs, components consume them |
| Keyboard shortcuts | `src/components/command-palette/` |
| Admin custom fields | `src/components/admin/` (awards designers, CollapsibleGroupField, narrative-flow, shims) |
## Anti-patterns

View file

@ -7,6 +7,7 @@ import {
ChevronsUpDown,
Crosshair,
FileTextIcon,
Gamepad2,
LogOut,
Radio,
Users2,
@ -23,6 +24,9 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@ -132,30 +136,38 @@ export function NavUser({
Service Record
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/flappy">
<Bird />
Flight Simulator
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/eod">
<Bomb />
Minefield Clearance
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/radio">
<Radio />
Radio Traffic
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/qualification">
<Crosshair />
Qualification Course
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Gamepad2 />
Exercises
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem asChild>
<Link href="/flappy">
<Bird />
Flight Simulator
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/eod">
<Bomb />
Minefield Clearance
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/radio">
<Radio />
Radio Traffic
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/qualification">
<Crosshair />
Qualification Course
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>

View file

@ -0,0 +1,293 @@
"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { HourglassIcon, PencilIcon, Save, ShieldCheck, X } from "lucide-react";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { LexicalComposer } from "@payloadcms/richtext-lexical/lexical/react/LexicalComposer";
import { ContentEditable } from "@payloadcms/richtext-lexical/lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@payloadcms/richtext-lexical/lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalHistoryPlugin";
import { MarkdownShortcutPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalMarkdownShortcutPlugin";
import { OnChangePlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalOnChangePlugin";
import { RichTextPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalRichTextPlugin";
import { HeadingNode, QuoteNode } from "@payloadcms/richtext-lexical/lexical/rich-text";
import { ListItemNode, ListNode } from "@payloadcms/richtext-lexical/lexical/list";
import { AutoLinkNode, LinkNode } from "@payloadcms/richtext-lexical/lexical/link";
import { MarkNode } from "@payloadcms/richtext-lexical/lexical/mark";
import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { HorizontalRuleNode } from "@payloadcms/richtext-lexical/lexical/react/LexicalHorizontalRuleNode";
import { $getRoot } from "@payloadcms/richtext-lexical/lexical";
import type { LexicalEditor } from "@payloadcms/richtext-lexical/lexical";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { lexicalPlaintextLength } from "@/lib/lexical/text";
import type { LexicalStateValue } from "@/lib/lexical/text";
import {
INTEL_EXCERPT_MAX_LENGTH,
PERSONAL_EXCERPT_MAX_LENGTH,
} from "@/lib/profile/excerpt-limits";
import {
updateIntelExcerpt,
updatePersonalExcerpt,
} from "@/app/(frontend)/profile/[username]/actions";
import { cn } from "@/lib/utils";
const EDITOR_NODES = [
HeadingNode,
QuoteNode,
ListNode,
ListItemNode,
LinkNode,
AutoLinkNode,
HorizontalRuleNode,
CodeNode,
CodeHighlightNode,
MarkNode,
];
const EDITOR_CONTENT_CLASSES =
"prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic";
interface ExcerptEditorProps {
value: LexicalStateValue | null;
maxLength: number;
placeholder: string;
onEditChange: (draft: LexicalStateValue | null, length: number) => void;
}
function ExcerptEditor({ value, maxLength, placeholder, onEditChange }: ExcerptEditorProps) {
const [length, setLength] = useState<number>(() => lexicalPlaintextLength(value));
const handleChange = useCallback(
(editorState: ReturnType<LexicalEditor["getEditorState"]>) => {
const serialized: LexicalStateValue = JSON.parse(JSON.stringify(editorState.toJSON()));
setLength(lexicalPlaintextLength(serialized));
onEditChange(serialized, lexicalPlaintextLength(serialized));
},
[onEditChange],
);
return (
<LexicalComposer
initialConfig={{
namespace: `excerpt-editor-${maxLength}`,
nodes: EDITOR_NODES,
editable: true,
editorState: value ? JSON.stringify(value) : undefined,
onError: (error: Error) => {
console.error(error);
},
theme: {
heading: {
h2: "text-lg font-semibold text-foreground mb-2",
h3: "text-base font-semibold text-foreground mb-2",
},
quote: "border-l-2 border-border pl-3 italic text-muted-foreground mb-2",
list: { ul: "list-disc ml-4 mb-2", ol: "list-decimal ml-4 mb-2" },
link: "text-primary underline underline-offset-2",
},
}}
>
<OnChangePlugin onChange={handleChange} ignoreSelectionChange />
<HistoryPlugin />
<MarkdownShortcutPlugin />
<RichTextPlugin
contentEditable={
<div className="relative">
<ContentEditable
className={cn(
"min-h-[140px] w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm text-foreground leading-relaxed outline-none focus-visible:ring-1 focus-visible:ring-ring",
EDITOR_CONTENT_CLASSES,
)}
/>
<p className="absolute bottom-2 right-3 pointer-events-none text-xs text-muted-foreground">
<span className={cn(length > maxLength && "text-red-500 font-medium")}>
{length}
</span>
/{maxLength}
</p>
</div>
}
placeholder={
<div className="pointer-events-none absolute top-2 left-3 text-sm text-muted-foreground">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalComposer>
);
}
interface ExcerptCardProps {
profileId: number;
kind: "personal" | "intel";
title: string;
value: LexicalStateValue | null;
maxLength: number;
canEdit: boolean;
emptyMessage: string;
placeholder: string;
hasIntelBadge?: boolean;
}
function ExcerptCard({
profileId,
kind,
title,
value,
maxLength,
canEdit,
emptyMessage,
placeholder,
hasIntelBadge,
}: ExcerptCardProps) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<LexicalStateValue | null>(null);
const [draftLength, setDraftLength] = useState<number>(() => lexicalPlaintextLength(value));
const [saving, setSaving] = useState(false);
const handleEditChange = useCallback((next: LexicalStateValue | null, length: number) => {
setDraft(next);
setDraftLength(length);
}, []);
const handleEdit = () => {
setDraft(null);
setDraftLength(lexicalPlaintextLength(value));
setEditing(true);
};
const handleCancel = () => {
setDraft(null);
setEditing(false);
};
const handleSave = async () => {
const content = draft ?? value;
if (!content) {
handleCancel();
return;
}
if (draftLength > maxLength) return;
setSaving(true);
const result =
kind === "personal"
? await updatePersonalExcerpt({ profileId, content })
: await updateIntelExcerpt({ profileId, content });
setSaving(false);
if (result.success) {
toast.success(kind === "personal" ? "Personal excerpt saved." : "Intelligence record saved.");
setDraft(null);
setEditing(false);
router.refresh();
} else {
toast.error(result.error ?? "Failed to save.");
}
};
const showBadge = kind === "intel" && hasIntelBadge;
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle>{title}</ItemTitle>
{showBadge ? (
<div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300">
<span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" />
</div>
) : kind === "intel" ? (
<div className="inline-flex flex-row items-center gap-1 text-xs opacity-25">
<span>Undergoing review by the Intelligence Division</span>{" "}
<HourglassIcon className="w-4" />
</div>
) : null}
{canEdit && !editing ? (
<Button variant="ghost" size="sm" onClick={handleEdit} className="ml-auto">
<PencilIcon className="size-3.5" />
Edit
</Button>
) : null}
</ItemHeader>
<ItemContent
className={cn(
"flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed",
EDITOR_CONTENT_CLASSES,
)}
>
{editing ? (
<div className="w-full flex flex-col gap-3">
<ExcerptEditor
value={value}
maxLength={maxLength}
placeholder={placeholder}
onEditChange={handleEditChange}
/>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave} disabled={saving || draftLength > maxLength}>
<Save className="size-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" size="sm" onClick={handleCancel} disabled={saving}>
<X className="size-3.5" />
Cancel
</Button>
</div>
</div>
) : value ? (
<RichText data={value} />
) : (
<p>{emptyMessage}</p>
)}
</ItemContent>
</Item>
);
}
interface DossierExcerptEditorsProps {
profileId: number;
personalExcerpt: LexicalStateValue | null;
intelExcerpt: LexicalStateValue | null;
canEditPersonal: boolean;
canEditIntel: boolean;
}
export function DossierExcerptEditors({
profileId,
personalExcerpt,
intelExcerpt,
canEditPersonal,
canEditIntel,
}: DossierExcerptEditorsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ExcerptCard
profileId={profileId}
kind="personal"
title="Personal Excerpt"
value={personalExcerpt}
maxLength={PERSONAL_EXCERPT_MAX_LENGTH}
canEdit={canEditPersonal}
emptyMessage="No personal excerpt on file."
placeholder="Write a short personal introduction..."
/>
<ExcerptCard
profileId={profileId}
kind="intel"
title="Intelligence Record"
value={intelExcerpt}
maxLength={INTEL_EXCERPT_MAX_LENGTH}
canEdit={canEditIntel}
emptyMessage="No intelligence record on file."
placeholder="Draft the intelligence record..."
hasIntelBadge={Boolean(intelExcerpt)}
/>
</div>
);
}

View file

@ -1,20 +1,59 @@
import Markdown from "react-markdown";
import { cn } from "@/lib/utils";
import { splitPreparedHtml } from "@/lib/wiki/templateHtml";
import type { TemplateMap } from "@/lib/wiki/templates";
import {
wikiMarkdownComponents,
wikiRemarkPlugins,
wikiRehypePlugins,
wikiRemarkRehypeOptions,
} from "@/lib/wiki/markdown";
export function WikiContent({ prepared }: { readonly prepared: string }) {
export function WikiPreparedBody({
prepared,
htmlTemplates,
}: {
readonly prepared: string;
readonly htmlTemplates?: TemplateMap;
}) {
const segments = splitPreparedHtml(prepared, htmlTemplates ?? {});
return (
<div className="wiki-markdown flex flex-col gap-4 text-sm">
<Markdown
remarkPlugins={wikiRemarkPlugins}
remarkRehypeOptions={wikiRemarkRehypeOptions}
components={wikiMarkdownComponents}
>
{prepared}
</Markdown>
<>
{segments.map((segment, index) =>
segment.type === "html" ? (
<div
key={segment.name ?? index}
className="wiki-template-html"
dangerouslySetInnerHTML={{ __html: segment.value }}
/>
) : (
<Markdown
key={index}
remarkPlugins={wikiRemarkPlugins}
rehypePlugins={wikiRehypePlugins}
remarkRehypeOptions={wikiRemarkRehypeOptions}
components={wikiMarkdownComponents}
>
{segment.value}
</Markdown>
),
)}
</>
);
}
export function WikiContent({
prepared,
scopeClasses,
htmlTemplates,
}: {
readonly prepared: string;
readonly scopeClasses?: readonly string[];
readonly htmlTemplates?: TemplateMap;
}) {
return (
<div className={cn("wiki-markdown", scopeClasses)}>
<WikiPreparedBody prepared={prepared} htmlTemplates={htmlTemplates} />
</div>
);
}

View file

@ -1,10 +1,10 @@
"use client";
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Markdown from "react-markdown";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -19,14 +19,11 @@ import {
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 { buildTemplateStyles } from "@/lib/wiki/templateCss";
import { MarkdownGuide } from "@/components/frontend/wiki/MarkdownGuide";
import { WikiEditorBody } from "@/components/frontend/wiki/WikiEditorBody";
import { WikiStandardsDialog } from "@/components/frontend/wiki/WikiStandardsDialog";
import { WikiPreparedBody } from "@/components/frontend/wiki/WikiContent";
const STANDARDS_ACCEPTED_KEY = "ptf:wiki-standards-accepted";
@ -43,6 +40,8 @@ interface WikiEditorProps {
readonly initial?: WikiEditorInitial;
readonly initialTitle?: string;
readonly templates: Record<string, string>;
readonly templateCssMap?: Record<string, string>;
readonly templateHtmlMap?: Record<string, string>;
readonly existingPages: readonly { title: string }[];
readonly isFirstEdit?: boolean;
}
@ -67,6 +66,8 @@ export function WikiEditor({
initial,
initialTitle,
templates,
templateCssMap = {},
templateHtmlMap = {},
existingPages,
isFirstEdit = false,
}: WikiEditorProps) {
@ -83,7 +84,19 @@ export function WikiEditor({
if (!isFirstEdit || typeof window === "undefined") return false;
return window.localStorage.getItem(STANDARDS_ACCEPTED_KEY) !== "1";
});
const preparedPreview = prepareMarkdown(body, templates);
const preparedPreview = prepareMarkdown(body, templates, templateHtmlMap);
const { css: previewCss, scopeClasses: previewScopeClasses } = useMemo(
() =>
buildTemplateStyles(
body,
Object.entries(templates).map(([name, templateBody]) => ({
name,
body: templateBody,
css: templateCssMap[name] ?? null,
})),
),
[body, templates, templateCssMap],
);
function handleStandardsAccept() {
window.localStorage.setItem(STANDARDS_ACCEPTED_KEY, "1");
@ -133,6 +146,7 @@ export function WikiEditor({
return (
<form className="flex flex-col gap-6" onSubmit={(event) => void handleSave(event)}>
{previewCss && <style dangerouslySetInnerHTML={{ __html: previewCss }} />}
<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>
@ -190,43 +204,41 @@ export function WikiEditor({
</div>
</div>
<WikiEditorBody
body={body}
bodyRef={bodyRef}
existingPages={existingPages}
linkDialogOpen={linkDialogOpen}
onBodyChange={setBody}
onLinkDialogChange={setLinkDialogOpen}
pending={pending}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="lg:sticky lg:top-5">
<WikiEditorBody
body={body}
bodyRef={bodyRef}
existingPages={existingPages}
linkDialogOpen={linkDialogOpen}
onBodyChange={setBody}
onLinkDialogChange={setLinkDialogOpen}
pending={pending}
templates={templates}
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 lg:sticky lg:top-5">
<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 className={cn("wiki-markdown", previewScopeClasses)}>
<WikiPreparedBody prepared={preparedPreview} htmlTemplates={templateHtmlMap} />
</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-col gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Markdown guide
</h2>
<MarkdownGuide />
</div>
<div className="flex flex-wrap justify-end gap-2">

View file

@ -11,11 +11,14 @@ import {
Image,
ImageUp,
Italic,
LayoutTemplate,
Link2,
List,
ListOrdered,
NotebookPen,
PanelRight,
Quote,
Sparkles,
Strikethrough,
Table,
} from "lucide-react";
@ -23,13 +26,21 @@ import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { WikiLinkDialog } from "@/components/frontend/wiki/WikiLinkDialog";
import { WikiEditorHighlightLayer } from "@/components/frontend/wiki/WikiEditorHighlightLayer";
import { WikiTemplateDialog } from "@/components/frontend/wiki/WikiTemplateDialog";
import { useEditorHistory } from "@/components/frontend/wiki/useEditorHistory";
import { uploadWikiImage } from "@/app/(frontend)/wiki/actions";
import type { TemplateMap } from "@/lib/wiki/templates";
import {
IMAGE_SNIPPET,
INFOBOX_SNIPPET,
TABLE_SNIPPET,
THREE_COLUMN_SNIPPET,
TWO_COLUMN_SNIPPET,
createLinePrefixUpdate,
createListContinueUpdate,
createFormatUpdate,
createIndentUpdate,
createReferenceUpdate,
createSnippetUpdate,
createWrappedUpdate,
@ -51,6 +62,7 @@ interface WikiEditorBodyProps {
readonly onBodyChange: (value: string) => void;
readonly onLinkDialogChange: (open: boolean) => void;
readonly pending: boolean;
readonly templates: TemplateMap;
}
export function WikiEditorBody({
@ -61,9 +73,55 @@ export function WikiEditorBody({
onBodyChange,
onLinkDialogChange,
pending,
templates,
}: WikiEditorBodyProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const { record, undo, redo } = useEditorHistory(body, bodyRef);
const [uploading, setUploading] = useState(false);
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
function syncMirrorScroll(textarea: HTMLTextAreaElement) {
if (mirrorRef.current) mirrorRef.current.scrollTop = textarea.scrollTop;
}
function scrollCaretIntoView(caret: number) {
const textarea = bodyRef.current;
const mirror = mirrorRef.current;
if (!textarea || !mirror || caret < 0) return;
const walker = document.createTreeWalker(mirror, NodeFilter.SHOW_TEXT);
let remaining = caret;
let node = walker.nextNode() as Text | null;
let lastNode = node;
while (node && remaining > (node.textContent?.length ?? 0)) {
remaining -= node.textContent?.length ?? 0;
lastNode = node;
node = walker.nextNode() as Text | null;
}
const target = node ?? lastNode;
if (!target) return;
const offset = Math.min(remaining, target.textContent?.length ?? 0);
const range = document.createRange();
try {
range.setStart(target, offset);
} catch {
return;
}
range.collapse(true);
const mirrorRect = mirror.getBoundingClientRect();
const caretTop =
range.getBoundingClientRect().top - mirrorRect.top + mirror.scrollTop;
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 20;
const viewTop = textarea.scrollTop;
const viewBottom = viewTop + textarea.clientHeight - lineHeight;
if (caretTop < viewTop || caretTop > viewBottom) {
textarea.scrollTop = Math.max(0, caretTop - textarea.clientHeight / 2);
syncMirrorScroll(textarea);
}
}
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
@ -89,15 +147,29 @@ export function WikiEditorBody({
}
}
function applyBodyUpdate(update: BodyUpdate) {
function applyBodyUpdate(update: BodyUpdate, recordHistory = true) {
if (recordHistory) {
record(update.value, update.selectionStart, update.selectionEnd, false);
}
onBodyChange(update.value);
requestAnimationFrame(() => {
const textarea = bodyRef.current;
textarea?.focus();
textarea?.setSelectionRange(update.selectionStart, update.selectionEnd);
requestAnimationFrame(() => {
const textarea = bodyRef.current;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(update.selectionStart, update.selectionEnd);
scrollCaretIntoView(update.selectionStart);
syncMirrorScroll(textarea);
});
});
}
function handleBodyChange(event: ChangeEvent<HTMLTextAreaElement>) {
const { value, selectionStart, selectionEnd } = event.target;
record(value, selectionStart, selectionEnd, true);
onBodyChange(value);
}
function wrapSelection(format: WrappedFormat) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createWrappedUpdate(body, selection, format));
@ -128,7 +200,56 @@ export function WikiEditorBody({
onLinkDialogChange(false);
}
function insertTemplateBody(value: string) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(
createSnippetUpdate(body, selection, { insertion: value }),
);
setTemplateDialogOpen(false);
}
function formatBody() {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createFormatUpdate(body, selection));
}
function handleBodyKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.nativeEvent.isComposing) return;
if ((event.metaKey || event.ctrlKey) && !event.altKey) {
const undoKey = event.key.toLowerCase();
if (undoKey === "z") {
event.preventDefault();
const entry = event.shiftKey ? redo() : undo();
if (entry) applyBodyUpdate(entry, false);
return;
}
if (undoKey === "y" && !event.shiftKey) {
event.preventDefault();
const entry = redo();
if (entry) applyBodyUpdate(entry, false);
return;
}
}
if (event.key === "Tab") {
event.preventDefault();
const selection = readBodySelection(body, bodyRef);
const update = createIndentUpdate(body, selection, event.shiftKey);
if (update) applyBodyUpdate(update);
return;
}
if (event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
const selection = readBodySelection(body, bodyRef);
const update = createListContinueUpdate(body, selection);
if (update) {
event.preventDefault();
applyBodyUpdate(update);
}
return;
}
const modifierPressed = event.metaKey || event.ctrlKey;
if (!modifierPressed || event.altKey) return;
@ -153,6 +274,11 @@ export function WikiEditorBody({
prefixLines("## ");
return;
}
if (key === "f" && event.shiftKey) {
event.preventDefault();
formatBody();
return;
}
if (key === "k" && !event.shiftKey) {
event.preventDefault();
onLinkDialogChange(true);
@ -161,6 +287,12 @@ export function WikiEditorBody({
if (key === "e" && !event.shiftKey) {
event.preventDefault();
wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" });
return;
}
const browserComboKeys = new Set(["b", "i", "e", "k", "h", "u"]);
if (browserComboKeys.has(key)) {
event.preventDefault();
}
}
@ -191,6 +323,12 @@ export function WikiEditorBody({
icon: Heading2,
onClick: () => prefixLines("## "),
},
{
label: "Format",
shortcut: "Ctrl/Cmd+Shift+F",
icon: Sparkles,
onClick: formatBody,
},
],
[
{
@ -241,6 +379,11 @@ export function WikiEditorBody({
},
],
[
{
label: "Infobox",
icon: PanelRight,
onClick: () => insertSnippet({ insertion: INFOBOX_SNIPPET, placeholder: "Infobox title" }),
},
{
label: "2 columns",
icon: Columns2,
@ -253,6 +396,11 @@ export function WikiEditorBody({
onClick: () =>
insertSnippet({ insertion: THREE_COLUMN_SNIPPET, placeholder: "Left column content" }),
},
{
label: "Template",
icon: LayoutTemplate,
onClick: () => setTemplateDialogOpen(true),
},
],
];
@ -275,18 +423,28 @@ export function WikiEditorBody({
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="Write the field guide entry in Markdown."
<WikiTemplateDialog
templates={templates}
open={templateDialogOpen}
onOpenChange={setTemplateDialogOpen}
disabled={pending}
className="font-mono text-sm"
onInsert={insertTemplateBody}
/>
<WikiEditorToolbar groups={groups} disabled={pending || uploading} />
<div className="relative min-h-[23.5rem] w-full rounded-md border border-input bg-transparent shadow-sm transition-[box-shadow] focus-within:border-ring focus-within:ring-1 focus-within:ring-ring lg:h-[calc(100vh-10rem)]">
<WikiEditorHighlightLayer body={body} ref={mirrorRef} />
<Textarea
ref={bodyRef}
id="wiki-body"
value={body}
onChange={handleBodyChange}
onScroll={(event) => syncMirrorScroll(event.currentTarget)}
onKeyDown={handleBodyKeyDown}
placeholder="Write the field guide entry in Markdown."
disabled={pending}
className="absolute inset-0 h-full w-full resize-none border-0 bg-transparent font-mono text-sm text-transparent caret-foreground shadow-none selection:bg-primary/30 focus-visible:outline-none focus-visible:ring-0"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,49 @@
"use client";
import { forwardRef, useMemo, type HTMLAttributes, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { highlightMarkdownLines } from "@/lib/wiki/editorHighlight";
interface WikiEditorHighlightLayerProps extends HTMLAttributes<HTMLDivElement> {
readonly body: string;
}
/**
* Colored text layer rendered underneath the editor textarea (which uses
* transparent text). Sizing must mirror the textarea exactly: same padding,
* font, and a transparent border so text starts at the same 1px + padding
* offset inside the shared wrapper's border.
*/
export const WikiEditorHighlightLayer = forwardRef<
HTMLDivElement,
WikiEditorHighlightLayerProps
>(function WikiEditorHighlightLayer({ body, className, ...rest }, ref) {
const lines = useMemo(() => highlightMarkdownLines(body), [body]);
const nodes: ReactNode[] = [];
lines.forEach((segments, lineIndex) => {
if (lineIndex > 0) nodes.push("\n");
segments.forEach((segment, segmentIndex) => {
if (segment.className) {
nodes.push(
<span key={`${lineIndex}-${segmentIndex}`} className={segment.className}>
{segment.text}
</span>,
);
} else {
nodes.push(segment.text);
}
});
});
return (
<div
ref={ref}
aria-hidden="true"
className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)}
{...rest}
>
<div className="whitespace-pre-wrap break-words border border-transparent px-3 py-2 font-mono text-sm text-foreground/90">
{nodes}
</div>
</div>
);
});

View file

@ -0,0 +1,164 @@
"use client";
import { useMemo, useState } from "react";
import { LayoutTemplate } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { extractPlaceholders, type TemplateMap } from "@/lib/wiki/templates";
interface WikiTemplateDialogProps {
readonly templates: TemplateMap;
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
readonly disabled?: boolean;
readonly onInsert: (body: string) => void;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function fillTemplate(
body: string,
values: Readonly<Record<string, string>>,
): string {
let filled = body;
for (const [name, value] of Object.entries(values)) {
if (value.length === 0) continue;
filled = filled.replace(
new RegExp(`\\{\\{\\s*${escapeRegExp(name)}\\s*\\}\\}`, "g"),
value,
);
}
return filled;
}
export function WikiTemplateDialog({
templates,
open,
onOpenChange,
disabled,
onInsert,
}: WikiTemplateDialogProps) {
const [selectedName, setSelectedName] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const names = useMemo(() => Object.keys(templates).sort(), [templates]);
const selectedBody = selectedName !== null ? (templates[selectedName] ?? "") : "";
const placeholders = useMemo(
() => extractPlaceholders(selectedBody),
[selectedBody],
);
const filled = useMemo(
() => fillTemplate(selectedBody, values),
[selectedBody, values],
);
function handleOpenChange(next: boolean) {
if (!next) {
setSelectedName(null);
setValues({});
}
onOpenChange(next);
}
function handleInsert() {
onInsert(filled);
handleOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Insert template</DialogTitle>
<DialogDescription>
Pick a wiki template, fill in its fields, and insert it at the
cursor. Leave a field blank to keep its {"{{marker}}"} in the article.
</DialogDescription>
</DialogHeader>
{names.length === 0 ? (
<p className="text-sm text-muted-foreground">
No templates are available yet. Templates can be created in the
admin panel.
</p>
) : selectedName === null ? (
<div className="flex flex-col gap-1.5">
{names.map((name) => (
<button
key={name}
type="button"
onClick={() => setSelectedName(name)}
className="flex items-start gap-2.5 rounded-md border p-2.5 text-left transition-colors hover:bg-muted/50"
>
<LayoutTemplate className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex flex-col gap-0.5">
<span className="text-sm font-medium">{name}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">
{templates[name]}
</span>
</span>
</button>
))}
</div>
) : (
<div className="flex flex-col gap-4">
<button
type="button"
onClick={() => setSelectedName(null)}
className="self-start text-xs text-muted-foreground underline-offset-2 hover:underline"
>
← Back to all templates
</button>
{placeholders.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{placeholders.map((name) => (
<div key={name} className="flex flex-col gap-1.5">
<Label htmlFor={`template-field-${name}`}>{name}</Label>
<Input
id={`template-field-${name}`}
value={values[name] ?? ""}
onChange={(event) =>
setValues((previous) => ({
...previous,
[name]: event.target.value,
}))
}
/>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
This template has no fields to fill in.
</p>
)}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
Preview
</span>
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">
{filled}
</pre>
</div>
<div className={cn("flex justify-end gap-2")}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleInsert}>Insert</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,91 @@
"use client";
import { type RefObject, useCallback, useEffect, useRef } from "react";
export interface HistoryEntry {
readonly value: string;
readonly selectionStart: number;
readonly selectionEnd: number;
}
const COALESCE_WINDOW_MS = 400;
const COALESCE_MAX_DELTA = 8;
const MAX_HISTORY = 200;
function countLines(value: string): number {
let lines = 1;
for (let i = 0; i < value.length; i += 1) {
if (value.charCodeAt(i) === 10) lines += 1;
}
return lines;
}
export function useEditorHistory(
body: string,
bodyRef: RefObject<HTMLTextAreaElement | null>,
) {
const stackRef = useRef<HistoryEntry[]>([]);
const indexRef = useRef(-1);
const lastRecordAtRef = useRef(0);
if (stackRef.current.length === 0) {
stackRef.current = [{ value: body, selectionStart: 0, selectionEnd: 0 }];
indexRef.current = 0;
}
useEffect(() => {
const top = stackRef.current[indexRef.current];
if (top && top.value === body) return;
stackRef.current = [
{
value: body,
selectionStart: bodyRef.current?.selectionStart ?? 0,
selectionEnd: bodyRef.current?.selectionEnd ?? 0,
},
];
indexRef.current = 0;
lastRecordAtRef.current = 0;
}, [body, bodyRef]);
const record = useCallback(
(value: string, selectionStart: number, selectionEnd: number, coalesce: boolean) => {
const stack = stackRef.current;
const top = stack[indexRef.current];
const now = Date.now();
const canCoalesce =
coalesce &&
top !== undefined &&
now - lastRecordAtRef.current <= COALESCE_WINDOW_MS &&
Math.abs(value.length - top.value.length) <= COALESCE_MAX_DELTA &&
countLines(value) === countLines(top.value);
if (canCoalesce && top) {
stack[indexRef.current] = { value, selectionStart, selectionEnd };
} else {
stack.length = indexRef.current + 1;
stack.push({ value, selectionStart, selectionEnd });
if (stack.length > MAX_HISTORY) stack.shift();
indexRef.current = stack.length - 1;
}
lastRecordAtRef.current = now;
},
[],
);
const undo = useCallback((): HistoryEntry | null => {
if (indexRef.current <= 0) return null;
indexRef.current -= 1;
lastRecordAtRef.current = 0;
return stackRef.current[indexRef.current];
}, []);
const redo = useCallback((): HistoryEntry | null => {
const stack = stackRef.current;
if (indexRef.current >= stack.length - 1) return null;
indexRef.current += 1;
lastRecordAtRef.current = 0;
return stack[indexRef.current];
}, []);
return { record, undo, redo };
}

View file

@ -98,6 +98,13 @@ const SidebarProvider = React.forwardRef<
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target;
if (
target instanceof HTMLElement &&
(target.tagName === "TEXTAREA" || target.tagName === "INPUT" || target.isContentEditable)
) {
return;
}
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();

View file

@ -4,7 +4,7 @@
## Overview
23 files across 8 domain subdirectories. Contains pure business logic and Payload-dependent service modules. Server actions in route directories delegate here; these modules hold the actual domain rules.
63 files across 17 domain subdirectories plus root modules. Contains pure business logic and Payload-dependent service modules. Server actions in route directories delegate here; these modules hold the actual domain rules.
## Structure
@ -15,39 +15,56 @@ lib/
shipping.ts # Fuel cost, transit time, vehicle effective speed
distance.ts # Haversine distance calculation
logistics.ts # Shared logistics helpers
aim-trainer.ts # Pure aim-trainer game rules (difficulties, scoring, XP)
impersonation.ts # Admin impersonation cookie helpers
redaction.ts # Text redaction helper
versionInfo.ts # Build version display
arma-bridge/ # Arma server sync: auth, heartbeat/presence, commands, sync events
attendance/ # Mission attendance service (single write path)
banking/ # Transaction engine, account creation, formatting
locker/ # Grid logic, placement validation, loadouts
awards/ # Award grant eligibility + diff/notify/cleanup
banking/ # Transaction engine, account creation, currency config, formatting
base/ # Staffing (hire/fire/headcount), structure upgrades, storage, base tick
evaluations/ # Evaluation levels + bot reminder plan (reminders.ts)
locker/ # Grid logic, placement validation, loadouts, attachments/skins
logistics/ # Mission lifecycle + mission reminder logic
market/ # Buy/sell, NPC negotiation, dialogue, vendor resolution
notifications/ # User notification helper + muteable types
realtime/ # In-process SSE bus (single-instance only)
projects/ # Project ticket-type metadata (projectMeta.ts)
realtime/ # In-process SSE bus + presence tracking (single-instance only)
session/ # Session token expiry helpers
shims/ # Shim audience/path evaluation (djb2 hash matching)
tickets/ # Ticket vocabulary, Lexical helpers, staff resolution
transfers/ # Assignment transfer workflow (request/decide/appeal) + notification types
wiki/ # Slugify, templates, wikilinks, markdown pipeline, page service
```
## Dependency graph
```
Server actions ──┬── storageRules.ts
├── lib/banking/index.ts ──┬── format.ts
├── lib/banking/index.ts ──┬── format.ts (CurrencyConfig)
│ └── ui.ts
├── lib/market/index.ts ──┬── negotiations.ts
│ ├── npcs.ts
│ ├── npcDialogue.ts (pure, client-safe)
│ └── chatBubbles.ts (pure, client-safe)
├── lib/base/ (staffing.ts, upgrade.ts, tick.ts, storage.ts)
├── lib/wiki/service.ts + prepare.ts (markdown.tsx is pure, client-safe)
├── lib/locker/index.ts ── search.ts
├── lib/tickets/, lib/transfers/, lib/awards/
├── lib/attendance/index.ts
├── lib/notifications/index.ts
└── lib/tickets/
├── lib/evaluations/ (reminders.ts drives the bot's evaluation DMs)
└── lib/notifications/index.ts
game-tick script ── shipping.ts, distance.ts, storageRules.ts
market-tick script ── lib/market/index.ts (autoPrice, autoQuantity, npc vendor logic)
base-tick script ── lib/base/tick.ts (processBaseTick: salaries, maintenance, upkeep)
```
## Pure vs Payload-dependent
- **Pure modules** (no Payload import, safe for client + server): `npcDialogue.ts`, `chatBubbles.ts`, `ticketMeta.ts`, `distance.ts`, `storageRules.ts` (logic only)
- **Pure modules** (no Payload import, safe for client + server): `npcDialogue.ts`, `chatBubbles.ts`, `ticketMeta.ts`, `distance.ts`, `aim-trainer.ts`, `wiki/markdown.tsx`, `wiki/prepare.ts`, `wiki/templates.ts`, `wiki/wikilinks.ts`, `shims/evaluate.ts`, `storageRules.ts` (logic only)
- **Payload-dependent** (import `@payload-config`, server-only): everything else
## Key modules
@ -56,7 +73,10 @@ market-tick script ── lib/market/index.ts (autoPrice, autoQuantity, npc vend
Enforcement order: **prohibited → whitelist → per-item cap**. Functions: `checkStorageDeposit`, `isResourceProhibited`, `isResourceWhitelisted`, `getResourceStorageCap`, `storageViolationMessage`. Used in structure actions, shipment creation, and arrival processing.
### `banking/index.ts`
`applyTransaction()` — single source of truth for balance math. Validates accounts, creates transaction + ledger entries, updates balances. `ensurePersonalAccount()` — dedup find-or-create. `getMainCurrencyId()` — resolves Game Rules main currency.
`applyTransaction()` — single source of truth for balance math. Validates accounts, creates transaction + ledger entries, updates balances. `ensurePersonalAccount()` — dedup find-or-create. `getMainCurrencyId()` — resolves Game Rules main currency. `currencyConfigFromGameRules()` builds the `CurrencyConfig` threaded through banking/market UI.
### `base/`
Staffing/upkeep engine for structures: `staffing.ts` (`hireStaff`/`fireStaff`/`setLaborHeadcount`/`assertStaffHiringEnabled`), `upgrade.ts` (`upgradeStructure` — consumes stored materials, swaps type via `upgradesInto`), `storage.ts` (`consumeStorage`/`storedAmount`), `tick.ts` (`processBaseTick` — salaries, maintenance, upkeep flags).
### `market/index.ts`
`buyListing()` — validates, debits buyer, credits locker, marks sold. Supports partial buys via `quantity` parameter. `creditLockerQuantity()` — merges stackables or fills empty grid spots; throws if no space.
@ -74,6 +94,8 @@ Module-level subscriber Set. SSE endpoint `GET /api/realtime` subscribes; game t
| Add storage rule logic | `storageRules.ts` (pure functions) |
| Modify transaction flow | `lib/banking/index.ts` — `applyTransaction()` |
| Change NPC pricing | `lib/market/negotiations.ts` — NPC_ACCEPT constants |
| Add staffing/upkeep logic | `lib/base/` (hire/fire/upgrade/tick) |
| Modify minigame rules | `aim-trainer.ts` (pure) or the game's components dir |
| Add notification type | `lib/notifications/notificationTypes.ts` |
| Add pure client+server logic | Verify no Payload import; place in appropriate domain dir |

67
src/lib/lexical/text.ts Normal file
View file

@ -0,0 +1,67 @@
/**
* Pure (client-safe) helpers for reading Lexical richText editor states.
*
* No Payload or DOM imports — usable from server actions, collection field
* validators, and client components alike.
*/
interface LexicalNodeLike {
type?: string;
text?: string;
children?: LexicalNodeLike[];
}
interface LexicalStateLike {
root?: LexicalNodeLike;
}
/**
* Structural mirror of Payload's generated richText field data shape.
* Loose on purpose (type/children as any) so a value flows unchanged between
* payload-types reads, payload.update data, and RichText rendering —
* SerializedEditorState itself lacks the index signature Payload's generated
* types carry, so it cannot be used in these positions directly.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface LexicalStateValue {
root: {
type: any;
children: any[];
direction: "ltr" | "rtl" | null;
format: "" | "left" | "start" | "center" | "right" | "end" | "justify";
indent: number;
version: number;
};
[k: string]: unknown;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/**
* Concatenates the text of every text node in a serialized Lexical editor
* state (no separators, so length checks measure actual typed characters).
* Returns "" for null/undefined/malformed values.
*/
export function lexicalToPlaintext(value: unknown): string {
if (!value || typeof value !== "object") return "";
const root = (value as LexicalStateLike).root;
if (!root) return "";
let out = "";
const walk = (node: LexicalNodeLike): void => {
if (node.type === "text" && typeof node.text === "string") {
out += node.text;
}
if (Array.isArray(node.children)) {
node.children.forEach(walk);
}
};
walk(root);
return out;
}
/**
* Character limit for dossier excerpts, measured on plain text content.
*/
export function lexicalPlaintextLength(value: unknown): number {
return lexicalToPlaintext(value).length;
}

View file

@ -0,0 +1,7 @@
/**
* Shared character limits for dossier excerpts (plaintext content, not raw
* JSON). Imported by the server actions, the collection field validator
* wiring, and the client editors.
*/
export const PERSONAL_EXCERPT_MAX_LENGTH = 1000;
export const INTEL_EXCERPT_MAX_LENGTH = 5000;

View file

@ -1,4 +1,5 @@
import type { RefObject } from "react";
import { formatMarkdown } from "@/lib/wiki/formatMarkdown";
export interface BodySelection {
readonly start: number;
@ -57,6 +58,16 @@ export const TABLE_SNIPPET = `| Header | Header |
export const IMAGE_SNIPPET = `![Alt text](https:// "Caption on hover")`;
export const INFOBOX_SNIPPET = `::::infobox
**Infobox title**
| Field | Value |
| ----- | ----- |
| Field 1 | Value 1 |
| Field 2 | Value 2 |
| Field 3 | Value 3 |
::::`;
export function readBodySelection(
body: string,
bodyRef: RefObject<HTMLTextAreaElement | null>,
@ -138,6 +149,111 @@ export function createSnippetUpdate(
};
}
const INDENT_UNIT = " ";
export function createIndentUpdate(
body: string,
selection: BodySelection,
outdent: boolean,
): BodyUpdate | null {
const collapsed = selection.start === selection.end;
if (collapsed || !body.slice(selection.start, selection.end).includes("\n")) {
const lineStart = body.lastIndexOf("\n", selection.start - 1) + 1;
if (outdent) {
const leading = /^[ \t]*/.exec(body.slice(lineStart))?.[0] ?? "";
const remove = leading.startsWith("\t") ? 1 : Math.min(INDENT_UNIT.length, leading.length);
if (remove === 0) return null;
const cursor = Math.max(lineStart, selection.start - remove);
return {
value: `${body.slice(0, lineStart)}${body.slice(lineStart + remove)}`,
selectionStart: cursor,
selectionEnd: cursor,
};
}
const content = body.slice(selection.start, selection.end);
const value = `${body.slice(0, selection.start)}${INDENT_UNIT}${content}${body.slice(selection.end)}`;
const cursor = selection.start + INDENT_UNIT.length;
return { value, selectionStart: cursor, selectionEnd: cursor + content.length };
}
const selectedEnd =
selection.end > selection.start && body[selection.end - 1] === "\n"
? selection.end - 1
: selection.end;
const lineStart = body.lastIndexOf("\n", selection.start - 1) + 1;
const nextLine = body.indexOf("\n", selectedEnd);
const lineEnd = nextLine === -1 ? body.length : nextLine;
const formatted = body
.slice(lineStart, lineEnd)
.split("\n")
.map((line) =>
outdent
? line.startsWith("\t")
? line.slice(1)
: line.replace(/^ {1,2}/, "")
: `${INDENT_UNIT}${line}`,
)
.join("\n");
return {
value: `${body.slice(0, lineStart)}${formatted}${body.slice(lineEnd)}`,
selectionStart: lineStart,
selectionEnd: lineStart + formatted.length,
};
}
const LIST_ITEM_RE =
/^(\s*)(?:([-*+])|(\d+)([.)]))[ \t]+(\[[ xX]\][ \t]+)?(.*)$/;
const FENCE_LINE_RE = /^\s{0,3}(?:```|~~~)/;
export function createListContinueUpdate(
body: string,
selection: BodySelection,
): BodyUpdate | null {
if (selection.start !== selection.end) return null;
const cursor = selection.start;
const lineStart = body.lastIndexOf("\n", cursor - 1) + 1;
let fences = 0;
for (const line of body.slice(0, lineStart).split("\n")) {
if (FENCE_LINE_RE.test(line)) fences += 1;
}
if (fences % 2 === 1) return null;
const beforeCursor = body.slice(lineStart, cursor);
const match = LIST_ITEM_RE.exec(beforeCursor);
if (!match) return null;
const [, indent, bullet, num, delim, task, content] = match;
const lineEndIndex = body.indexOf("\n", cursor);
const hasTextAfterCursor = lineEndIndex !== cursor;
if (!hasTextAfterCursor && content.trim() === "") {
const end = lineEndIndex === -1 ? body.length : lineEndIndex + 1;
return {
value: `${body.slice(0, lineStart)}${body.slice(end)}`,
selectionStart: lineStart,
selectionEnd: lineStart,
};
}
const marker = bullet ?? `${parseInt(num, 10) + 1}${delim}`;
const insertion = `\n${indent}${marker} ${task ? "[ ] " : ""}`;
return {
value: `${body.slice(0, cursor)}${insertion}${body.slice(cursor)}`,
selectionStart: cursor + insertion.length,
selectionEnd: cursor + insertion.length,
};
}
export function createFormatUpdate(body: string, selection: BodySelection): BodyUpdate {
const value = formatMarkdown(body);
const cursor = Math.min(selection.start, value.length);
return { value, selectionStart: cursor, selectionEnd: cursor };
}
export function createReferenceUpdate(
body: string,
selection: BodySelection,

View file

@ -0,0 +1,109 @@
/**
* Lightweight markdown tokenizer for the editor highlight mirror.
*
* Pure module — safe to import client-side. Splits the source into lines and
* returns, per line, segments tagged with a Tailwind class for the mirror
* layer behind the editor textarea. Not a full parser: fence state is tracked
* across lines, everything else is line-local.
*/
export interface HighlightSegment {
readonly text: string;
readonly className?: string;
}
const HL = {
heading: "font-semibold text-sky-200",
bold: "font-semibold text-foreground",
italic: "italic text-foreground",
strike: "line-through text-muted-foreground/70",
code: "rounded bg-muted px-0.5 text-emerald-300",
wikilink: "text-sky-300 underline underline-offset-2",
link: "text-sky-300 underline underline-offset-2",
quote: "italic text-muted-foreground/80",
directive: "text-violet-300",
fence: "text-muted-foreground/60",
codeBlock: "text-emerald-300/50",
listMarker: "text-amber-300",
} as const;
const INLINE_RE =
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*)|((?<![A-Za-z0-9])_[^_\n]+_(?![A-Za-z0-9]))|(\[\[[^\]\n]+\]\])|(\[[^\]\n]*\]\([^)\n]+\))/g;
const INLINE_CLASSES = [
HL.code,
HL.bold,
HL.bold,
HL.strike,
HL.italic,
HL.italic,
HL.wikilink,
HL.link,
] as const;
function tokenizeInline(text: string): HighlightSegment[] {
const segments: HighlightSegment[] = [];
let cursor = 0;
for (const match of text.matchAll(INLINE_RE)) {
const start = match.index ?? 0;
if (start > cursor) segments.push({ text: text.slice(cursor, start) });
const groupIndex =
match.findIndex((capture, index) => index > 0 && capture !== undefined) - 1;
segments.push({ text: match[0], className: INLINE_CLASSES[groupIndex] });
cursor = start + match[0].length;
}
if (cursor < text.length) {
segments.push({ text: text.slice(cursor) });
}
return segments;
}
const FENCE_RE = /^\s{0,3}(```|~~~)/;
const HEADING_RE = /^\s{0,3}#{1,6}\s/;
const QUOTE_RE = /^\s{0,3}>/;
const DIRECTIVE_RE = /^\s{0,3}:::/;
const HRULE_RE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
const LIST_MARKER_RE = /^(\s*)([-*+]|\d+[.)])(\s+)/;
export function highlightMarkdownLines(source: string): HighlightSegment[][] {
const lines: HighlightSegment[][] = [];
let inFence = false;
for (const line of source.split("\n")) {
if (FENCE_RE.test(line)) {
lines.push([{ text: line, className: HL.fence }]);
inFence = !inFence;
continue;
}
if (inFence) {
lines.push([{ text: line, className: HL.codeBlock }]);
continue;
}
if (HEADING_RE.test(line)) {
lines.push([{ text: line, className: HL.heading }]);
continue;
}
if (QUOTE_RE.test(line)) {
lines.push([{ text: line, className: HL.quote }]);
continue;
}
if (DIRECTIVE_RE.test(line)) {
lines.push([{ text: line, className: HL.directive }]);
continue;
}
if (HRULE_RE.test(line)) {
lines.push([{ text: line, className: HL.fence }]);
continue;
}
const listMatch = line.match(LIST_MARKER_RE);
if (listMatch) {
const markerEnd = listMatch[0].length;
lines.push([
{ text: listMatch[0], className: HL.listMarker },
...tokenizeInline(line.slice(markerEnd)),
]);
continue;
}
lines.push(tokenizeInline(line));
}
return lines;
}

View file

@ -0,0 +1,56 @@
const FENCE_LINE_RE = /^\s{0,3}(```|~~~)/;
const HEADING_RE = /^(#{1,6})[ \t]*(?=[^#\s])/;
const BULLET_RE = /^([ \t]*)[*+][ \t]+(?=\S)/;
const QUOTE_RE = /^>[ \t]*(?=[^>\s])/;
const HRULE_RE = /^\s*([-*_])([ \t]*\1){2,}[ \t]*$/;
const BLANK_RE = /^[ \t]*$/;
function trimTrailingWhitespace(line: string): string {
return line.replace(/[ \t]+$/, (ws) => {
if (ws === " ") return ws;
return ws.length > 2 ? " " : "";
});
}
export function formatMarkdown(source: string): string {
const lines = source.replace(/\r\n?/g, "\n").split("\n");
const out: string[] = [];
let fenceMarker: string | null = null;
let previousBlank = true;
for (const line of lines) {
const fenceMatch = FENCE_LINE_RE.exec(line);
if (fenceMarker) {
out.push(line);
if (fenceMatch && fenceMatch[1] === fenceMarker) fenceMarker = null;
continue;
}
if (fenceMatch) {
fenceMarker = fenceMatch[1];
out.push(line);
previousBlank = false;
continue;
}
if (BLANK_RE.test(line)) {
if (!previousBlank && out.length > 0) out.push("");
previousBlank = true;
continue;
}
previousBlank = false;
let formatted = trimTrailingWhitespace(line);
if (!HRULE_RE.test(formatted)) {
formatted = formatted
.replace(HEADING_RE, "$1 ")
.replace(BULLET_RE, "$1- ")
.replace(QUOTE_RE, "> ");
}
out.push(formatted);
}
while (out.length > 0 && out[out.length - 1] === "") out.pop();
return out.length === 0 ? "" : `${out.join("\n")}\n`;
}

View file

@ -2,6 +2,7 @@ import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkDirective from "remark-directive";
import rehypeHighlight from "rehype-highlight";
import type { State } from "mdast-util-to-hast";
import type { ContainerDirective, LeafDirective, TextDirective } from "mdast-util-directive";
import { cn } from "@/lib/utils";
@ -17,6 +18,9 @@ import { Info, TriangleAlert } from "lucide-react";
/** remark plugins: GFM (tables, strikethrough, task lists, footnotes) + directives. */
export const wikiRemarkPlugins = [remarkGfm, remarkDirective];
/** rehype plugins: fenced code block syntax highlighting (hljs classes on <code>). */
export const wikiRehypePlugins = [rehypeHighlight];
type DirectiveNode = ContainerDirective | LeafDirective | TextDirective;
/**

View file

@ -6,6 +6,10 @@
import { expandTemplates, type TemplateMap } from "./templates";
import { preprocessWikilinks } from "./wikilinks";
export function prepareMarkdown(source: string, templates: TemplateMap): string {
return preprocessWikilinks(expandTemplates(source, templates));
export function prepareMarkdown(
source: string,
templates: TemplateMap,
htmlTemplates: TemplateMap = {},
): string {
return preprocessWikilinks(expandTemplates(source, templates, htmlTemplates));
}

136
src/lib/wiki/templateCss.ts Normal file
View file

@ -0,0 +1,136 @@
/**
* Wiki template CSS handling.
*
* Pure module — safe to import client-side. Template CSS is authored with
* plain selectors and is automatically scoped per template: every rule's
* selectors are prefixed with a template-specific hash class (e.g.
* `.ptf-tpl-k3x2f .infobox`) that is only added to the article wrapper on
* pages which actually use that template — either by transcluding `{{Name}}`
* or by rendering a directive (`::::infobox`) that the template's body
* introduces. Template styles therefore cannot reach the app shell or
* collide with other templates.
*/
import { extractDirectiveNames, extractTemplateNames } from "@/lib/wiki/templates";
interface TemplateWithCss {
readonly name: string;
readonly css?: string | null;
readonly body?: string | null;
}
const STYLE_ESCAPE_RE = /<\/?\s*(style|script)/gi;
const COMMENT_RE = /\/\*[\s\S]*?\*\//g;
/** At-rules whose blocks contain nested rules that should be scoped too. */
const NESTED_AT_RULES = new Set(["media", "supports", "layer", "container"]);
/** Strip sequences that would let the CSS break out of (or open tags inside) a <style> element. */
export function sanitizeTemplateCss(css: string): string {
return css.replace(STYLE_ESCAPE_RE, "");
}
/** Deterministic djb2-based scope class for a template name. */
export function hashTemplateScope(name: string): string {
let hash = 5381;
for (let i = 0; i < name.length; i++) {
hash = (((hash << 5) + hash) ^ name.charCodeAt(i)) >>> 0;
}
return `ptf-tpl-${hash.toString(36)}`;
}
/**
* Prefix every rule's selectors with `.${scope} `. At-rules with nested rule
* blocks (media/supports/layer/container) recurse; declarations-only at-rules
* (keyframes, font-face, ...) pass through untouched. Content outside any
* rule block (e.g. an `@import` line) is dropped.
*/
function scopeRules(css: string, scope: string): string {
const rules: string[] = [];
let i = 0;
while (i < css.length) {
while (i < css.length && /\s/.test(css[i])) i++;
if (i >= css.length) break;
const open = css.indexOf("{", i);
if (open === -1) break;
const prelude = css.slice(i, open).trim();
let depth = 1;
let cursor = open + 1;
while (cursor < css.length && depth > 0) {
if (css[cursor] === "{") depth++;
else if (css[cursor] === "}") depth--;
cursor++;
}
const body = css.slice(open + 1, depth === 0 ? cursor - 1 : cursor);
if (prelude.startsWith("@")) {
const at = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
if (NESTED_AT_RULES.has(at)) {
rules.push(`${prelude} { ${scopeRules(body, scope)} }`);
} else {
rules.push(`${prelude} { ${body.trim()} }`);
}
} else {
const scoped = prelude
.split(",")
.map((selector) => selector.trim())
.filter(Boolean)
.map((selector) => `.${scope} ${selector}`)
.join(", ");
rules.push(`${scoped} { ${body.trim().replace(/\s+/g, " ")} }`);
}
i = cursor;
}
return rules.join("\n");
}
/** Scope a template's CSS under a single scope class. */
export function scopeTemplateCss(css: string, scopeClass: string): string {
return scopeRules(css.replace(COMMENT_RE, ""), scopeClass);
}
export interface TemplateStyles {
readonly css: string;
readonly scopeClasses: readonly string[];
}
/**
* Build the stylesheet for one render: only templates actually referenced by
* the page body contribute CSS, each scoped under its own hash class.
*/
export function buildTemplateStyles(
source: string,
templates: readonly TemplateWithCss[],
): TemplateStyles {
const usedNames = extractTemplateNames(source);
const usedDirectives = new Set(extractDirectiveNames(source));
const cssByName = new Map<string, string>();
for (const template of templates) {
if (!template.css || template.css.trim().length === 0) continue;
const ownsUsedDirective =
template.body !== undefined &&
template.body !== null &&
extractDirectiveNames(template.body).some((name) => usedDirectives.has(name));
if (usedNames.includes(template.name) || ownsUsedDirective) {
cssByName.set(template.name, template.css);
}
}
const parts: string[] = [];
const scopeClasses: string[] = [];
for (const template of templates) {
const css = cssByName.get(template.name);
if (!css) continue;
const scopeClass = hashTemplateScope(template.name);
const scoped = scopeTemplateCss(sanitizeTemplateCss(css), scopeClass);
if (scoped.length > 0) {
parts.push(scoped);
scopeClasses.push(scopeClass);
}
}
return { css: parts.join("\n"), scopeClasses };
}

View file

@ -0,0 +1,58 @@
/**
* Developer-authored template HTML handling.
*
* Pure module — safe to import client-side. HTML templates are restricted to
* the developer role at the field level; this module sanitizes them (defense
* in depth) and splits prepared markdown around sentinel markers emitted by
* template expansion so raw HTML can be rendered without rehype-raw.
*/
const HTML_SENTINEL_RE = /PTF-TEMPLATE-HTML:(.+?):END/g;
const HTML_DANGEROUS_TAGS_RE =
/<\s*\/?\s*(script|iframe|object|embed|link|meta|base)\b[\s\S]*?(?:\/>|<\/\s*\1\s*>|$)/gi;
const HTML_EVENT_ATTR_RE = /\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi;
const HTML_JS_URL_RE =
/\s(?:href|src|action|formaction)\s*=\s*("\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]*)/gi;
export function sanitizeTemplateHtml(html: string): string {
return html
.replace(HTML_DANGEROUS_TAGS_RE, "")
.replace(HTML_EVENT_ATTR_RE, "")
.replace(HTML_JS_URL_RE, "");
}
export interface PreparedSegment {
readonly type: "markdown" | "html";
readonly value: string;
readonly name?: string;
}
export function splitPreparedHtml(
prepared: string,
htmlTemplates: Record<string, string>,
): PreparedSegment[] {
if (
Object.keys(htmlTemplates).length === 0 ||
!prepared.includes("PTF-TEMPLATE-HTML:")
) {
return [{ type: "markdown", value: prepared }];
}
const segments: PreparedSegment[] = [];
let last = 0;
for (const match of prepared.matchAll(HTML_SENTINEL_RE)) {
const name = match[1].trim();
const html = htmlTemplates[name];
if (html === undefined) continue;
if (match.index > last) {
segments.push({ type: "markdown", value: prepared.slice(last, match.index) });
}
segments.push({ type: "html", value: sanitizeTemplateHtml(html), name });
last = match.index + match[0].length;
}
if (last < prepared.length) {
segments.push({ type: "markdown", value: prepared.slice(last) });
}
return segments;
}

View file

@ -17,19 +17,53 @@ const PLACEHOLDER_RE = /\{\{\s*([^{}]+?)\s*\}\}/g;
function expandLevel(
text: string,
templates: TemplateMap,
htmlTemplates: TemplateMap,
depth: number,
chain: string[],
): string {
if (depth >= 2) return text;
return text.replace(PLACEHOLDER_RE, (match, rawName: string) => {
const name = rawName.trim();
if (htmlTemplates[name] !== undefined) {
return `PTF-TEMPLATE-HTML:${name}:END`;
}
const body = templates[name];
if (body === undefined) return match;
if (chain.includes(name)) return match;
return expandLevel(body, templates, depth + 1, [...chain, name]);
return expandLevel(body, templates, htmlTemplates, depth + 1, [...chain, name]);
});
}
export function expandTemplates(source: string, templates: TemplateMap): string {
return expandLevel(source, templates, 0, []);
export function expandTemplates(
source: string,
templates: TemplateMap,
htmlTemplates: TemplateMap = {},
): string {
return expandLevel(source, templates, htmlTemplates, 0, []);
}
/** Template names referenced by raw `{{Name}}` placeholders in the source, in first-appearance order, deduplicated. */
export function extractTemplateNames(source: string): string[] {
const names: string[] = [];
for (const match of source.matchAll(PLACEHOLDER_RE)) {
const name = match[1].trim();
if (name.length > 0 && !names.includes(name)) names.push(name);
}
return names;
}
/** `{{Param}}` placeholder names inside a template body, in first-appearance order, deduplicated. */
export function extractPlaceholders(source: string): string[] {
return extractTemplateNames(source);
}
const DIRECTIVE_NAME_RE = /^[ \t]{0,3}:{3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)/gm;
/** Container/leaf directive names opened in the source (`::::infobox` → "infobox"), deduplicated. */
export function extractDirectiveNames(source: string): string[] {
const names: string[] = [];
for (const match of source.matchAll(DIRECTIVE_NAME_RE)) {
if (!names.includes(match[1])) names.push(match[1]);
}
return names;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_templates" ADD COLUMN "css" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_templates" DROP COLUMN "css";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,93 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
import { plaintextToLexical } from '../lib/tickets/lexical'
// dossier.personalExcerpt changed from textarea (varchar) to lexical richText
// (jsonb). Existing plain-text values are wrapped into a minimal Lexical
// document using the same plaintextToLexical helper the app uses elsewhere,
// guaranteeing the produced shape matches what the editor/RichText expect.
// The conversion runs row-by-row in TypeScript instead of SQL to keep the
// logic in one place; profile counts are small (per-unit personnel records).
interface LegacyRow extends Record<string, unknown> {
id: number
dossier_personal_excerpt: string | null
}
interface JsonbRow extends Record<string, unknown> {
id: number
dossier_personal_excerpt: string | null
}
/** Flattens a serialized Lexical state back to the legacy textarea string. */
function lexicalToLegacyText(value: string): string {
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
return value
}
const children =
parsed && typeof parsed === "object"
? ((parsed as { root?: { children?: unknown[] } }).root?.children ?? [])
: []
const paragraphs: string[] = []
for (const para of children) {
if (!para || typeof para !== "object") continue
const inline = (para as { children?: unknown[] }).children ?? []
let text = ""
for (const node of inline) {
if (!node || typeof node !== "object") continue
const type = (node as { type?: string }).type
if (type === "text") text += (node as { text?: string }).text ?? ""
if (type === "linebreak") text += "\n"
}
paragraphs.push(text)
}
return paragraphs.join("\n\n")
}
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(
sql`ALTER TABLE "profiles" ADD COLUMN "dossier_personal_excerpt_new" jsonb;`,
)
const result = await db.execute<LegacyRow>(
sql`SELECT "id", "dossier_personal_excerpt" FROM "profiles" WHERE "dossier_personal_excerpt" IS NOT NULL`,
)
const rows = result.rows ?? []
for (const row of rows) {
const lexical = plaintextToLexical(row.dossier_personal_excerpt ?? "")
await db.execute(
sql`UPDATE "profiles" SET "dossier_personal_excerpt_new" = ${JSON.stringify(lexical)}::jsonb WHERE "id" = ${row.id}`,
)
}
await db.execute(sql`ALTER TABLE "profiles" DROP COLUMN "dossier_personal_excerpt";`)
await db.execute(
sql`ALTER TABLE "profiles" RENAME COLUMN "dossier_personal_excerpt_new" TO "dossier_personal_excerpt";`,
)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(
sql`ALTER TABLE "profiles" ADD COLUMN "dossier_personal_excerpt_old" varchar;`,
)
const result = await db.execute<JsonbRow>(
sql`SELECT "id", "dossier_personal_excerpt"::text AS "dossier_personal_excerpt" FROM "profiles" WHERE "dossier_personal_excerpt" IS NOT NULL`,
)
const rows = result.rows ?? []
for (const row of rows) {
const legacy = lexicalToLegacyText(row.dossier_personal_excerpt ?? "")
await db.execute(
sql`UPDATE "profiles" SET "dossier_personal_excerpt_old" = ${legacy} WHERE "id" = ${row.id}`,
)
}
await db.execute(sql`ALTER TABLE "profiles" DROP COLUMN "dossier_personal_excerpt";`)
await db.execute(
sql`ALTER TABLE "profiles" RENAME COLUMN "dossier_personal_excerpt_old" TO "dossier_personal_excerpt";`,
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_templates" ADD COLUMN "html" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_templates" DROP COLUMN "html";`)
}

View file

@ -29,6 +29,9 @@ import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from './20260905_215658';
import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
export const migrations = [
{
@ -184,6 +187,21 @@ export const migrations = [
{
up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: '20260906_050900_add_evaluation_reminders_field'
name: '20260906_050900_add_evaluation_reminders_field',
},
{
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: '20260906_063753_add_personal_excerpt_richtext',
},
{
up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down,
name: '20260906_061451_add_wiki_template_css',
},
{
up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down,
name: '20260906_065405_add_wiki_template_html'
},
];

View file

@ -1866,7 +1866,21 @@ export interface Profile {
profileTitle?: string | null;
dossier: {
enlistmentDate: string;
personalExcerpt?: string | null;
personalExcerpt?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
intelExcerpt?: {
root: {
type: string;
@ -3285,6 +3299,14 @@ export interface WikiTemplate {
*/
body: string;
description?: string | null;
/**
* Optional CSS injected on wiki pages while this template exists. Write plain selectors — they are automatically prefixed with a hash-based scope class (e.g. .ptf-tpl-3afb1c .my-class) so templates never collide with each other or the app shell.
*/
css?: string | null;
/**
* Developer-only. Raw HTML rendered verbatim (sanitized) when this template is transcluded — replaces the markdown body.
*/
html?: string | null;
updatedAt: string;
createdAt: string;
}
@ -5064,6 +5086,8 @@ export interface WikiTemplatesSelect<T extends boolean = true> {
name?: T;
body?: T;
description?: T;
css?: T;
html?: T;
updatedAt?: T;
createdAt?: T;
}

View file

@ -10,7 +10,7 @@
```
utils/
access-control/ 6 files Permission checking, role gates, qualification queries
access-control/ 8 files Permission checking, role gates, qualification queries
event-log/ 3 files Event emitter, type constants, formatting
xp/ 1 file Level resolver
```
@ -33,8 +33,8 @@ Queries `Profiles.progression.qualifications` for specific qualification strings
### Emitter: `emitGameEvent(payload, { type, message, ... })`
Fire-and-forget create on `game-event-logs`. Always sets `system: true`. Silent error catch (no throw). Always called AFTER successful mutation in server actions.
### Event types: `EventTypes` constants (95 types across 12 categories)
Defined in `eventTypes.ts`. Use these constants for TypeScript narrowing on the `type` field. Categories: `mission:*`, `finance:*`, `market:*`, `structure:*`, `logistics:*`, `bank:*`, `notification:*`, `locker:*`, `xp:*`, `ticket:*`, `attendance:*`, `system:*`.
### Event types: `EventTypes` constants (~105 types across 15+ categories)
Defined in `eventTypes.ts`. Use these constants for TypeScript narrowing on the `type` field. Categories include: `mission:*`, `finance:*`, `market:*`, `structure:*`, `staff:*`, `logistics:*`, `bank:*`, `notification:*`, `locker:*`, `xp:*`, `ticket:*`, `wiki:*`, `minigame:*`, `attendance:*`, `system:*`.
### Display: `formatType(type)` — human-readable label for event types.

View file

@ -99,6 +99,10 @@ export const EventTypes = {
AccountDiscordRequest: "account:discord-request",
AccountPreferencesUpdate: "account:preferences-update",
// Dossier
DossierPersonalExcerptUpdate: "dossier:personal-excerpt-update",
DossierIntelExcerptUpdate: "dossier:intel-excerpt-update",
// Attendance
MissionAttendanceChange: "mission:attendance-change",