1
0
Fork 0
polaris-task-force/src/app/(frontend)/intelligence/missions/[id]/page.tsx

589 lines
24 KiB
TypeScript

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<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",
};
const operationTypeColors: Record<string, string> = {
main: "bg-primary/20 text-primary",
side: "bg-muted text-muted-foreground",
external: "bg-purple-500/20 text-purple-400",
};
const operationTypeLabels: Record<string, string> = {
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<Record<number, AttendanceResponse>>({}),
]);
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 (
<div className="flex flex-col">
<header className="relative min-h-[500px] w-full overflow-hidden bg-background">
<div className="absolute top-0 left-0 px-5 pt-5 pb-3 z-10">
<Link
href="/intelligence/missions"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Back to Missions
</Link>
</div>
{coverImageUrl ? (
<img
src={coverImageUrl}
alt=""
fetchPriority="high"
className="absolute inset-0 size-full object-cover opacity-70"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-b from-muted/40 to-background" />
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-background" />
<div className="absolute inset-0 bg-gradient-to-r from-black/30 via-transparent to-black/40" />
<div className="absolute top-4 right-4 z-20 flex gap-2">
<span
className={`rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
>
{mission.ownershipAndStatus.status}
</span>
<span
className={`rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`}
>
{operationTypeLabels[mission.operationType] ?? mission.operationType}
</span>
</div>
<div className="absolute inset-0 flex flex-col justify-end p-6 md:p-10 lg:p-12">
<div className="mx-auto flex w-full max-w-7xl flex-col">
<div className="flex items-center gap-2 font-mono text-sm uppercase tracking-widest text-white/60">
<CrosshairIcon className="size-4" />
<span>{mission.codeName}</span>
</div>
<h1
id="mission-title"
className="mt-3 text-5xl font-bold tracking-tight text-white drop-shadow-2xl md:text-6xl"
>
{mission.name}
</h1>
<p className="mt-2 max-w-3xl text-lg leading-relaxed text-white/80">
{mission.summary}
</p>
<div className="mt-6 flex flex-wrap gap-3">
<div className="flex items-center gap-2 px-3 py-2">
<CalendarDaysIcon className="size-4 text-white/60" />
<span className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Date
</span>
<span className="text-sm text-white">
<ClientDate
date={mission.classification.startDateTime}
options={{ weekday: "long", year: "numeric", month: "long", day: "numeric" }}
/>
</span>
</span>
</div>
<div className="flex items-center gap-2 px-3 py-2">
<ClockIcon className="size-4 text-white/60" />
<span className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Time
</span>
<span className="text-sm text-white">
<ClientDate
date={mission.classification.startDateTime}
options={{ hour: "2-digit", minute: "2-digit" }}
/>{" "}
· {durationStr}
</span>
</span>
</div>
{map && (
<div className="flex items-center gap-2 px-3 py-2">
<MapPinIcon className="size-4 text-white/60" />
<span className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Map
</span>
<span className="text-sm text-white">{map.name}</span>
</span>
</div>
)}
{hostLabel && (
<div className="flex items-center gap-2 px-3 py-2">
<UserIcon className="size-4 text-white/60" />
<span className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Host
</span>
<span className="max-w-56 truncate text-sm text-white">{hostLabel}</span>
</span>
</div>
)}
{campaign && (
<Link
href={`/intelligence/campaigns/${campaign.id}`}
className="flex items-center gap-2 px-3 py-2 transition-colors hover:border-white/30"
>
<FlagIcon className="size-4 text-white/60" />
<span className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Campaign
</span>
<span className="max-w-56 truncate text-sm text-white">{campaign.name}</span>
</span>
</Link>
)}
</div>
</div>
</div>
</header>
<div className="p-5 flex flex-col gap-6 max-w-7xl mx-auto w-full">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* ── Main Content (2/3) ──────────────────────────── */}
<div className="xl:col-span-2 flex flex-col gap-6">
<Item variant="outline">
<ItemHeader>
<ItemTitle>Mission Details</ItemTitle>
</ItemHeader>
<ItemContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Type</span>
<p className="font-medium">{mission.classification.missionType}</p>
</div>
<div>
<span className="text-muted-foreground">Operation Type</span>
<p className="font-medium capitalize">{mission.operationType}</p>
</div>
<div>
<span className="text-muted-foreground">Max Players</span>
<p className="font-medium">{mission.missionRoles?.maxPlayers ?? "-"}</p>
</div>
</div>
</ItemContent>
</Item>
<MissionBriefingSection briefing={mission.briefing ?? null} />
<MissionObjectives
objectives={objectives}
missionId={mission.id}
isEditable={canManage}
canReveal={canReveal}
/>
<MissionFactions factionsInvolved={mission.factionsInvolved ?? null} />
<MissionRoles
roles={mission.missionRoles?.providedRoles ?? null}
notes={mission.missionRoles?.notes ?? null}
maxPlayers={mission.missionRoles?.maxPlayers ?? 32}
/>
<MissionLogistics logisticsAndSupport={mission.logisticsAndSupport ?? null} />
<MissionComms commsAndDocs={mission.commsAndDocs ?? null} />
</div>
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
<div className="flex flex-col gap-4">
{isSideOpOwner && mission.createdBy != null && (
<SideOpControls
initial={{
missionId: mission.id,
name: mission.name,
codeName: mission.codeName,
summary: mission.summary,
mapId:
typeof mission.classification.map === "object"
? mission.classification.map.id
: mission.classification.map,
missionType: mission.classification.missionType,
startDateTime: mission.classification.startDateTime,
estimatedDuration: mission.classification.estimatedDuration,
maxPlayers: mission.missionRoles?.maxPlayers ?? 32,
campaignId:
typeof mission.classification.campaign === "object" &&
mission.classification.campaign !== null
? mission.classification.campaign.id
: (mission.classification.campaign ?? null),
briefing: (mission.briefing ?? []).map((entry) => ({
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}
/>
)}
<MissionAttendance
missionId={mission.id}
currentUserId={currentUser ? (currentUser.id as number) : null}
currentResponse={currentResponse}
attendanceClosed={attendanceClosed}
{...attendanceProps}
/>
{/* Campaign Card */}
{campaign && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<FlagIcon className="size-4 text-muted-foreground" />
<ItemTitle>Campaign</ItemTitle>
</div>
<Link
href={`/intelligence/campaigns/${campaign.id}`}
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
View
</Link>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
<div>
<h3 className="text-sm font-semibold">{campaign.name}</h3>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded mt-1 inline-block ${campaign.status === "inProgress" ? "bg-blue-500/20 text-blue-400" : "bg-muted text-muted-foreground"}`}
>
{campaign.status === "inProgress" ? "In Progress" : campaign.status}
</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-3">{campaign.summary}</p>
</div>
</ItemContent>
</Item>
)}
{/* Sibling Missions */}
{siblingMissions.length > 0 && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<ItemTitle>Other Missions in Campaign</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-1.5">
{siblingMissions.map((m) => {
const mDate = new Date(m.classification.startDateTime);
return (
<Link
key={m.id}
href={`/intelligence/missions/${m.id}`}
className="flex items-center gap-3 p-1.5 rounded-md hover:bg-muted/50 transition-colors group"
>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-mono text-[10px] text-muted-foreground">
{m.codeName}
</span>
<span className="text-sm truncate group-hover:text-primary transition-colors">
{m.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1 py-0.5 rounded ${statusColors[m.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
>
{m.ownershipAndStatus.status}
</span>
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</Link>
);
})}
</div>
</ItemContent>
</Item>
)}
{/* Server Details */}
{mission.gameDetails?.serverDetails?.serverIp && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ServerIcon className="size-4 text-muted-foreground" />
<ItemTitle>Server</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-1.5 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Address</span>
<span className="font-mono">
{mission.gameDetails.serverDetails.serverIp}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Port</span>
<span className="font-mono">
{mission.gameDetails.serverDetails.serverPort}
</span>
</div>
</div>
</ItemContent>
</Item>
)}
</div>
</div>
</div>
</div>
);
}