594 lines
24 KiB
TypeScript
594 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,
|
|
})),
|
|
};
|
|
|
|
const attendanceCard = (
|
|
<MissionAttendance
|
|
missionId={mission.id}
|
|
currentUserId={currentUser ? (currentUser.id as number) : null}
|
|
currentResponse={currentResponse}
|
|
attendanceClosed={attendanceClosed}
|
|
{...attendanceProps}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
<header className="relative flex min-h-[500px] w-full flex-col 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="relative z-10 mt-auto flex flex-col px-5 pb-6 pt-14 md:absolute md:inset-0 md:justify-end md:px-10 md:py-10 lg:px-12">
|
|
<div className="mx-auto flex w-full max-w-7xl flex-col">
|
|
<div className="z-20 mb-4 flex flex-wrap gap-2 md:absolute md:top-4 md:right-4 md:mb-0">
|
|
<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="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-4xl font-bold tracking-tight text-white drop-shadow-2xl md:text-6xl"
|
|
>
|
|
{mission.name}
|
|
</h1>
|
|
<p className="mt-2 max-w-3xl text-base leading-relaxed text-white/80 md:text-lg">
|
|
{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">
|
|
{/* Attendance sits right under the hero on stacked (below-xl) layouts */}
|
|
<div className="xl:hidden">{attendanceCard}</div>
|
|
<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}
|
|
/>
|
|
)}
|
|
|
|
<div className="hidden xl:block">{attendanceCard}</div>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|