import config from "@payload-config"; import { getPayload } from "payload"; import { notFound } from "next/navigation"; import Link from "next/link"; import { headers as nextHeaders } from "next/headers"; import type { Campaign, Map, Media, Mission, User } from "@/payload-types"; import { ArrowLeftIcon, CalendarDaysIcon, ChevronRightIcon, ClockIcon, CrosshairIcon, FlagIcon, MapPinIcon, ServerIcon, UserIcon, } from "lucide-react"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { ClientDate } from "@/components/frontend/ClientDate"; import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection"; import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives"; import { hasPermission } from "@/utils/access-control/hasPermission"; import { redactText } from "@/lib/redaction"; import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions"; import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles"; import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics"; import { MissionComms } from "@/components/frontend/intelligence/MissionComms"; import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance"; import { SideOpControls } from "@/components/frontend/intelligence/SideOpControls"; import { lexicalToText } from "@/lib/intelligence/briefingText"; import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog"; import { type AttendanceResponse, getMissionAttendance, getUserAttendance, isMissionPast, } from "@/lib/attendance"; interface MissionDetailPageProps { params: Promise<{ id: string }>; } type MissionWithCoverImage = Mission & { coverImage?: number | null | Media; }; 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", }; const operationTypeColors: Record = { main: "bg-primary/20 text-primary", side: "bg-muted text-muted-foreground", external: "bg-purple-500/20 text-purple-400", }; const operationTypeLabels: Record = { main: "Main Operation", side: "Side Operation", external: "External Operation", }; export default async function MissionDetailPage({ params }: MissionDetailPageProps) { const { id } = await params; const payload = await getPayload({ config }); const mission = await payload .findByID({ collection: "missions", id: Number(id), depth: 2, }) .catch(() => null); if (!mission) { notFound(); } const map = typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null; const campaign = typeof mission.classification.campaign === "object" && mission.classification.campaign !== null ? (mission.classification.campaign as Campaign) : null; const missionWithCoverImage = mission as MissionWithCoverImage; const coverImage = typeof missionWithCoverImage.coverImage === "object" && missionWithCoverImage.coverImage !== null ? (missionWithCoverImage.coverImage as Media) : null; const coverImageUrl = coverImage?.url ?? null; const host = mission.operationType === "side" && typeof mission.createdBy === "object" && mission.createdBy !== null ? (mission.createdBy as User) : null; const hostLabel = host ? (host.displayName || host.username) : null; const durationHours = Math.floor(mission.classification.estimatedDuration / 60); const durationMins = mission.classification.estimatedDuration % 60; const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`; // Fetch sibling missions in the same campaign (missions may have no campaign) const campaignId = typeof mission.classification.campaign === "object" && mission.classification.campaign !== null ? (mission.classification.campaign as Campaign).id : mission.classification.campaign; const siblingMissionsRes = campaignId != null ? await payload .find({ collection: "missions", where: { and: [ { "classification.campaign": { equals: campaignId } }, { id: { not_equals: mission.id } }, ], }, sort: "-classification.startDateTime", limit: 5, depth: 0, select: { name: true, codeName: true, ownershipAndStatus: { status: true }, classification: { startDateTime: true }, }, }) .catch(() => ({ docs: [] })) : { docs: [] }; const siblingMissions = siblingMissionsRes.docs; const { user: currentUser } = await payload.auth({ headers: await nextHeaders(), canSetHeaders: false, }); const canManage = currentUser ? await hasPermission(payload, currentUser, "missions:update") : false; const createdById = typeof mission.createdBy === "object" ? mission.createdBy?.id : mission.createdBy; const isSideOpOwner = currentUser != null && createdById === currentUser.id; const canControlSideOp = canManage || isSideOpOwner; let sideOpMaps: SideOpDialogOption[] = []; let sideOpCampaigns: SideOpDialogOption[] = []; let sideOpModLists: SideOpDialogOption[] = []; if (canControlSideOp && currentUser) { const [mapsRes, campaignsRes, modListsRes] = await Promise.all([ payload.find({ collection: "maps", overrideAccess: true, depth: 0, limit: 200, sort: "name", select: { name: true }, }), payload.find({ collection: "campaigns", where: { owner: { equals: currentUser.id } }, 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 }, }), ]); sideOpMaps = mapsRes.docs.map((m) => ({ id: m.id, name: m.name })); sideOpCampaigns = campaignsRes.docs.map((c) => ({ id: c.id, name: c.name })); sideOpModLists = modListsRes.docs.map((l) => ({ id: l.id, name: l.modlistName })); const currentCampaignId = typeof mission.classification.campaign === "object" && mission.classification.campaign !== null ? mission.classification.campaign.id : mission.classification.campaign; if ( currentCampaignId != null && !sideOpCampaigns.some((c) => c.id === currentCampaignId) ) { sideOpCampaigns = [ ...sideOpCampaigns, { id: currentCampaignId as number, name: campaign ? `${campaign.name} (not yours)` : "Current campaign (not yours)", disabled: true, }, ]; } } const authorOrZeusIds = [ ...(mission.ownershipAndStatus.authors ?? []), ...(mission.ownershipAndStatus.zeus ?? []), ].map((u) => (typeof u === "object" ? u.id : u)); const canReveal = canManage || (currentUser ? authorOrZeusIds.includes(currentUser.id as number) : false); const objectives = (mission.missionObjectives ?? []).map((obj) => obj.redacted && !canReveal ? { ...obj, title: redactText(obj.title) ?? obj.title, description: redactText(obj.description), successOrFailConditions: redactText(obj.successOrFailConditions), } : obj, ); const [attendance, userAttendance] = await Promise.all([ getMissionAttendance(payload, mission.id), currentUser ? getUserAttendance(payload, currentUser.id as number, [mission.id]) : Promise.resolve>({}), ]); const currentResponse = userAttendance[mission.id] ?? null; const attendanceClosed = isMissionPast(mission.classification.startDateTime); const attendanceProps = { yes: attendance.yes.map((u) => ({ id: u.id, name: u.displayName || u.username })), no: attendance.no.map((u) => ({ id: u.id, name: u.displayName || u.username })), tentative: attendance.tentative.map((u) => ({ id: u.id, name: u.displayName || u.username, })), }; return (
Back to Missions
{coverImageUrl ? ( ) : (
)}
{mission.ownershipAndStatus.status} {operationTypeLabels[mission.operationType] ?? mission.operationType}
{mission.codeName}

{mission.name}

{mission.summary}

Date
Time {" "} · {durationStr}
{map && (
Map {map.name}
)} {hostLabel && (
Host {hostLabel}
)} {campaign && ( Campaign {campaign.name} )}
{/* ── Main Content (2/3) ──────────────────────────── */}
Mission Details
Type

{mission.classification.missionType}

Operation Type

{mission.operationType}

Max Players

{mission.missionRoles?.maxPlayers ?? "-"}

{/* ── Right Sidebar (1/3) ─────────────────────────── */}
{isSideOpOwner && mission.createdBy != null && ( ({ sectionName: entry.sectionName, details: lexicalToText(entry.details), })), missionObjectives: (mission.missionObjectives ?? []).map((obj) => ({ title: obj.title, description: obj.description ?? "", missionCritical: obj.missionCritical ?? true, successOrFailConditions: obj.successOrFailConditions ?? "", redacted: obj.redacted ?? false, })), civilianPresence: mission.civilianPresence ?? "None", radioPlan: lexicalToText(mission.commsAndDocs?.radioPlan ?? null), insertion: mission.logisticsAndSupport?.insertion ?? [], extraction: mission.logisticsAndSupport?.extraction ?? [], assetsAvailable: (mission.logisticsAndSupport?.assetsAvailable ?? []).map( (asset) => ({ assetType: asset.assetType, assetCount: asset.assetCount ?? "", doesRespawn: asset.doesRespawn ?? false, assetCallsign: asset.assetCallsign ?? "", }), ), requiredModsId: typeof mission.gameDetails.gameSetup?.requiredMods === "object" && mission.gameDetails.gameSetup?.requiredMods !== null ? mission.gameDetails.gameSetup.requiredMods.id : (mission.gameDetails.gameSetup?.requiredMods ?? null), }} status={mission.ownershipAndStatus.status} maps={sideOpMaps} campaigns={sideOpCampaigns} modLists={sideOpModLists} /> )} {/* Campaign Card */} {campaign && (
Campaign
View

{campaign.name}

{campaign.status === "inProgress" ? "In Progress" : campaign.status}

{campaign.summary}

)} {/* Sibling Missions */} {siblingMissions.length > 0 && (
Other Missions in Campaign
{siblingMissions.map((m) => { const mDate = new Date(m.classification.startDateTime); return (
{m.codeName} {m.name}
{m.ownershipAndStatus.status}
); })}
)} {/* Server Details */} {mission.gameDetails?.serverDetails?.serverIp && (
Server
Address {mission.gameDetails.serverDetails.serverIp}
Port {mission.gameDetails.serverDetails.serverPort}
)}
); }