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>
591 lines
21 KiB
TypeScript
591 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useTransition } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
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";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import {
|
|
createFridaySideOp,
|
|
updateFridaySideOp,
|
|
uploadSideOpModlist,
|
|
type FridaySideOpInput,
|
|
} from "@/app/(frontend)/intelligence/missions/actions";
|
|
import { uploadUgcImage } from "./create";
|
|
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: null })),
|
|
missionObjectives: [],
|
|
civilianPresence: "None",
|
|
radioPlan: null,
|
|
insertion: [],
|
|
extraction: [],
|
|
assetsAvailable: [],
|
|
};
|
|
}
|
|
|
|
export interface SideOpDialogOption {
|
|
id: number;
|
|
name: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface SideOpDialogInitial {
|
|
missionId: number;
|
|
name: string;
|
|
codeName?: string | null;
|
|
summary: string;
|
|
coverImageId?: number | null;
|
|
coverImageUrl?: string | null;
|
|
mapId: number | null;
|
|
missionType: string;
|
|
startDateTime: string;
|
|
estimatedDuration: number;
|
|
maxPlayers: number;
|
|
campaignId: number | null;
|
|
requiredModsId?: number | null;
|
|
briefing?: { sectionName: string; details: LexicalJson | null }[];
|
|
missionObjectives?: {
|
|
title: string;
|
|
description: string;
|
|
missionCritical: boolean;
|
|
successOrFailConditions: string;
|
|
redacted: boolean;
|
|
}[];
|
|
civilianPresence?: string;
|
|
radioPlan?: LexicalJson | null;
|
|
insertion?: string[];
|
|
extraction?: string[];
|
|
assetsAvailable?: {
|
|
assetType: string;
|
|
assetCount: string;
|
|
doesRespawn: boolean;
|
|
assetCallsign: string;
|
|
}[];
|
|
}
|
|
|
|
interface CreateSideOpDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
maps: SideOpDialogOption[];
|
|
campaigns: SideOpDialogOption[];
|
|
modLists: SideOpDialogOption[];
|
|
defaultStartDateTime?: string;
|
|
initial?: SideOpDialogInitial;
|
|
onSaved?: () => void;
|
|
onModlistUploaded?: () => void;
|
|
}
|
|
|
|
export function CreateSideOpDialog({
|
|
open,
|
|
onOpenChange,
|
|
maps,
|
|
campaigns,
|
|
modLists,
|
|
defaultStartDateTime,
|
|
initial,
|
|
onSaved,
|
|
onModlistUploaded,
|
|
}: CreateSideOpDialogProps) {
|
|
const isEdit = initial !== undefined;
|
|
const [name, setName] = useState(initial?.name ?? "");
|
|
const [codeName, setCodeName] = useState(initial?.codeName ?? "");
|
|
const [summary, setSummary] = useState(initial?.summary ?? "");
|
|
const [mapId, setMapId] = useState<string>(initial?.mapId ? String(initial.mapId) : "");
|
|
const [mapChoice, setMapChoice] = useState<string>(
|
|
initial?.mapId ? String(initial.mapId) : "existing",
|
|
);
|
|
const [customMapName, setCustomMapName] = useState("");
|
|
const [missionType, setMissionType] = useState<string>(initial?.missionType ?? "Zeus");
|
|
const [requiredModsId, setRequiredModsId] = useState<string>(
|
|
initial?.requiredModsId ? String(initial.requiredModsId) : "none",
|
|
);
|
|
const [modlistName, setModlistName] = useState("");
|
|
const [modlistUploading, setModlistUploading] = useState(false);
|
|
const [estimatedDuration, setEstimatedDuration] = useState(
|
|
String(initial?.estimatedDuration ?? 240),
|
|
);
|
|
const [maxPlayers, setMaxPlayers] = useState(String(initial?.maxPlayers ?? 32));
|
|
const [campaignChoice, setCampaignChoice] = useState<string>(
|
|
initial?.campaignId ? String(initial.campaignId) : "none",
|
|
);
|
|
const [newCampaignName, setNewCampaignName] = useState("");
|
|
const [narrative, setNarrative] = useState<SideOpNarrative>(defaultNarrative);
|
|
const [narrativeOpen, setNarrativeOpen] = useState(false);
|
|
const [coverImage, setCoverImage] = useState<{ id: number; url: string } | null>(null);
|
|
const [coverImageUploading, setCoverImageUploading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setError(null);
|
|
setNarrativeOpen(false);
|
|
if (initial) {
|
|
setName(initial.name);
|
|
setCodeName(initial.codeName ?? "");
|
|
setSummary(initial.summary);
|
|
setCoverImage(
|
|
initial.coverImageId != null && initial.coverImageUrl
|
|
? { id: initial.coverImageId, url: initial.coverImageUrl }
|
|
: null,
|
|
);
|
|
setMapId(initial.mapId ? String(initial.mapId) : "");
|
|
setMapChoice(initial.mapId ? String(initial.mapId) : "custom");
|
|
setCustomMapName("");
|
|
setMissionType(initial.missionType);
|
|
setRequiredModsId(initial.requiredModsId ? String(initial.requiredModsId) : "none");
|
|
setModlistName("");
|
|
setEstimatedDuration(String(initial.estimatedDuration));
|
|
setMaxPlayers(String(initial.maxPlayers));
|
|
setCampaignChoice(initial.campaignId ? String(initial.campaignId) : "none");
|
|
setNarrative({
|
|
briefing: initial.briefing ?? [],
|
|
missionObjectives: initial.missionObjectives ?? [],
|
|
civilianPresence: initial.civilianPresence ?? "None",
|
|
radioPlan: initial.radioPlan ?? null,
|
|
insertion: initial.insertion ?? [],
|
|
extraction: initial.extraction ?? [],
|
|
assetsAvailable: initial.assetsAvailable ?? [],
|
|
});
|
|
} else {
|
|
setCoverImage(null);
|
|
setNarrative(defaultNarrative());
|
|
}
|
|
}
|
|
}, [open, initial]);
|
|
|
|
function narrativeInput() {
|
|
if (!narrativeOpen) {
|
|
return {};
|
|
}
|
|
return {
|
|
briefing: narrative.briefing
|
|
.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() !== "")
|
|
.map((o) => ({
|
|
title: o.title.trim(),
|
|
description: o.description || null,
|
|
missionCritical: o.missionCritical,
|
|
successOrFailConditions: o.successOrFailConditions || null,
|
|
redacted: o.redacted,
|
|
})),
|
|
civilianPresence: narrative.civilianPresence as "None" | "Low" | "Medium" | "High",
|
|
radioPlan: narrative.radioPlan,
|
|
insertion: narrative.insertion as FridaySideOpInput["insertion"],
|
|
extraction: narrative.extraction as FridaySideOpInput["extraction"],
|
|
assetsAvailable: narrative.assetsAvailable.map((a) => ({
|
|
assetType: a.assetType.trim(),
|
|
assetCount: a.assetCount || null,
|
|
doesRespawn: a.doesRespawn,
|
|
assetCallsign: a.assetCallsign || null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function mapInput() {
|
|
return mapChoice === "custom" && customMapName.trim() !== ""
|
|
? { customMapName }
|
|
: mapId
|
|
? { mapId: Number(mapId), customMapName: null }
|
|
: { customMapName: null };
|
|
}
|
|
|
|
function modsInput() {
|
|
return requiredModsId === "none" ? { requiredModsId: null } : { requiredModsId: Number(requiredModsId) };
|
|
}
|
|
|
|
async function handleModlistUpload(file: File) {
|
|
setModlistUploading(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
formData.append("name", modlistName.trim() || file.name.replace(/\.html?$/i, ""));
|
|
const result = await uploadSideOpModlist(formData);
|
|
if (!result.success || !result.data) {
|
|
toast.error(result.error ?? "Failed to upload the mod list.");
|
|
return;
|
|
}
|
|
setRequiredModsId(String(result.data.modlistId));
|
|
toast.success(`Mod list "${result.data.modlistName}" uploaded.`);
|
|
onModlistUploaded?.();
|
|
} finally {
|
|
setModlistUploading(false);
|
|
}
|
|
}
|
|
|
|
async function handleCoverImageUpload(file: File) {
|
|
setCoverImageUploading(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
const result = await uploadUgcImage(formData);
|
|
if (!result.success || !result.data) {
|
|
toast.error(result.error ?? "Failed to upload the image.");
|
|
return;
|
|
}
|
|
setCoverImage({ id: result.data.id, url: result.data.url });
|
|
toast.success("Cover image uploaded.");
|
|
} finally {
|
|
setCoverImageUploading(false);
|
|
}
|
|
}
|
|
|
|
function handleSubmit() {
|
|
setError(null);
|
|
if (!name.trim() || !summary.trim()) {
|
|
setError("Name and summary are required.");
|
|
return;
|
|
}
|
|
if (mapChoice === "custom" && !customMapName.trim()) {
|
|
setError("Please enter a name for the custom map.");
|
|
return;
|
|
}
|
|
if (mapChoice !== "custom" && !mapId) {
|
|
setError("Please choose a map.");
|
|
return;
|
|
}
|
|
if (!isEdit && !defaultStartDateTime) {
|
|
setError("Missing start date: schedule from a Friday on the calendar.");
|
|
return;
|
|
}
|
|
const startDateIso = defaultStartDateTime ?? "";
|
|
|
|
startTransition(async () => {
|
|
if (isEdit) {
|
|
const initialChoice = initial.campaignId ? String(initial.campaignId) : "none";
|
|
const campaignChanged = campaignChoice !== initialChoice;
|
|
const result = await updateFridaySideOp({
|
|
missionId: initial.missionId,
|
|
name,
|
|
codeName: codeName.trim(),
|
|
summary,
|
|
...(coverImage && coverImage.id !== (initial.coverImageId ?? null)
|
|
? { coverImageId: coverImage.id }
|
|
: {}),
|
|
...mapInput(),
|
|
missionType: missionType as FridaySideOpInput["missionType"],
|
|
estimatedDuration: Number(estimatedDuration) || 240,
|
|
maxPlayers: Number(maxPlayers) || 32,
|
|
...modsInput(),
|
|
...(campaignChanged
|
|
? campaignChoice === "none"
|
|
? { noCampaign: true }
|
|
: campaignChoice === "new"
|
|
? { newCampaignName: newCampaignName || null }
|
|
: { campaignId: Number(campaignChoice) }
|
|
: {}),
|
|
...narrativeInput(),
|
|
});
|
|
if (!result.success) {
|
|
setError(result.error ?? "Failed to update the side operation.");
|
|
return;
|
|
}
|
|
toast.success("Side operation updated.");
|
|
onOpenChange(false);
|
|
onSaved?.();
|
|
return;
|
|
}
|
|
|
|
const result = await createFridaySideOp({
|
|
name,
|
|
codeName: codeName.trim() || undefined,
|
|
summary,
|
|
...(coverImage ? { coverImageId: coverImage.id } : {}),
|
|
...mapInput(),
|
|
missionType: missionType as FridaySideOpInput["missionType"],
|
|
startDateTime: startDateIso,
|
|
estimatedDuration: Number(estimatedDuration) || 240,
|
|
maxPlayers: Number(maxPlayers) || 32,
|
|
...modsInput(),
|
|
campaignId: campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
|
|
newCampaignName: campaignChoice === "new" ? newCampaignName || null : null,
|
|
noCampaign: campaignChoice === "none",
|
|
...narrativeInput(),
|
|
});
|
|
if (!result.success) {
|
|
setError(result.error ?? "Failed to schedule the side operation.");
|
|
return;
|
|
}
|
|
toast.success("Side operation scheduled.");
|
|
onOpenChange(false);
|
|
onSaved?.();
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEdit ? "Edit Side Operation" : "Schedule Side Operation"}</DialogTitle>
|
|
<DialogDescription>
|
|
{isEdit
|
|
? "Update the details of this Friday side operation."
|
|
: "Player-run Friday side operations include Discord roll-call and RSVP automatically."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-4 py-2">
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="sideop-name">Mission name</Label>
|
|
<Input
|
|
id="sideop-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Operation Broken Arrow"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="sideop-code">
|
|
Code name <span className="text-xs text-muted-foreground">(optional)</span>
|
|
</Label>
|
|
<Input
|
|
id="sideop-code"
|
|
value={codeName}
|
|
onChange={(e) => setCodeName(e.target.value)}
|
|
placeholder="OP:BRKARW"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="sideop-summary">Summary</Label>
|
|
<Textarea
|
|
id="sideop-summary"
|
|
value={summary}
|
|
onChange={(e) => setSummary(e.target.value)}
|
|
placeholder="What is this operation about?"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="sideop-cover">Cover image</Label>
|
|
<Input
|
|
id="sideop-cover"
|
|
type="file"
|
|
accept="image/*"
|
|
disabled={coverImageUploading}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) void handleCoverImageUpload(file);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
{coverImage && (
|
|
<img
|
|
src={coverImage.url}
|
|
alt="Mission cover preview"
|
|
className="h-28 w-full rounded-md object-cover"
|
|
/>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
Optional background image for the mission page header. 16:9 or wider works best, 8 MB
|
|
max.
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="grid gap-1.5">
|
|
<Label>Map</Label>
|
|
<Select
|
|
value={mapChoice}
|
|
onValueChange={(v) => {
|
|
setMapChoice(v);
|
|
if (v !== "custom" && v !== "") setMapId(v);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Choose a map" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{maps.map((m) => (
|
|
<SelectItem key={m.id} value={String(m.id)}>
|
|
{m.name}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value="custom">+ Custom map…</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<Label>Mission type</Label>
|
|
<Select value={missionType} onValueChange={setMissionType}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{MISSION_TYPES.map((t) => (
|
|
<SelectItem key={t} value={t}>
|
|
{t}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="sideop-duration">Est. duration (minutes)</Label>
|
|
<Input
|
|
id="sideop-duration"
|
|
type="number"
|
|
min={15}
|
|
step={15}
|
|
value={estimatedDuration}
|
|
onChange={(e) => setEstimatedDuration(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="sideop-players">Max players</Label>
|
|
<Input
|
|
id="sideop-players"
|
|
type="number"
|
|
min={2}
|
|
value={maxPlayers}
|
|
onChange={(e) => setMaxPlayers(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<Label>Campaign</Label>
|
|
<Select value={campaignChoice} onValueChange={setCampaignChoice}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Choose a campaign" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">No campaign (one-off op)</SelectItem>
|
|
{campaigns.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)} disabled={c.disabled}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value="new">+ New campaign…</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
{mapChoice === "custom" && (
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="sideop-custom-map">Custom map name</Label>
|
|
<Input
|
|
id="sideop-custom-map"
|
|
value={customMapName}
|
|
onChange={(e) => setCustomMapName(e.target.value)}
|
|
placeholder="e.g. Altis Special Branch Edit"
|
|
/>
|
|
</div>
|
|
)}
|
|
{campaignChoice === "new" && (
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="sideop-campaign">New campaign name</Label>
|
|
<Input
|
|
id="sideop-campaign"
|
|
value={newCampaignName}
|
|
onChange={(e) => setNewCampaignName(e.target.value)}
|
|
placeholder="Leave empty to use your personal side ops campaign"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label>Mod list</Label>
|
|
<Select value={requiredModsId} onValueChange={setRequiredModsId}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="No mod list (vanilla)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">No mod list (vanilla)</SelectItem>
|
|
{modLists.map((l) => (
|
|
<SelectItem key={l.id} value={String(l.id)}>
|
|
{l.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
type="file"
|
|
accept=".html,.htm,.txt,text/html,text/plain"
|
|
disabled={modlistUploading}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) void handleModlistUpload(file);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod
|
|
list, or pick an existing one.
|
|
</p>
|
|
</div>
|
|
|
|
<Collapsible open={narrativeOpen} onOpenChange={setNarrativeOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button type="button" variant="outline" className="w-full justify-between">
|
|
Narrative & logistics (optional)
|
|
<span className="text-xs text-muted-foreground">
|
|
{narrativeOpen ? "Hide" : "Show"}
|
|
</span>
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-3">
|
|
<SideOpNarrativeFields value={narrative} onChange={setNarrative} />
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={isPending}>
|
|
{isPending ? "Saving…" : isEdit ? "Save changes" : "Schedule"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|