feat(missions): add Friday Ops calendar page and side-op dialog UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
7664aad4ef
commit
03f699d852
5 changed files with 1311 additions and 0 deletions
158
src/app/(frontend)/intelligence/missions/calendar/page.tsx
Normal file
158
src/app/(frontend)/intelligence/missions/calendar/page.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import {
|
||||||
|
FridayCalendar,
|
||||||
|
type FridayCalendarEntry,
|
||||||
|
type FridayCalendarMission,
|
||||||
|
} from "@/components/frontend/intelligence/FridayCalendar";
|
||||||
|
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Friday Operations",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_SCHEDULED_AHEAD_MISSIONS = 2;
|
||||||
|
|
||||||
|
const FRIDAYS_SHOWN = 8;
|
||||||
|
|
||||||
|
function nextFridays(count: number): Date[] {
|
||||||
|
const fridays: Date[] = [];
|
||||||
|
const cursor = new Date();
|
||||||
|
cursor.setHours(0, 0, 0, 0);
|
||||||
|
while (fridays.length < count) {
|
||||||
|
if (cursor.getDay() === 5) {
|
||||||
|
fridays.push(new Date(cursor));
|
||||||
|
}
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
return fridays;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FridayCalendarPage() {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fridays = nextFridays(FRIDAYS_SHOWN);
|
||||||
|
const rangeStart = fridays[0];
|
||||||
|
const rangeEnd = new Date(fridays[fridays.length - 1]);
|
||||||
|
rangeEnd.setDate(rangeEnd.getDate() + 1);
|
||||||
|
|
||||||
|
const missionsRes = await payload.find({
|
||||||
|
collection: "missions",
|
||||||
|
where: {
|
||||||
|
"classification.startDateTime": {
|
||||||
|
greater_than_equal: rangeStart.toISOString(),
|
||||||
|
less_than: rangeEnd.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sort: "classification.startDateTime",
|
||||||
|
depth: 1,
|
||||||
|
limit: 200,
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
codeName: true,
|
||||||
|
operationType: true,
|
||||||
|
ownershipAndStatus: { status: true },
|
||||||
|
classification: { startDateTime: true, map: true },
|
||||||
|
createdBy: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries: FridayCalendarEntry[] = fridays.map((friday) => {
|
||||||
|
const dayStart = new Date(friday);
|
||||||
|
const dayEnd = new Date(friday);
|
||||||
|
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||||
|
const missions: FridayCalendarMission[] = missionsRes.docs
|
||||||
|
.filter((m) => {
|
||||||
|
const start = new Date(m.classification.startDateTime);
|
||||||
|
return start >= dayStart && start < dayEnd;
|
||||||
|
})
|
||||||
|
.map((m) => {
|
||||||
|
const map = m.classification.map;
|
||||||
|
const createdBy = m.createdBy;
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
codeName: m.codeName,
|
||||||
|
status: m.ownershipAndStatus.status,
|
||||||
|
startDateTime: m.classification.startDateTime,
|
||||||
|
operationType: m.operationType,
|
||||||
|
mapName: typeof map === "object" && map !== null ? map.name : null,
|
||||||
|
isOwn:
|
||||||
|
user != null &&
|
||||||
|
(typeof createdBy === "object" ? createdBy?.id : createdBy) === user.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { fridayIso: friday.toISOString(), missions };
|
||||||
|
});
|
||||||
|
|
||||||
|
let remainingSlots = 0;
|
||||||
|
let maps: SideOpDialogOption[] = [];
|
||||||
|
let campaigns: SideOpDialogOption[] = [];
|
||||||
|
let modLists: SideOpDialogOption[] = [];
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const userId = user.id as number;
|
||||||
|
const [scheduledCount, mapsRes, campaignsRes, modListsRes] = await Promise.all([
|
||||||
|
payload.count({
|
||||||
|
collection: "missions",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ createdBy: { equals: userId } },
|
||||||
|
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||||
|
{ "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
payload.find({
|
||||||
|
collection: "maps",
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
limit: 200,
|
||||||
|
sort: "name",
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
|
payload.find({
|
||||||
|
collection: "campaigns",
|
||||||
|
where: { owner: { equals: userId } },
|
||||||
|
depth: 0,
|
||||||
|
limit: 100,
|
||||||
|
sort: "name",
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
|
payload.find({
|
||||||
|
collection: "mod-lists",
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
limit: 200,
|
||||||
|
sort: "modlistName",
|
||||||
|
select: { modlistName: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
remainingSlots = Math.max(0, MAX_SCHEDULED_AHEAD_MISSIONS - scheduledCount.totalDocs);
|
||||||
|
maps = mapsRes.docs.map((m) => ({ id: m.id, name: m.name }));
|
||||||
|
campaigns = campaignsRes.docs.map((c) => ({ id: c.id, name: c.name }));
|
||||||
|
modLists = modListsRes.docs.map((l) => ({ id: l.id, name: l.modlistName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="mx-auto w-full max-w-5xl">
|
||||||
|
<FridayCalendar
|
||||||
|
entries={entries}
|
||||||
|
remainingSlots={remainingSlots}
|
||||||
|
maxSlots={MAX_SCHEDULED_AHEAD_MISSIONS}
|
||||||
|
maps={maps}
|
||||||
|
campaigns={campaigns}
|
||||||
|
modLists={modLists}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
513
src/components/frontend/intelligence/CreateSideOpDialog.tsx
Normal file
513
src/components/frontend/intelligence/CreateSideOpDialog.tsx
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
"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 {
|
||||||
|
SideOpNarrativeFields,
|
||||||
|
type SideOpNarrative,
|
||||||
|
} from "@/components/frontend/intelligence/SideOpNarrativeFields";
|
||||||
|
|
||||||
|
const MISSION_TYPES = ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"] as const;
|
||||||
|
const DEFAULT_BRIEFING_SECTIONS = ["Situation", "Mission", "Execution", "Signals"];
|
||||||
|
|
||||||
|
function defaultNarrative(): SideOpNarrative {
|
||||||
|
return {
|
||||||
|
briefing: DEFAULT_BRIEFING_SECTIONS.map((sectionName) => ({ sectionName, details: "" })),
|
||||||
|
missionObjectives: [],
|
||||||
|
civilianPresence: "None",
|
||||||
|
radioPlan: "",
|
||||||
|
insertion: [],
|
||||||
|
extraction: [],
|
||||||
|
assetsAvailable: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SideOpDialogOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SideOpDialogInitial {
|
||||||
|
missionId: number;
|
||||||
|
name: string;
|
||||||
|
codeName: string;
|
||||||
|
summary: string;
|
||||||
|
mapId: number | null;
|
||||||
|
missionType: string;
|
||||||
|
startDateTime: string;
|
||||||
|
estimatedDuration: number;
|
||||||
|
maxPlayers: number;
|
||||||
|
campaignId: number | null;
|
||||||
|
requiredModsId?: number | null;
|
||||||
|
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 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 [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setError(null);
|
||||||
|
setNarrativeOpen(initial !== undefined);
|
||||||
|
if (initial) {
|
||||||
|
setName(initial.name);
|
||||||
|
setCodeName(initial.codeName);
|
||||||
|
setSummary(initial.summary);
|
||||||
|
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 ?? "",
|
||||||
|
insertion: initial.insertion ?? [],
|
||||||
|
extraction: initial.extraction ?? [],
|
||||||
|
assetsAvailable: initial.assetsAvailable ?? [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNarrative(defaultNarrative());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, initial]);
|
||||||
|
|
||||||
|
function narrativeInput() {
|
||||||
|
return {
|
||||||
|
briefing: narrative.briefing
|
||||||
|
.filter((e) => e.sectionName.trim() !== "" || e.details.trim() !== "")
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim() || !codeName.trim() || !summary.trim()) {
|
||||||
|
setError("Name, code 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,
|
||||||
|
summary,
|
||||||
|
...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,
|
||||||
|
summary,
|
||||||
|
...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</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 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,text/html"
|
||||||
|
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 HTML mod list exported from the Arma 3 launcher, 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 (briefing, objectives, infil/exfil)
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/components/frontend/intelligence/FridayCalendar.tsx
Normal file
190
src/components/frontend/intelligence/FridayCalendar.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CalendarDaysIcon, ChevronRightIcon, PlusIcon } from "lucide-react";
|
||||||
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CreateSideOpDialog, type SideOpDialogOption } from "./CreateSideOpDialog";
|
||||||
|
|
||||||
|
export interface FridayCalendarMission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
codeName: string;
|
||||||
|
status: string;
|
||||||
|
startDateTime: string;
|
||||||
|
operationType: string;
|
||||||
|
mapName: string | null;
|
||||||
|
isOwn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FridayCalendarEntry {
|
||||||
|
fridayIso: string;
|
||||||
|
missions: FridayCalendarMission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FridayCalendarProps {
|
||||||
|
entries: FridayCalendarEntry[];
|
||||||
|
remainingSlots: number;
|
||||||
|
maxSlots: number;
|
||||||
|
maps: SideOpDialogOption[];
|
||||||
|
campaigns: SideOpDialogOption[];
|
||||||
|
modLists: SideOpDialogOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
Concept: "bg-muted text-muted-foreground",
|
||||||
|
Planning: "bg-muted text-muted-foreground",
|
||||||
|
Ready: "bg-blue-500/20 text-blue-400",
|
||||||
|
Scheduled: "bg-amber-500/20 text-amber-400",
|
||||||
|
Active: "bg-green-500/20 text-green-400",
|
||||||
|
Completed: "bg-emerald-500/20 text-emerald-400",
|
||||||
|
Cancelled: "bg-red-500/20 text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatFriday(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStartForFriday(fridayIso: string): string {
|
||||||
|
const date = new Date(fridayIso);
|
||||||
|
date.setHours(20, 0, 0, 0);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FridayCalendar({
|
||||||
|
entries,
|
||||||
|
remainingSlots,
|
||||||
|
maxSlots,
|
||||||
|
maps,
|
||||||
|
campaigns,
|
||||||
|
modLists,
|
||||||
|
}: FridayCalendarProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [scheduleFor, setScheduleFor] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const upcoming = useMemo(() => entries, [entries]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Friday Operations</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Schedule and manage player-run Friday side operations. Each Friday gets Discord
|
||||||
|
roll-call and RSVP automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs font-medium ${
|
||||||
|
remainingSlots > 0
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "border-red-500/30 bg-red-500/10 text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{remainingSlots > 0
|
||||||
|
? `You can schedule ${remainingSlots} more mission${remainingSlots === 1 ? "" : "s"}`
|
||||||
|
: `Scheduling limit reached (${maxSlots} ahead)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{upcoming.map((entry) => {
|
||||||
|
const fridayTaken = entry.missions.some(
|
||||||
|
(m) => m.status !== "Completed" && m.status !== "Cancelled",
|
||||||
|
);
|
||||||
|
const canSchedule = remainingSlots > 0 && !fridayTaken;
|
||||||
|
return (
|
||||||
|
<Item key={entry.fridayIso} variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarDaysIcon className="size-4 text-muted-foreground" />
|
||||||
|
<ItemTitle>{formatFriday(entry.fridayIso)}</ItemTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canSchedule}
|
||||||
|
onClick={() => setScheduleFor(entry.fridayIso)}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
{fridayTaken ? "Taken" : "Schedule"}
|
||||||
|
</Button>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
{entry.missions.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nothing scheduled yet — claim this Friday.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{entry.missions.map((m) => (
|
||||||
|
<Link
|
||||||
|
key={m.id}
|
||||||
|
href={`/intelligence/missions/${m.id}`}
|
||||||
|
className="group flex items-center gap-3 rounded-md p-1.5 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="w-14 shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{formatTime(m.startDateTime)}
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm transition-colors group-hover:text-primary">
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{m.codeName}
|
||||||
|
{m.mapName ? ` · ${m.mapName}` : ""}
|
||||||
|
{m.isOwn ? " · yours" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
||||||
|
statusColors[m.status] ?? "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.status}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Fridays beyond this date are unavailable for scheduling via the web app.<br/><br/>If you want to schedule an
|
||||||
|
op further out than the dates listed here, please contact <b>Jason (Z8MB1E)</b> directly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CreateSideOpDialog
|
||||||
|
open={scheduleFor !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setScheduleFor(null);
|
||||||
|
}}
|
||||||
|
maps={maps}
|
||||||
|
campaigns={campaigns}
|
||||||
|
modLists={modLists}
|
||||||
|
defaultStartDateTime={scheduleFor ? defaultStartForFriday(scheduleFor) : undefined}
|
||||||
|
onSaved={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/frontend/intelligence/SideOpControls.tsx
Normal file
98
src/components/frontend/intelligence/SideOpControls.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { PencilIcon, XCircleIcon } from "lucide-react";
|
||||||
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions";
|
||||||
|
import {
|
||||||
|
CreateSideOpDialog,
|
||||||
|
type SideOpDialogInitial,
|
||||||
|
type SideOpDialogOption,
|
||||||
|
} from "./CreateSideOpDialog";
|
||||||
|
|
||||||
|
interface SideOpControlsProps {
|
||||||
|
initial: SideOpDialogInitial;
|
||||||
|
status: string;
|
||||||
|
maps: SideOpDialogOption[];
|
||||||
|
campaigns: SideOpDialogOption[];
|
||||||
|
modLists: SideOpDialogOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideOpControls({
|
||||||
|
initial,
|
||||||
|
status,
|
||||||
|
maps,
|
||||||
|
campaigns,
|
||||||
|
modLists,
|
||||||
|
}: SideOpControlsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const isTerminal = status === "Completed" || status === "Cancelled";
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
if (!confirmingCancel) {
|
||||||
|
setConfirmingCancel(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await cancelFridaySideOp(initial.missionId);
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error ?? "Failed to cancel the side operation.");
|
||||||
|
setConfirmingCancel(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Side operation cancelled. Discord roll-call will be updated.");
|
||||||
|
setConfirmingCancel(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTerminal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle>Side Operation Controls</ItemTitle>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
|
||||||
|
<PencilIcon className="size-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={confirmingCancel ? "destructive" : "outline"}
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<XCircleIcon className="size-3.5" />
|
||||||
|
{confirmingCancel ? "Confirm cancel?" : "Cancel mission"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot
|
||||||
|
and updates the Discord embed.
|
||||||
|
</p>
|
||||||
|
</ItemContent>
|
||||||
|
|
||||||
|
<CreateSideOpDialog
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
maps={maps}
|
||||||
|
campaigns={campaigns}
|
||||||
|
modLists={modLists}
|
||||||
|
initial={initial}
|
||||||
|
onSaved={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
src/components/frontend/intelligence/SideOpNarrativeFields.tsx
Normal file
352
src/components/frontend/intelligence/SideOpNarrativeFields.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue