Compare commits
9 commits
bcd6d97ff4
...
56b8b9d320
| Author | SHA1 | Date | |
|---|---|---|---|
| 56b8b9d320 | |||
| db02be7ce9 | |||
| 33174572cc | |||
| 55a03e8094 | |||
| 51f7c803e0 | |||
| 60da363a93 | |||
| c37a848d66 | |||
| fb83250ff5 | |||
| 49bffc04a5 |
46 changed files with 69999 additions and 187 deletions
59
AGENTS.md
59
AGENTS.md
|
|
@ -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 `` — 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
|
||||
|
||||
|
|
|
|||
13
bun.lock
13
bun.lock
|
|
@ -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
204
docs/wiki-demo-article.md
Normal 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**
|
||||
|
||||

|
||||
|
||||
| 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:
|
||||
|
||||

|
||||
|
||||
## 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 |
|
||||
92
docs/wiki-infobox-template.md
Normal file
92
docs/wiki-infobox-template.md
Normal 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}}**
|
||||
|
||||

|
||||
|
||||
| {{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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────── */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
293
src/components/frontend/profile/DossierExcerptEditors.tsx
Normal file
293
src/components/frontend/profile/DossierExcerptEditors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
49
src/components/frontend/wiki/WikiEditorHighlightLayer.tsx
Normal file
49
src/components/frontend/wiki/WikiEditorHighlightLayer.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
164
src/components/frontend/wiki/WikiTemplateDialog.tsx
Normal file
164
src/components/frontend/wiki/WikiTemplateDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/components/frontend/wiki/useEditorHistory.ts
Normal file
91
src/components/frontend/wiki/useEditorHistory.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
67
src/lib/lexical/text.ts
Normal 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;
|
||||
}
|
||||
7
src/lib/profile/excerpt-limits.ts
Normal file
7
src/lib/profile/excerpt-limits.ts
Normal 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;
|
||||
|
|
@ -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 = ``;
|
||||
|
||||
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,
|
||||
|
|
|
|||
109
src/lib/wiki/editorHighlight.ts
Normal file
109
src/lib/wiki/editorHighlight.ts
Normal 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;
|
||||
}
|
||||
56
src/lib/wiki/formatMarkdown.ts
Normal file
56
src/lib/wiki/formatMarkdown.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
136
src/lib/wiki/templateCss.ts
Normal 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 };
|
||||
}
|
||||
58
src/lib/wiki/templateHtml.ts
Normal file
58
src/lib/wiki/templateHtml.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
22455
src/migrations/20260906_061451_add_wiki_template_css.json
Normal file
22455
src/migrations/20260906_061451_add_wiki_template_css.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260906_061451_add_wiki_template_css.ts
Normal file
11
src/migrations/20260906_061451_add_wiki_template_css.ts
Normal 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";`)
|
||||
}
|
||||
22455
src/migrations/20260906_063753_add_personal_excerpt_richtext.json
Normal file
22455
src/migrations/20260906_063753_add_personal_excerpt_richtext.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`,
|
||||
)
|
||||
}
|
||||
22461
src/migrations/20260906_065405_add_wiki_template_html.json
Normal file
22461
src/migrations/20260906_065405_add_wiki_template_html.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260906_065405_add_wiki_template_html.ts
Normal file
11
src/migrations/20260906_065405_add_wiki_template_html.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue