diff --git a/src/app/(frontend)/intelligence/missions/calendar/page.tsx b/src/app/(frontend)/intelligence/missions/calendar/page.tsx
new file mode 100644
index 0000000..9055eb8
--- /dev/null
+++ b/src/app/(frontend)/intelligence/missions/calendar/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/frontend/intelligence/CreateSideOpDialog.tsx b/src/components/frontend/intelligence/CreateSideOpDialog.tsx
new file mode 100644
index 0000000..be2e210
--- /dev/null
+++ b/src/components/frontend/intelligence/CreateSideOpDialog.tsx
@@ -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(initial?.mapId ? String(initial.mapId) : "");
+ const [mapChoice, setMapChoice] = useState(
+ initial?.mapId ? String(initial.mapId) : "existing",
+ );
+ const [customMapName, setCustomMapName] = useState("");
+ const [missionType, setMissionType] = useState(initial?.missionType ?? "Zeus");
+ const [requiredModsId, setRequiredModsId] = useState(
+ 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(
+ initial?.campaignId ? String(initial.campaignId) : "none",
+ );
+ const [newCampaignName, setNewCampaignName] = useState("");
+ const [narrative, setNarrative] = useState(defaultNarrative);
+ const [narrativeOpen, setNarrativeOpen] = useState(false);
+ const [error, setError] = useState(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 (
+
+ );
+}
diff --git a/src/components/frontend/intelligence/FridayCalendar.tsx b/src/components/frontend/intelligence/FridayCalendar.tsx
new file mode 100644
index 0000000..fd43057
--- /dev/null
+++ b/src/components/frontend/intelligence/FridayCalendar.tsx
@@ -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 = {
+ 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(null);
+
+ const upcoming = useMemo(() => entries, [entries]);
+
+ return (
+
+
+
+
Friday Operations
+
+ Schedule and manage player-run Friday side operations. Each Friday gets Discord
+ roll-call and RSVP automatically.
+
+
+
+ 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)`}
+
+
+
+
+
+ {upcoming.map((entry) => {
+ const fridayTaken = entry.missions.some(
+ (m) => m.status !== "Completed" && m.status !== "Cancelled",
+ );
+ const canSchedule = remainingSlots > 0 && !fridayTaken;
+ return (
+
-
+
+
+
+ {formatFriday(entry.fridayIso)}
+
+
+
+
+ {entry.missions.length === 0 ? (
+
+ Nothing scheduled yet — claim this Friday.
+
+ ) : (
+
+ {entry.missions.map((m) => (
+
+
+ {formatTime(m.startDateTime)}
+
+
+
+ {m.name}
+
+
+ {m.codeName}
+ {m.mapName ? ` · ${m.mapName}` : ""}
+ {m.isOwn ? " · yours" : ""}
+
+
+
+ {m.status}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ Fridays beyond this date are unavailable for scheduling via the web app.
If you want to schedule an
+ op further out than the dates listed here, please contact Jason (Z8MB1E) directly.
+
+
+
{
+ if (!open) setScheduleFor(null);
+ }}
+ maps={maps}
+ campaigns={campaigns}
+ modLists={modLists}
+ defaultStartDateTime={scheduleFor ? defaultStartForFriday(scheduleFor) : undefined}
+ onSaved={() => router.refresh()}
+ />
+
+ );
+}
diff --git a/src/components/frontend/intelligence/SideOpControls.tsx b/src/components/frontend/intelligence/SideOpControls.tsx
new file mode 100644
index 0000000..8839521
--- /dev/null
+++ b/src/components/frontend/intelligence/SideOpControls.tsx
@@ -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 (
+ -
+
+ Side Operation Controls
+
+
+
+
+
+
+
+ Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot
+ and updates the Discord embed.
+
+
+
+ router.refresh()}
+ />
+
+ );
+}
diff --git a/src/components/frontend/intelligence/SideOpNarrativeFields.tsx b/src/components/frontend/intelligence/SideOpNarrativeFields.tsx
new file mode 100644
index 0000000..1320bb6
--- /dev/null
+++ b/src/components/frontend/intelligence/SideOpNarrativeFields.tsx
@@ -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 (
+
+
+ Plain text only — each line becomes a paragraph. Rich formatting written in the admin panel
+ is flattened if you save here.
+
+
+
+
+ {value.briefing.map((entry, i) => (
+
+
+ {
+ const next = [...value.briefing];
+ next[i] = { ...entry, sectionName: e.target.value };
+ setBriefing(next);
+ }}
+ placeholder="Section name (e.g. Situation)"
+ />
+
+
+
+ ))}
+ {value.briefing.length < 8 && (
+
+ )}
+
+
+
+
+ {value.missionObjectives.map((obj, i) => (
+
+
+ {
+ const next = [...value.missionObjectives];
+ next[i] = { ...obj, title: e.target.value };
+ setObjectives(next);
+ }}
+ placeholder="Objective title"
+ />
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {INFIL_EXFIL_METHODS.map((m) => (
+
+ ))}
+
+
+
+
+
+ {INFIL_EXFIL_METHODS.map((m) => (
+
+ ))}
+
+
+
+
+
+
+ {value.assetsAvailable.map((asset, i) => (
+
+ ))}
+
+
+
+ );
+}