1
0
Fork 0

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-14 15:37:12 -04:00
parent 7ad9320c02
commit 982c33f4f3
9 changed files with 595 additions and 79 deletions

View file

@ -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(

View file

@ -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<string, unknown> | 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<string, unknown> | null;
insertion?: InfilExfilMethod[] | null;
extraction?: InfilExfilMethod[] | null;
assetsAvailable?: {
@ -291,11 +290,11 @@ export interface FridaySideOpInput {
}
type SideOpNarrativeData = {
briefing?: { sectionName: string; details: ReturnType<typeof textToLexical> }[];
briefing?: { sectionName: string; details: MissionLexicalState }[];
missionObjectives?: FridaySideOpInput["missionObjectives"];
civilianPresence?: FridaySideOpInput["civilianPresence"];
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
commsAndDocs?: { radioPlan?: ReturnType<typeof textToLexical> };
commsAndDocs?: { radioPlan?: MissionLexicalState | null };
logisticsAndSupport?: {
insertion?: InfilExfilMethod[] | null;
extraction?: InfilExfilMethod[] | null;
@ -303,6 +302,38 @@ type SideOpNarrativeData = {
};
};
type MissionLexicalState = NonNullable<NonNullable<Mission["briefing"]>[number]["details"]>;
type NarrativeDataResult = { success: true; data: SideOpNarrativeData } | { success: false; error: string };
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> | 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 {
success: true,
data: {
...(input.briefing !== undefined
? {
briefing: (input.briefing ?? []).map((entry) => ({
sectionName: entry.sectionName,
details: textToLexical(entry.details),
details: normalizeLexicalValue(entry.details),
})),
}
: {}),
...(input.missionObjectives !== undefined ? { missionObjectives: input.missionObjectives } : {}),
...(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<string, unknown> | null }[] | null;
missionObjectives?: FridaySideOpInput["missionObjectives"];
civilianPresence?: FridaySideOpInput["civilianPresence"];
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
radioPlan?: string | null;
radioPlan?: Record<string, unknown> | 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",

View file

@ -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<string, unknown> {
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() !== "")

View file

@ -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<string, unknown>;
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<string | null>(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 (
<LexicalComposer initialConfig={initialConfig}>
<div
className={cn(
"rounded-md border border-input bg-background transition-[box-shadow] focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
disabled && "cursor-not-allowed opacity-60",
)}
>
<LexicalRichTextToolbar disabled={disabled} />
<div className="relative min-h-[120px]">
<RichTextPlugin
contentEditable={
<ContentEditable
className="min-h-[120px] px-3 py-2 text-sm outline-none"
aria-label="Rich text editor"
aria-placeholder={placeholder}
placeholder={<span className="sr-only">{placeholder}</span>}
/>
}
placeholder={
<div className="pointer-events-none absolute left-3 top-2 text-sm text-muted-foreground">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
</div>
<HistoryPlugin />
<ListPlugin />
<CheckListPlugin />
<HorizontalRulePlugin />
<EditablePlugin disabled={disabled} />
<ExternalStatePlugin value={value} lastEmittedJsonRef={lastEmittedJsonRef} />
<SerializeOnChangePlugin onChange={onChange} lastEmittedJsonRef={lastEmittedJsonRef} />
</LexicalComposer>
);
}

View file

@ -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 (
<div
className="flex min-w-0 flex-wrap items-center gap-1 rounded-t-md border-b bg-muted/20 p-1"
role="toolbar"
aria-label="Rich text formatting toolbar"
>
<div className="flex flex-wrap items-center gap-1" aria-label="Text formatting">
{inlineButtons.map((button) => (
<LexicalRichTextToolbarButton key={button.label} {...button} disabled={disabled} />
))}
</div>
<Separator orientation="vertical" className="mx-1 hidden h-6 sm:block" />
<div className="flex flex-wrap items-center gap-1" aria-label="Block style">
{(["paragraph", "h2", "h3", "h4"] as const).map((type) => (
<LexicalRichTextToolbarButton
key={type}
label={type === "paragraph" ? "Text" : type.toUpperCase()}
active={state.blockType === type}
disabled={disabled}
onClick={() => setBlockType(type)}
>
{type === "paragraph" ? "Text" : type.toUpperCase()}
</LexicalRichTextToolbarButton>
))}
</div>
<Separator orientation="vertical" className="mx-1 hidden h-6 sm:block" />
<div className="flex flex-wrap items-center gap-1" aria-label="Lists and blocks">
{blockButtons.map((button) => (
<LexicalRichTextToolbarButton key={button.label} {...button} disabled={disabled} />
))}
</div>
</div>
);
}

View file

@ -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 (
<Button
type="button"
size="sm"
variant={active ? "secondary" : "outline"}
className={Icon ? "size-8 p-0" : "px-2.5"}
aria-label={label}
aria-pressed={pressed}
title={label}
disabled={disabled}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{Icon ? <Icon aria-hidden="true" /> : children}
{Icon && <span className="sr-only">{label}</span>}
</Button>
);
}

View file

@ -55,7 +55,7 @@ export function MissionBriefingSection({ briefing }: MissionBriefingSectionProps
<ItemHeader>
<ItemTitle>{entry.sectionName}</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">
<ItemContent className="flex flex-col justify-start items-start lexical-content">
<RichText data={entry.details} />
</ItemContent>
</Item>

View file

@ -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 (
<div className="grid gap-5">
<p className="text-xs text-muted-foreground">
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.
</p>
<div className="grid gap-2">
@ -93,15 +94,14 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields
<Trash2 className="size-4" />
</Button>
</div>
<Textarea
<LexicalRichTextEditor
value={entry.details}
onChange={(e) => {
onChange={(details) => {
const next = [...value.briefing];
next[i] = { ...entry, details: e.target.value };
next[i] = { ...entry, details };
setBriefing(next);
}}
placeholder="Briefing text for this section"
rows={3}
/>
</div>
))}
@ -111,9 +111,7 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields
variant="outline"
size="sm"
className="w-fit"
onClick={() =>
setBriefing([...value.briefing, { sectionName: "", details: "" }])
}
onClick={() => setBriefing([...value.briefing, { sectionName: "", details: null }])}
>
<Plus className="size-4" /> Add briefing entry
</Button>
@ -232,11 +230,10 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields
<div className="grid gap-2">
<Label>Radio SOP</Label>
<Textarea
<LexicalRichTextEditor
value={value.radioPlan}
onChange={(e) => onChange({ ...value, radioPlan: e.target.value })}
onChange={(radioPlan) => onChange({ ...value, radioPlan })}
placeholder="How radios will be used: nets, contingency plans, radio silence, etc."
rows={3}
/>
</div>

View file

@ -52,33 +52,3 @@ export function lexicalToText(details: unknown): string {
.filter((line, i, all) => line.trim() !== "" || (i > 0 && i < all.length - 1))
.join("\n");
}
export function textToLexical(text: string): SerializedLexicalState {
const lines = text.split("\n");
return {
root: {
type: "root",
children: (lines.length > 0 ? lines : [""]).map((line) => ({
type: "paragraph",
children:
line === ""
? []
: [
{
type: "text",
text: line,
version: 1,
},
],
direction: null,
format: "",
indent: 0,
version: 1,
})),
direction: "ltr",
format: "",
indent: 0,
version: 1,
},
};
}