Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
const CIVILIAN_PRESENCE = ["None", "Low", "Medium", "High"] as const;
|
|
|
|
const INFIL_EXFIL_METHODS = [
|
|
{ value: "helicopter", label: "Helicopter" },
|
|
{ value: "aircraft", label: "Aircraft" },
|
|
{ value: "paradrop", label: "HALO / Paradrop" },
|
|
{ value: "boat", label: "Boat" },
|
|
{ value: "car", label: "Car" },
|
|
{ value: "onFoot", label: "On Foot" },
|
|
] as const;
|
|
|
|
export interface SideOpNarrative {
|
|
briefing: { sectionName: string; details: string }[];
|
|
missionObjectives: {
|
|
title: string;
|
|
description: string;
|
|
missionCritical: boolean;
|
|
successOrFailConditions: string;
|
|
redacted: boolean;
|
|
}[];
|
|
civilianPresence: string;
|
|
radioPlan: string;
|
|
insertion: string[];
|
|
extraction: string[];
|
|
assetsAvailable: {
|
|
assetType: string;
|
|
assetCount: string;
|
|
doesRespawn: boolean;
|
|
assetCallsign: string;
|
|
}[];
|
|
}
|
|
|
|
interface SideOpNarrativeFieldsProps {
|
|
value: SideOpNarrative;
|
|
onChange: (next: SideOpNarrative) => void;
|
|
}
|
|
|
|
function toggleValue(list: string[], value: string): string[] {
|
|
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
|
|
}
|
|
|
|
export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFieldsProps) {
|
|
const setBriefing = (briefing: SideOpNarrative["briefing"]) => onChange({ ...value, briefing });
|
|
const setObjectives = (missionObjectives: SideOpNarrative["missionObjectives"]) =>
|
|
onChange({ ...value, missionObjectives });
|
|
const setAssets = (assetsAvailable: SideOpNarrative["assetsAvailable"]) =>
|
|
onChange({ ...value, assetsAvailable });
|
|
|
|
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.
|
|
</p>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Briefing entries</Label>
|
|
{value.briefing.map((entry, i) => (
|
|
<div key={i} className="grid gap-1.5 rounded-md border p-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={entry.sectionName}
|
|
onChange={(e) => {
|
|
const next = [...value.briefing];
|
|
next[i] = { ...entry, sectionName: e.target.value };
|
|
setBriefing(next);
|
|
}}
|
|
placeholder="Section name (e.g. Situation)"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Remove briefing entry"
|
|
onClick={() => setBriefing(value.briefing.filter((_, j) => j !== i))}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
value={entry.details}
|
|
onChange={(e) => {
|
|
const next = [...value.briefing];
|
|
next[i] = { ...entry, details: e.target.value };
|
|
setBriefing(next);
|
|
}}
|
|
placeholder="Briefing text for this section"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
))}
|
|
{value.briefing.length < 8 && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-fit"
|
|
onClick={() =>
|
|
setBriefing([...value.briefing, { sectionName: "", details: "" }])
|
|
}
|
|
>
|
|
<Plus className="size-4" /> Add briefing entry
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Mission objectives</Label>
|
|
{value.missionObjectives.map((obj, i) => (
|
|
<div key={i} className="grid gap-1.5 rounded-md border p-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={obj.title}
|
|
onChange={(e) => {
|
|
const next = [...value.missionObjectives];
|
|
next[i] = { ...obj, title: e.target.value };
|
|
setObjectives(next);
|
|
}}
|
|
placeholder="Objective title"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Remove objective"
|
|
onClick={() => setObjectives(value.missionObjectives.filter((_, j) => j !== i))}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
value={obj.description}
|
|
onChange={(e) => {
|
|
const next = [...value.missionObjectives];
|
|
next[i] = { ...obj, description: e.target.value };
|
|
setObjectives(next);
|
|
}}
|
|
placeholder="Description"
|
|
rows={2}
|
|
/>
|
|
<Input
|
|
value={obj.successOrFailConditions}
|
|
onChange={(e) => {
|
|
const next = [...value.missionObjectives];
|
|
next[i] = { ...obj, successOrFailConditions: e.target.value };
|
|
setObjectives(next);
|
|
}}
|
|
placeholder="Success / fail conditions"
|
|
/>
|
|
<div className="flex flex-wrap gap-4">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
checked={obj.missionCritical}
|
|
onCheckedChange={(checked) => {
|
|
const next = [...value.missionObjectives];
|
|
next[i] = { ...obj, missionCritical: checked === true };
|
|
setObjectives(next);
|
|
}}
|
|
/>
|
|
Mission critical
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
checked={obj.redacted}
|
|
onCheckedChange={(checked) => {
|
|
const next = [...value.missionObjectives];
|
|
next[i] = { ...obj, redacted: checked === true };
|
|
setObjectives(next);
|
|
}}
|
|
/>
|
|
Redacted
|
|
</label>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-fit"
|
|
onClick={() =>
|
|
setObjectives([
|
|
...value.missionObjectives,
|
|
{
|
|
title: "",
|
|
description: "",
|
|
missionCritical: true,
|
|
successOrFailConditions: "",
|
|
redacted: false,
|
|
},
|
|
])
|
|
}
|
|
>
|
|
<Plus className="size-4" /> Add objective
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Civilian presence</Label>
|
|
<Select
|
|
value={value.civilianPresence}
|
|
onValueChange={(civilianPresence) => onChange({ ...value, civilianPresence })}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CIVILIAN_PRESENCE.map((c) => (
|
|
<SelectItem key={c} value={c}>
|
|
{c}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Radio SOP</Label>
|
|
<Textarea
|
|
value={value.radioPlan}
|
|
onChange={(e) => onChange({ ...value, radioPlan: e.target.value })}
|
|
placeholder="How radios will be used: nets, contingency plans, radio silence, etc."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="grid gap-2">
|
|
<Label>Infiltration methods</Label>
|
|
<div className="grid gap-1.5">
|
|
{INFIL_EXFIL_METHODS.map((m) => (
|
|
<label key={m.value} className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
checked={value.insertion.includes(m.value)}
|
|
onCheckedChange={() =>
|
|
onChange({ ...value, insertion: toggleValue(value.insertion, m.value) })
|
|
}
|
|
/>
|
|
{m.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>Exfiltration methods</Label>
|
|
<div className="grid gap-1.5">
|
|
{INFIL_EXFIL_METHODS.map((m) => (
|
|
<label key={m.value} className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
checked={value.extraction.includes(m.value)}
|
|
onCheckedChange={() =>
|
|
onChange({ ...value, extraction: toggleValue(value.extraction, m.value) })
|
|
}
|
|
/>
|
|
{m.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Logistics & support assets</Label>
|
|
{value.assetsAvailable.map((asset, i) => (
|
|
<div key={i} className="grid gap-1.5 rounded-md border p-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={asset.assetType}
|
|
onChange={(e) => {
|
|
const next = [...value.assetsAvailable];
|
|
next[i] = { ...asset, assetType: e.target.value };
|
|
setAssets(next);
|
|
}}
|
|
placeholder="Asset type (e.g. Artillery, Fuel Truck, CAS)"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Remove asset"
|
|
onClick={() => setAssets(value.assetsAvailable.filter((_, j) => j !== i))}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Input
|
|
value={asset.assetCount}
|
|
onChange={(e) => {
|
|
const next = [...value.assetsAvailable];
|
|
next[i] = { ...asset, assetCount: e.target.value };
|
|
setAssets(next);
|
|
}}
|
|
placeholder="Count"
|
|
/>
|
|
<Input
|
|
value={asset.assetCallsign}
|
|
onChange={(e) => {
|
|
const next = [...value.assetsAvailable];
|
|
next[i] = { ...asset, assetCallsign: e.target.value };
|
|
setAssets(next);
|
|
}}
|
|
placeholder="Callsign"
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
checked={asset.doesRespawn}
|
|
onCheckedChange={(checked) => {
|
|
const next = [...value.assetsAvailable];
|
|
next[i] = { ...asset, doesRespawn: checked === true };
|
|
setAssets(next);
|
|
}}
|
|
/>
|
|
Respawnable
|
|
</label>
|
|
</div>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-fit"
|
|
onClick={() =>
|
|
setAssets([
|
|
...value.assetsAvailable,
|
|
{ assetType: "", assetCount: "", doesRespawn: false, assetCallsign: "" },
|
|
])
|
|
}
|
|
>
|
|
<Plus className="size-4" /> Add asset
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|