From 982c33f4f3edced8cd557683e8b0d8eee62bb2a6 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 14 Sep 2026 15:37:12 -0400 Subject: [PATCH] feat(missions): lexical rich text editing, side-op narrative fields, briefing updates Editor, toolbar, and button components with the lib they share, consumed by the mission briefing section and side-op dialogs. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../intelligence/missions/[id]/page.tsx | 5 +- .../intelligence/missions/actions.ts | 99 +++++-- .../intelligence/CreateSideOpDialog.tsx | 27 +- .../intelligence/LexicalRichTextEditor.tsx | 206 +++++++++++++++ .../intelligence/LexicalRichTextToolbar.tsx | 241 ++++++++++++++++++ .../LexicalRichTextToolbarButton.tsx | 39 +++ .../intelligence/MissionBriefingSection.tsx | 2 +- .../intelligence/SideOpNarrativeFields.tsx | 25 +- src/lib/intelligence/briefingText.ts | 30 --- 9 files changed, 595 insertions(+), 79 deletions(-) create mode 100644 src/components/frontend/intelligence/LexicalRichTextEditor.tsx create mode 100644 src/components/frontend/intelligence/LexicalRichTextToolbar.tsx create mode 100644 src/components/frontend/intelligence/LexicalRichTextToolbarButton.tsx diff --git a/src/app/(frontend)/intelligence/missions/[id]/page.tsx b/src/app/(frontend)/intelligence/missions/[id]/page.tsx index 30fcd96..e32b889 100644 --- a/src/app/(frontend)/intelligence/missions/[id]/page.tsx +++ b/src/app/(frontend)/intelligence/missions/[id]/page.tsx @@ -27,7 +27,6 @@ import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogi import { MissionComms } from "@/components/frontend/intelligence/MissionComms"; import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance"; import { SideOpControls } from "@/components/frontend/intelligence/SideOpControls"; -import { lexicalToText } from "@/lib/intelligence/briefingText"; import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog"; import { type AttendanceResponse, @@ -457,7 +456,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro : (mission.classification.campaign ?? null), briefing: (mission.briefing ?? []).map((entry) => ({ sectionName: entry.sectionName, - details: lexicalToText(entry.details), + details: entry.details, })), missionObjectives: (mission.missionObjectives ?? []).map((obj) => ({ title: obj.title, @@ -467,7 +466,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro redacted: obj.redacted ?? false, })), civilianPresence: mission.civilianPresence ?? "None", - radioPlan: lexicalToText(mission.commsAndDocs?.radioPlan ?? null), + radioPlan: mission.commsAndDocs?.radioPlan ?? null, insertion: mission.logisticsAndSupport?.insertion ?? [], extraction: mission.logisticsAndSupport?.extraction ?? [], assetsAvailable: (mission.logisticsAndSupport?.assetsAvailable ?? []).map( diff --git a/src/app/(frontend)/intelligence/missions/actions.ts b/src/app/(frontend)/intelligence/missions/actions.ts index ba3d16a..373ddf0 100644 --- a/src/app/(frontend)/intelligence/missions/actions.ts +++ b/src/app/(frontend)/intelligence/missions/actions.ts @@ -9,7 +9,6 @@ import { hasPermission } from "@/utils/access-control/hasPermission"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; -import { textToLexical } from "@/lib/intelligence/briefingText"; import { nextDayKey, parseDateKey, @@ -266,7 +265,7 @@ export interface FridaySideOpInput { noCampaign?: boolean; customMapName?: string | null; requiredModsId?: number | null; - briefing?: { sectionName: string; details: string }[] | null; + briefing?: { sectionName: string; details?: Record | null }[] | null; missionObjectives?: { title: string; description?: string | null; @@ -279,7 +278,7 @@ export interface FridaySideOpInput { faction: number; threatLevel: "ally" | "friendly" | "neutral" | "hostile"; }[] | null; - radioPlan?: string | null; + radioPlan?: Record | null; insertion?: InfilExfilMethod[] | null; extraction?: InfilExfilMethod[] | null; assetsAvailable?: { @@ -291,11 +290,11 @@ export interface FridaySideOpInput { } type SideOpNarrativeData = { - briefing?: { sectionName: string; details: ReturnType }[]; + briefing?: { sectionName: string; details: MissionLexicalState }[]; missionObjectives?: FridaySideOpInput["missionObjectives"]; civilianPresence?: FridaySideOpInput["civilianPresence"]; factionsInvolved?: FridaySideOpInput["factionsInvolved"]; - commsAndDocs?: { radioPlan?: ReturnType }; + commsAndDocs?: { radioPlan?: MissionLexicalState | null }; logisticsAndSupport?: { insertion?: InfilExfilMethod[] | null; extraction?: InfilExfilMethod[] | null; @@ -303,6 +302,38 @@ type SideOpNarrativeData = { }; }; +type MissionLexicalState = NonNullable[number]["details"]>; + +type NarrativeDataResult = { success: true; data: SideOpNarrativeData } | { success: false; error: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isLexicalValue(value: unknown): value is MissionLexicalState | null | undefined { + return value === undefined || value === null || (isRecord(value) && isRecord(value.root)); +} + +const EMPTY_LEXICAL_STATE: MissionLexicalState = { + root: { + type: "root", + children: [], + direction: null, + format: "", + indent: 0, + version: 1, + }, +}; + +function normalizeLexicalValue( + value: Record | null | undefined, +): MissionLexicalState { + if (value === null || value === undefined || !isLexicalValue(value)) { + return EMPTY_LEXICAL_STATE; + } + return value; +} + function narrativeData(input: { briefing?: FridaySideOpInput["briefing"]; missionObjectives?: FridaySideOpInput["missionObjectives"]; @@ -312,10 +343,18 @@ function narrativeData(input: { insertion?: FridaySideOpInput["insertion"]; extraction?: FridaySideOpInput["extraction"]; assetsAvailable?: FridaySideOpInput["assetsAvailable"]; -}): SideOpNarrativeData { +}): NarrativeDataResult { + const radioPlan = input.radioPlan; + if (!isLexicalValue(radioPlan)) { + return { success: false, error: "Invalid radio plan format." }; + } + if (input.briefing?.some((entry) => !isLexicalValue(entry.details))) { + return { success: false, error: "Invalid briefing details format." }; + } + const commsAndDocs: SideOpNarrativeData["commsAndDocs"] = {}; - if (input.radioPlan !== undefined) { - commsAndDocs.radioPlan = textToLexical(input.radioPlan ?? ""); + if (radioPlan !== undefined) { + commsAndDocs.radioPlan = radioPlan; } const logisticsAndSupport: SideOpNarrativeData["logisticsAndSupport"] = {}; if (input.insertion !== undefined) { @@ -328,19 +367,24 @@ function narrativeData(input: { logisticsAndSupport.assetsAvailable = input.assetsAvailable; } return { - ...(input.briefing !== undefined - ? { - briefing: (input.briefing ?? []).map((entry) => ({ - sectionName: entry.sectionName, - details: textToLexical(entry.details), - })), - } - : {}), - ...(input.missionObjectives !== undefined ? { missionObjectives: input.missionObjectives } : {}), - ...(input.civilianPresence !== undefined ? { civilianPresence: input.civilianPresence } : {}), - ...(input.factionsInvolved !== undefined ? { factionsInvolved: input.factionsInvolved } : {}), - ...(Object.keys(commsAndDocs).length > 0 ? { commsAndDocs } : {}), - ...(Object.keys(logisticsAndSupport).length > 0 ? { logisticsAndSupport } : {}), + success: true, + data: { + ...(input.briefing !== undefined + ? { + briefing: (input.briefing ?? []).map((entry) => ({ + sectionName: entry.sectionName, + details: normalizeLexicalValue(entry.details), + })), + } + : {}), + ...(input.missionObjectives !== undefined + ? { missionObjectives: input.missionObjectives } + : {}), + ...(input.civilianPresence !== undefined ? { civilianPresence: input.civilianPresence } : {}), + ...(input.factionsInvolved !== undefined ? { factionsInvolved: input.factionsInvolved } : {}), + ...(Object.keys(commsAndDocs).length > 0 ? { commsAndDocs } : {}), + ...(Object.keys(logisticsAndSupport).length > 0 ? { logisticsAndSupport } : {}), + }, }; } @@ -474,6 +518,9 @@ export async function createFridaySideOp( return { success: false, error: "Please choose a map." }; } + const narrative = narrativeData(input); + if (!narrative.success) return narrative; + const mission = await payload.create({ collection: "missions", overrideAccess: true, @@ -508,7 +555,7 @@ export async function createFridaySideOp( ? { gameSetup: { requiredMods: input.requiredModsId } } : {}), }, - ...narrativeData(input), + ...narrative.data, }, }); @@ -547,11 +594,11 @@ export interface UpdateFridaySideOpInput { noCampaign?: boolean; customMapName?: string | null; requiredModsId?: number | null; - briefing?: { sectionName: string; details: string }[] | null; + briefing?: { sectionName: string; details?: Record | null }[] | null; missionObjectives?: FridaySideOpInput["missionObjectives"]; civilianPresence?: FridaySideOpInput["civilianPresence"]; factionsInvolved?: FridaySideOpInput["factionsInvolved"]; - radioPlan?: string | null; + radioPlan?: Record | null; insertion?: InfilExfilMethod[] | null; extraction?: InfilExfilMethod[] | null; assetsAvailable?: FridaySideOpInput["assetsAvailable"]; @@ -677,7 +724,9 @@ export async function updateFridaySideOp( if (input.requiredModsId !== undefined) { data.gameDetails = { gameSetup: { requiredMods: input.requiredModsId } }; } - Object.assign(data, narrativeData(input)); + const narrative = narrativeData(input); + if (!narrative.success) return narrative; + Object.assign(data, narrative.data); const updated = await payload.update({ collection: "missions", diff --git a/src/components/frontend/intelligence/CreateSideOpDialog.tsx b/src/components/frontend/intelligence/CreateSideOpDialog.tsx index 6d57391..36baf94 100644 --- a/src/components/frontend/intelligence/CreateSideOpDialog.tsx +++ b/src/components/frontend/intelligence/CreateSideOpDialog.tsx @@ -37,16 +37,31 @@ import { SideOpNarrativeFields, type SideOpNarrative, } from "@/components/frontend/intelligence/SideOpNarrativeFields"; +import type { LexicalJson } from "./LexicalRichTextEditor"; const MISSION_TYPES = ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"] as const; const DEFAULT_BRIEFING_SECTIONS = ["Situation", "Mission", "Execution", "Signals"]; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasLexicalText(value: unknown): boolean { + if (!isRecord(value)) return false; + if (typeof value.text === "string" && value.text.trim() !== "") return true; + return Array.isArray(value.children) && value.children.some(hasLexicalText); +} + +function isEmptyLexical(value: LexicalJson | null): boolean { + return value === null || !hasLexicalText(value); +} + function defaultNarrative(): SideOpNarrative { return { - briefing: DEFAULT_BRIEFING_SECTIONS.map((sectionName) => ({ sectionName, details: "" })), + briefing: DEFAULT_BRIEFING_SECTIONS.map((sectionName) => ({ sectionName, details: null })), missionObjectives: [], civilianPresence: "None", - radioPlan: "", + radioPlan: null, insertion: [], extraction: [], assetsAvailable: [], @@ -73,7 +88,7 @@ export interface SideOpDialogInitial { maxPlayers: number; campaignId: number | null; requiredModsId?: number | null; - briefing?: { sectionName: string; details: string }[]; + briefing?: { sectionName: string; details: LexicalJson | null }[]; missionObjectives?: { title: string; description: string; @@ -82,7 +97,7 @@ export interface SideOpDialogInitial { redacted: boolean; }[]; civilianPresence?: string; - radioPlan?: string; + radioPlan?: LexicalJson | null; insertion?: string[]; extraction?: string[]; assetsAvailable?: { @@ -172,7 +187,7 @@ export function CreateSideOpDialog({ briefing: initial.briefing ?? [], missionObjectives: initial.missionObjectives ?? [], civilianPresence: initial.civilianPresence ?? "None", - radioPlan: initial.radioPlan ?? "", + radioPlan: initial.radioPlan ?? null, insertion: initial.insertion ?? [], extraction: initial.extraction ?? [], assetsAvailable: initial.assetsAvailable ?? [], @@ -190,7 +205,7 @@ export function CreateSideOpDialog({ } return { briefing: narrative.briefing - .filter((e) => e.sectionName.trim() !== "" || e.details.trim() !== "") + .filter((e) => e.sectionName.trim() !== "" || !isEmptyLexical(e.details)) .map((e) => ({ sectionName: e.sectionName.trim() || "Untitled", details: e.details })), missionObjectives: narrative.missionObjectives .filter((o) => o.title.trim() !== "") diff --git a/src/components/frontend/intelligence/LexicalRichTextEditor.tsx b/src/components/frontend/intelligence/LexicalRichTextEditor.tsx new file mode 100644 index 0000000..92cffc9 --- /dev/null +++ b/src/components/frontend/intelligence/LexicalRichTextEditor.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { $createParagraphNode, $getRoot, type EditorThemeClasses } from "lexical"; +import { HorizontalRuleNode } from "@lexical/extension"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; +import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { cn } from "@/lib/utils"; +import { LexicalRichTextToolbar } from "./LexicalRichTextToolbar"; + +export type LexicalJson = Record; + +const EXTERNAL_STATE_TAG = "polaris-external-state"; + +const EDITOR_NODES = [ + HeadingNode, + QuoteNode, + ListNode, + ListItemNode, + HorizontalRuleNode, + LinkNode, + AutoLinkNode, +] as const; + +const EDITOR_THEME: EditorThemeClasses = { + heading: { + h2: "text-lg font-semibold", + h3: "text-base font-semibold", + h4: "text-sm font-semibold", + }, + hr: "my-3 border-border", + link: "text-primary underline underline-offset-2", + list: { + checklist: "pl-6", + listitem: "pl-1", + listitemChecked: "text-muted-foreground line-through", + listitemUnchecked: "", + nested: { list: "pl-6", listitem: "pl-1" }, + ol: "list-decimal pl-6", + ul: "list-disc pl-6", + }, + quote: "border-l-2 border-border pl-3 italic text-muted-foreground", + text: { + bold: "font-semibold", + code: "rounded-sm bg-muted px-1 font-mono text-xs", + italic: "italic", + strikethrough: "line-through", + underline: "underline", + underlineStrikethrough: "underline line-through", + }, +}; + +function serializeValue(value: LexicalJson | null): string | null { + return value === null ? null : (JSON.stringify(value) ?? null); +} + +function EditablePlugin({ disabled }: { disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + editor.setEditable(!disabled); + }, [disabled, editor]); + + return null; +} + +function ExternalStatePlugin({ + lastEmittedJsonRef, + value, +}: { + lastEmittedJsonRef: { current: string | null }; + value: LexicalJson | null; +}) { + const [editor] = useLexicalComposerContext(); + const previousValueRef = useRef(value); + + useEffect(() => { + if (previousValueRef.current === value) return; + previousValueRef.current = value; + + const serializedValue = serializeValue(value); + if (serializedValue !== null && serializedValue === lastEmittedJsonRef.current) { + lastEmittedJsonRef.current = null; + return; + } + lastEmittedJsonRef.current = null; + + if (serializedValue === null) { + editor.update( + () => { + const root = $getRoot(); + root.clear(); + root.append($createParagraphNode()); + }, + { tag: EXTERNAL_STATE_TAG }, + ); + return; + } + + editor.setEditorState(editor.parseEditorState(serializedValue), { tag: EXTERNAL_STATE_TAG }); + }, [editor, lastEmittedJsonRef, value]); + + return null; +} + +function SerializeOnChangePlugin({ + lastEmittedJsonRef, + onChange, +}: { + lastEmittedJsonRef: { current: string | null }; + onChange: (json: LexicalJson) => void; +}) { + const [editor] = useLexicalComposerContext(); + const onChangeRef = useRef(onChange); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect( + () => + editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, editorState, tags }) => { + if (tags.has(EXTERNAL_STATE_TAG) || (dirtyElements.size === 0 && dirtyLeaves.size === 0)) { + return; + } + const json: LexicalJson = { ...editorState.toJSON() }; + lastEmittedJsonRef.current = JSON.stringify(json); + onChangeRef.current(json); + }), + [editor, lastEmittedJsonRef], + ); + + return null; +} + +export interface LexicalRichTextEditorProps { + value: LexicalJson | null; + onChange: (json: LexicalJson) => void; + placeholder?: string; + disabled?: boolean; +} + +export function LexicalRichTextEditor({ + disabled = false, + onChange, + placeholder = "Write the operation details...", + value, +}: LexicalRichTextEditorProps) { + const lastEmittedJsonRef = useRef(null); + const [initialConfig] = useState(() => ({ + namespace: "PolarisSideOpNarrative", + nodes: EDITOR_NODES, + onError: (error: Error) => console.error("Lexical editor error.", error), + editorState: serializeValue(value), + theme: EDITOR_THEME, + editable: !disabled, + })); + + return ( + +
+ +
+ {placeholder}} + /> + } + placeholder={ +
+ {placeholder} +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> +
+
+ + + + + + + +
+ ); +} diff --git a/src/components/frontend/intelligence/LexicalRichTextToolbar.tsx b/src/components/frontend/intelligence/LexicalRichTextToolbar.tsx new file mode 100644 index 0000000..6a3ed41 --- /dev/null +++ b/src/components/frontend/intelligence/LexicalRichTextToolbar.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + FORMAT_TEXT_COMMAND, + mergeRegister, + SELECTION_CHANGE_COMMAND, + type LexicalEditor, + type LexicalNode, + type RangeSelection, +} from "lexical"; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, + $isQuoteNode, +} from "@lexical/rich-text"; +import { $setBlocksType } from "@lexical/selection"; +import { + $isListNode, + INSERT_CHECK_LIST_COMMAND, + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + type ListType, +} from "@lexical/list"; +import { + Bold, + Code2, + Italic, + List, + ListChecks, + ListOrdered, + Minus, + Quote, + Strikethrough, + Underline, + type LucideIcon, +} from "lucide-react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/extension"; +import { Separator } from "@/components/ui/separator"; +import { LexicalRichTextToolbarButton } from "./LexicalRichTextToolbarButton"; + +type BlockType = "paragraph" | "h2" | "h3" | "h4" | "quote"; + +interface ToolbarState { + bold: boolean; + italic: boolean; + underline: boolean; + strikethrough: boolean; + code: boolean; + blockType: BlockType; + listType: ListType | null; +} + +const DEFAULT_TOOLBAR_STATE: ToolbarState = { + blockType: "paragraph", + bold: false, + code: false, + italic: false, + listType: null, + strikethrough: false, + underline: false, +}; + +function getBlockType(selection: RangeSelection): BlockType { + const topLevelElement = selection.anchor.getNode().getTopLevelElement(); + if ($isHeadingNode(topLevelElement)) { + const tag = topLevelElement.getTag(); + if (tag === "h2" || tag === "h3" || tag === "h4") return tag; + } + if ($isQuoteNode(topLevelElement)) return "quote"; + return "paragraph"; +} + +function getListType(selection: RangeSelection): ListType | null { + let node: LexicalNode | null = selection.anchor.getNode(); + while (node) { + if ($isListNode(node)) return node.getListType(); + node = node.getParent(); + } + return null; +} + +function readToolbarState(editor: LexicalEditor): ToolbarState { + let state = DEFAULT_TOOLBAR_STATE; + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + state = { + blockType: getBlockType(selection), + bold: selection.hasFormat("bold"), + code: selection.hasFormat("code"), + italic: selection.hasFormat("italic"), + listType: getListType(selection), + strikethrough: selection.hasFormat("strikethrough"), + underline: selection.hasFormat("underline"), + }; + }); + return state; +} + +export function LexicalRichTextToolbar({ disabled }: { disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + const [state, setState] = useState(DEFAULT_TOOLBAR_STATE); + + useEffect(() => { + const update = () => setState(readToolbarState(editor)); + update(); + return mergeRegister( + editor.registerUpdateListener(update), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + update(); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor]); + + function formatText(format: "bold" | "italic" | "underline" | "strikethrough" | "code") { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + } + + function setBlockType(type: BlockType) { + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + const nextType = state.blockType === type && type !== "paragraph" ? "paragraph" : type; + if (nextType === "paragraph") { + $setBlocksType(selection, () => $createParagraphNode()); + } else if (nextType === "quote") { + $setBlocksType(selection, () => $createQuoteNode()); + } else { + $setBlocksType(selection, () => $createHeadingNode(nextType)); + } + }); + } + + const inlineButtons: { + label: string; + icon: LucideIcon; + active: boolean; + onClick: () => void; + }[] = [ + { label: "Bold", icon: Bold, active: state.bold, onClick: () => formatText("bold") }, + { label: "Italic", icon: Italic, active: state.italic, onClick: () => formatText("italic") }, + { + label: "Underline", + icon: Underline, + active: state.underline, + onClick: () => formatText("underline"), + }, + { + label: "Strikethrough", + icon: Strikethrough, + active: state.strikethrough, + onClick: () => formatText("strikethrough"), + }, + { label: "Inline code", icon: Code2, active: state.code, onClick: () => formatText("code") }, + ]; + const blockButtons: { + label: string; + icon: LucideIcon; + active: boolean; + pressed?: boolean; + onClick: () => void; + }[] = [ + { + label: "Bulleted list", + icon: List, + active: state.listType === "bullet", + onClick: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), + }, + { + label: "Numbered list", + icon: ListOrdered, + active: state.listType === "number", + onClick: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined), + }, + { + label: "Checklist", + icon: ListChecks, + active: state.listType === "check", + onClick: () => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined), + }, + { + label: "Blockquote", + icon: Quote, + active: state.blockType === "quote", + onClick: () => setBlockType("quote"), + }, + { + label: "Horizontal rule", + icon: Minus, + active: false, + pressed: false, + onClick: () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined), + }, + ]; + + return ( +
+
+ {inlineButtons.map((button) => ( + + ))} +
+ +
+ {(["paragraph", "h2", "h3", "h4"] as const).map((type) => ( + setBlockType(type)} + > + {type === "paragraph" ? "Text" : type.toUpperCase()} + + ))} +
+ +
+ {blockButtons.map((button) => ( + + ))} +
+
+ ); +} diff --git a/src/components/frontend/intelligence/LexicalRichTextToolbarButton.tsx b/src/components/frontend/intelligence/LexicalRichTextToolbarButton.tsx new file mode 100644 index 0000000..c55ae55 --- /dev/null +++ b/src/components/frontend/intelligence/LexicalRichTextToolbarButton.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function LexicalRichTextToolbarButton({ + active = false, + children, + disabled, + icon: Icon, + label, + onClick, + pressed = active, +}: { + active?: boolean; + children?: ReactNode; + disabled: boolean; + icon?: LucideIcon; + label: string; + onClick: () => void; + pressed?: boolean; +}) { + return ( + + ); +} diff --git a/src/components/frontend/intelligence/MissionBriefingSection.tsx b/src/components/frontend/intelligence/MissionBriefingSection.tsx index c5bc9f6..e10f036 100644 --- a/src/components/frontend/intelligence/MissionBriefingSection.tsx +++ b/src/components/frontend/intelligence/MissionBriefingSection.tsx @@ -55,7 +55,7 @@ export function MissionBriefingSection({ briefing }: MissionBriefingSectionProps {entry.sectionName} - + diff --git a/src/components/frontend/intelligence/SideOpNarrativeFields.tsx b/src/components/frontend/intelligence/SideOpNarrativeFields.tsx index eaae023..f486947 100644 --- a/src/components/frontend/intelligence/SideOpNarrativeFields.tsx +++ b/src/components/frontend/intelligence/SideOpNarrativeFields.tsx @@ -13,6 +13,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { LexicalRichTextEditor, type LexicalJson } from "./LexicalRichTextEditor"; const CIVILIAN_PRESENCE = ["None", "Low", "Medium", "High"] as const; @@ -26,7 +27,7 @@ const INFIL_EXFIL_METHODS = [ ] as const; export interface SideOpNarrative { - briefing: { sectionName: string; details: string }[]; + briefing: { sectionName: string; details: LexicalJson | null }[]; missionObjectives: { title: string; description: string; @@ -35,7 +36,7 @@ export interface SideOpNarrative { redacted: boolean; }[]; civilianPresence: string; - radioPlan: string; + radioPlan: LexicalJson | null; insertion: string[]; extraction: string[]; assetsAvailable: { @@ -65,8 +66,8 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields return (

- Plain text only. Each line becomes a paragraph. Rich formatting written in the admin panel - is flattened if you save here. + Use the formatting toolbar for briefing and radio details. Existing rich text is preserved + when you save.

@@ -93,15 +94,14 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields
-