1
0
Fork 0

feat: add intelligence section with campaigns, missions, and factions

- Add campaigns list page with campaign cards
- Add missions list page with filtering and mission cards
- Add factions list page with faction cards
- Add mission detail components: briefing, comms, factions, logistics, objectives, roles
- Add MissionFilters for status and type filtering
This commit is contained in:
Jason Fraley 2026-07-31 20:02:07 -04:00
parent 25c088aebf
commit 28a148c06f
18 changed files with 2229 additions and 0 deletions

View file

@ -0,0 +1,321 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Campaign, Faction, Mission } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react";
import {
ArrowLeftIcon,
CalendarDaysIcon,
ChevronRightIcon,
CrosshairIcon,
ShieldIcon,
TargetIcon,
UsersIcon,
} from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
interface CampaignDetailPageProps {
params: Promise<{ id: string }>;
}
const statusColors: Record<string, string> = {
concept: "bg-muted text-muted-foreground",
inProgress: "bg-blue-500/20 text-blue-400",
completed: "bg-emerald-500/20 text-emerald-400",
};
const factionTypeColors: Record<string, string> = {
majorFaction: "bg-blue-500/20 text-blue-400",
minorFaction: "bg-muted text-muted-foreground",
corporation: "bg-amber-500/20 text-amber-400",
special: "bg-purple-500/20 text-purple-400",
};
const missionStatusColors: 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",
};
export default async function CampaignDetailPage({
params,
}: CampaignDetailPageProps) {
const { id } = await params;
const payload = await getPayload({ config });
const campaign = await payload
.findByID({
collection: "campaigns",
id: Number(id),
depth: 0,
})
.catch(() => null);
if (!campaign) {
notFound();
}
const [missionsRes, factionsRes] = await Promise.all([
payload
.find({
collection: "missions",
where: { "classification.campaign": { equals: campaign.id } },
sort: "-classification.startDateTime",
limit: 100,
depth: 1,
select: {
name: true,
codeName: true,
summary: true,
operationType: true,
classification: true,
ownershipAndStatus: true,
missionRoles: true,
},
})
.catch(() => ({ docs: [] })),
payload.find({
collection: "factions",
where: { associatedCampaigns: { equals: campaign.id } },
limit: 100,
depth: 0,
select: { name: true, type: true, description: true },
}),
]);
const missions = missionsRes.docs as Mission[];
const factions = factionsRes.docs as Faction[];
const startDate = campaign.startDate
? new Date(campaign.startDate).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
const endDate = campaign.endDate
? new Date(campaign.endDate).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
// Mission status breakdown
const statusCounts: Record<string, number> = {};
for (const m of missions) {
const s = m.ownershipAndStatus.status;
statusCounts[s] = (statusCounts[s] ?? 0) + 1;
}
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<Link
href="/intelligence/campaigns"
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
>
<ArrowLeftIcon className="size-3" />
Back to Campaigns
</Link>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ShieldIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{campaign.name}</h1>
</div>
<div className="flex items-center gap-2">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[campaign.status] ?? "bg-muted text-muted-foreground"}`}
>
{campaign.status === "inProgress"
? "In Progress"
: campaign.status.charAt(0).toUpperCase() +
campaign.status.slice(1)}
</span>
{startDate && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<CalendarDaysIcon className="size-3" />
{startDate}
{endDate ? ` — ${endDate}` : " — Present"}
</span>
)}
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">{campaign.summary}</p>
<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">
{campaign.description && (
<Item variant="outline">
<ItemHeader>
<ItemTitle>Description</ItemTitle>
</ItemHeader>
<ItemContent className="prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
<RichText data={campaign.description} />
</ItemContent>
</Item>
)}
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Missions
</h2>
<span className="text-xs text-muted-foreground">
{missions.length} mission{missions.length !== 1 ? "s" : ""}
</span>
</div>
{missions.length > 0 ? (
<div className="flex flex-col gap-2">
{missions.map((mission) => (
<Link
key={mission.id}
href={`/intelligence/missions/${mission.id}`}
>
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
>
<ItemHeader>
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-muted-foreground">
{mission.codeName}
</span>
<ItemTitle>{mission.name}</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
missionStatusColors[
mission.ownershipAndStatus.status
] ?? "bg-muted text-muted-foreground"
}`}
>
{mission.ownershipAndStatus.status}
</span>
</ItemHeader>
<ItemContent>
<ItemDescription className="line-clamp-1">
{mission.summary}
</ItemDescription>
</ItemContent>
</Item>
</Link>
))}
</div>
) : (
<Item variant="muted">
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No missions in this campaign yet.
</p>
</ItemContent>
</Item>
)}
</section>
</div>
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
<div className="flex flex-col gap-4">
{/* Campaign Stats */}
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<TargetIcon className="size-4 text-muted-foreground" />
<ItemTitle>Campaign Stats</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Missions</span>
<span className="font-semibold">{missions.length}</span>
</div>
{Object.entries(statusCounts).map(([status, count]) => (
<div key={status} className="flex justify-between text-sm">
<span className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${missionStatusColors[status]?.split(" ")[0] ?? "bg-muted"}`}
/>
<span className="text-muted-foreground">{status}</span>
</span>
<span className="font-mono">{count}</span>
</div>
))}
{campaign.startDate && (
<div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Duration</span>
<span>
{startDate}
{endDate ? ` — ${endDate}` : " — Present"}
</span>
</div>
)}
</div>
</ItemContent>
</Item>
{/* Faction Roster */}
{factions.length > 0 && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<UsersIcon className="size-4 text-muted-foreground" />
<ItemTitle>Faction Roster</ItemTitle>
</div>
<Link
href="/intelligence/factions"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
View all
</Link>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
{factions.map((faction) => (
<Link
key={faction.id}
href="/intelligence/factions"
className="flex items-center gap-3 p-1.5 rounded-md hover:bg-muted/50 transition-colors group"
>
<ShieldIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
{faction.name}
</span>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1 py-0.5 rounded w-fit ${factionTypeColors[faction.type] ?? "bg-muted text-muted-foreground"}`}
>
{faction.type === "majorFaction"
? "Major"
: faction.type === "minorFaction"
? "Minor"
: faction.type}
</span>
</div>
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</Link>
))}
</div>
</ItemContent>
</Item>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,58 @@
import config from "@payload-config";
import { getPayload } from "payload";
import type { Campaign } from "@/payload-types";
import { ShieldIcon } from "lucide-react";
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
export const metadata = {
title: "Campaigns — Polaris Task Force",
};
export default async function CampaignsPage() {
const payload = await getPayload({ config });
const campaignsRes = await payload.find({
collection: "campaigns",
sort: "-createdAt",
limit: 100,
});
const campaigns = campaignsRes.docs as Campaign[];
const statusOrder: Record<string, number> = {
inProgress: 0,
concept: 1,
completed: 2,
};
const sorted = [...campaigns].sort(
(a, b) =>
(statusOrder[a.status] ?? 99) - (statusOrder[b.status] ?? 99),
);
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ShieldIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Campaigns</h1>
</div>
<p className="text-sm text-muted-foreground">
{campaigns.length} campaign{campaigns.length !== 1 ? "s" : ""}{" "}
across all theaters of operation.
</p>
</div>
{sorted.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{sorted.map((campaign) => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No campaigns found.</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
import config from "@payload-config";
import { getPayload } from "payload";
import type { Faction } from "@/payload-types";
import { ShieldIcon } from "lucide-react";
import { FactionCard } from "@/components/frontend/intelligence/FactionCard";
export const metadata = {
title: "Factions — Polaris Task Force",
};
export default async function FactionsPage() {
const payload = await getPayload({ config });
const factionsRes = await payload.find({
collection: "factions",
limit: 100,
depth: 1,
});
const factions = factionsRes.docs as Faction[];
const typeOrder: Record<string, number> = {
majorFaction: 0,
minorFaction: 1,
corporation: 2,
special: 3,
};
const sorted = [...factions].sort(
(a, b) => (typeOrder[a.type] ?? 99) - (typeOrder[b.type] ?? 99),
);
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ShieldIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Factions</h1>
</div>
<p className="text-sm text-muted-foreground">
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in
the theater of operations.
</p>
</div>
{sorted.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{sorted.map((faction) => (
<FactionCard key={faction.id} faction={faction} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No factions found.</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,366 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
import type {
Mission,
Campaign,
Map,
User,
Faction,
Qualification,
Rank,
} from "@/payload-types";
import {
ArrowLeftIcon,
ArrowRightIcon,
CalendarDaysIcon,
ChevronRightIcon,
ClockIcon,
MapPinIcon,
CrosshairIcon,
ServerIcon,
FlagIcon,
} from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { MissionHeader } from "@/components/frontend/intelligence/MissionHeader";
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
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";
interface MissionDetailPageProps {
params: Promise<{ id: string }>;
}
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",
};
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 as Campaign)
: null;
const startDate = new Date(mission.classification.startDateTime);
const dateStr = startDate.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const timeStr = startDate.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
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
const campaignId =
typeof mission.classification.campaign === "object"
? (mission.classification.campaign as Campaign).id
: mission.classification.campaign;
const siblingMissionsRes = 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: [] }));
const siblingMissions = siblingMissionsRes.docs;
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<Link
href="/intelligence/missions"
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
>
<ArrowLeftIcon className="size-3" />
Back to Missions
</Link>
<MissionHeader
name={mission.name}
codeName={mission.codeName}
operationType={mission.operationType}
status={mission.ownershipAndStatus.status}
/>
<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">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<CalendarDaysIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Date</ItemTitle>
<ItemDescription>{dateStr}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<ClockIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Time & Duration</ItemTitle>
<ItemDescription>
{timeStr} · {durationStr}
</ItemDescription>
</ItemContent>
</Item>
{map && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<MapPinIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Map</ItemTitle>
<ItemDescription>{map.name}</ItemDescription>
</ItemContent>
</Item>
)}
{campaign && (
<Link href={`/intelligence/campaigns/${campaign.id}`}>
<Item
variant="muted"
size="sm"
className="cursor-pointer hover:border-primary/50 transition-colors"
>
<ItemMedia variant="icon">
<CrosshairIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Campaign</ItemTitle>
<ItemDescription>{campaign.name}</ItemDescription>
</ItemContent>
</Item>
</Link>
)}
</div>
<p className="text-sm text-muted-foreground">{mission.summary}</p>
<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={mission.missionObjectives ?? null} />
<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">
{/* 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>
);
}

View file

@ -0,0 +1,47 @@
import config from "@payload-config";
import { getPayload } from "payload";
import type { Mission, Campaign, Map } from "@/payload-types";
import { CrosshairIcon } from "lucide-react";
import { MissionsList } from "@/components/frontend/intelligence/MissionsList";
export const metadata = {
title: "Missions — Polaris Task Force",
};
export default async function MissionsPage() {
const payload = await getPayload({ config });
const missionsRes = await payload
.find({
collection: "missions",
sort: "-classification.startDateTime",
limit: 500,
depth: 2,
})
.catch(() => ({ docs: [] }));
const missions = missionsRes.docs as unknown as Mission[];
const campaignsRes = await payload.find({
collection: "campaigns",
limit: 100,
select: { name: true },
});
const campaigns = campaignsRes.docs as Campaign[];
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<CrosshairIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Missions</h1>
</div>
<p className="text-sm text-muted-foreground">
{missions.length} mission{missions.length !== 1 ? "s" : ""} on file.
</p>
</div>
<MissionsList missions={missions} campaigns={campaigns} />
</div>
);
}

View file

@ -0,0 +1,120 @@
import config from "@payload-config";
import { getPayload } from "payload";
import Link from "next/link";
import type { Campaign, Mission, Faction } from "@/payload-types";
import { CrosshairIcon, ArrowRightIcon } from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
export const metadata = {
title: "Intelligence — Polaris Task Force",
};
export default async function IntelligenceOverviewPage() {
const payload = await getPayload({ config });
const [campaignsRes, factionsRes] = await Promise.all([
payload
.find({
collection: "campaigns",
where: { status: { equals: "inProgress" } },
limit: 10,
sort: "-createdAt",
})
.catch(() => ({ docs: [] })),
payload.find({
collection: "factions",
limit: 100,
select: { name: true, type: true },
}),
]);
const campaigns = campaignsRes.docs as Campaign[];
const factions = factionsRes.docs as Faction[];
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<CrosshairIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Intelligence</h1>
</div>
<p className="text-sm text-muted-foreground">
Campaigns, missions, and faction intelligence for the theater of
operations.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Link href="/intelligence/campaigns">
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
>
<ItemHeader>
<ItemTitle>Campaigns</ItemTitle>
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
</ItemHeader>
<ItemContent>
<ItemDescription>
Active and completed campaigns across all theaters.
</ItemDescription>
</ItemContent>
</Item>
</Link>
<Link href="/intelligence/missions">
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
>
<ItemHeader>
<ItemTitle>Missions</ItemTitle>
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
</ItemHeader>
<ItemContent>
<ItemDescription>
Operation briefings, objectives, and schedules.
</ItemDescription>
</ItemContent>
</Item>
</Link>
<Link href="/intelligence/factions">
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
>
<ItemHeader>
<ItemTitle>Factions</ItemTitle>
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
</ItemHeader>
<ItemContent>
<ItemDescription>
{factions.length} faction{factions.length !== 1 ? "s" : ""}{" "}
tracked in the AO.
</ItemDescription>
</ItemContent>
</Item>
</Link>
</div>
{campaigns.length > 0 && (
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Active Campaigns
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{campaigns.map((campaign) => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
CalendarDaysIcon,
ChevronRightIcon,
FlagIcon,
} from "lucide-react";
interface CampaignCardProps {
campaign: {
id: number;
name: string;
summary: string;
status: "concept" | "inProgress" | "completed";
startDate?: string | null;
endDate?: string | null;
};
}
const statusColors: Record<string, string> = {
concept: "bg-muted text-muted-foreground",
inProgress: "bg-blue-500/20 text-blue-400",
completed: "bg-green-500/20 text-green-400",
};
const statusLabels: Record<string, string> = {
concept: "Concept",
inProgress: "In Progress",
completed: "Completed",
};
export function CampaignCard({ campaign }: CampaignCardProps) {
const startDate = campaign.startDate
? new Date(campaign.startDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: null;
const endDate = campaign.endDate
? new Date(campaign.endDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: null;
return (
<Link href={`/intelligence/campaigns/${campaign.id}`}>
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
>
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<FlagIcon />
</ItemMedia>
<ItemTitle>{campaign.name}</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[campaign.status] ?? "bg-muted text-muted-foreground"}`}
>
{statusLabels[campaign.status] ?? campaign.status}
</span>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3 w-full">
<p className="text-sm text-muted-foreground line-clamp-2">
{campaign.summary}
</p>
{(startDate || endDate) && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarDaysIcon className="size-3" />
{startDate && <span>{startDate}</span>}
{startDate && endDate && <span>—</span>}
{endDate && <span>{endDate}</span>}
</div>
)}
</div>
</ItemContent>
<ItemDescription>{campaign.summary}</ItemDescription>
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
View details <ChevronRightIcon className="size-3" />
</span>
</Item>
</Link>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { ShieldIcon, UsersIcon } from "lucide-react";
interface FactionCardProps {
faction: {
id: number;
name: string;
type: "majorFaction" | "minorFaction" | "corporation" | "special";
description?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ("ltr" | "rtl") | null;
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
playableFaction?: boolean | null;
startingFaction?: boolean | null;
associatedCampaigns?: (number | { id: number; name: string })[] | null;
};
}
const typeColors: Record<string, string> = {
majorFaction: "bg-blue-500/20 text-blue-400",
minorFaction: "bg-muted text-muted-foreground",
corporation: "bg-amber-500/20 text-amber-400",
special: "bg-purple-500/20 text-purple-400",
};
const typeLabels: Record<string, string> = {
majorFaction: "Major Faction",
minorFaction: "Minor Faction",
corporation: "Corporation",
special: "Special",
};
export function FactionCard({ faction }: FactionCardProps) {
const description = faction.description
? convertLexicalToPlaintext({ data: faction.description })
: null;
const truncatedDescription =
description && description.length > 150
? description.slice(0, 150) + "…"
: description;
const campaignCount = faction.associatedCampaigns?.length ?? 0;
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<ShieldIcon />
</ItemMedia>
<ItemTitle>{faction.name}</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${typeColors[faction.type] ?? "bg-muted text-muted-foreground"}`}
>
{typeLabels[faction.type] ?? faction.type}
</span>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3 w-full">
{truncatedDescription && (
<p className="text-sm text-muted-foreground">{truncatedDescription}</p>
)}
<div className="flex items-center gap-2 flex-wrap">
{faction.playableFaction && (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
Playable
</span>
)}
{faction.startingFaction && (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
Starting
</span>
)}
{campaignCount > 0 && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3" />
{campaignCount} campaign{campaignCount !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</ItemContent>
<ItemDescription>
{truncatedDescription ?? "No description provided."}
</ItemDescription>
</Item>
);
}

View file

@ -0,0 +1,67 @@
import { RichText } from "@payloadcms/richtext-lexical/react";
import { FileTextIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
interface BriefingEntry {
sectionName: string;
details: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ("ltr" | "rtl") | null;
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
indent: number;
version: number;
};
[k: string]: unknown;
};
}
interface MissionBriefingSectionProps {
briefing: BriefingEntry[] | null;
}
export function MissionBriefingSection({ briefing }: MissionBriefingSectionProps) {
if (!briefing || briefing.length === 0) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<FileTextIcon className="size-4 text-muted-foreground" />
<ItemTitle>Briefing</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No briefing available.
</p>
</ItemContent>
</Item>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<FileTextIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Briefing
</h2>
</div>
{briefing.map((entry, index) => (
<Item key={index} variant="outline">
<ItemHeader>
<ItemTitle>{entry.sectionName}</ItemTitle>
</ItemHeader>
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
<RichText data={entry.details} />
</ItemContent>
</Item>
))}
</div>
);
}

View file

@ -0,0 +1,160 @@
"use client";
import Link from "next/link";
import type { Campaign, Map } from "@/payload-types";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
CalendarDaysIcon,
ChevronRightIcon,
CrosshairIcon,
MapPinIcon,
UsersIcon,
} from "lucide-react";
interface MissionCardProps {
mission: {
id: number;
name: string;
codeName: string;
summary: string;
operationType: "main" | "side" | "external";
classification: {
missionType: "Zeus" | "PvE" | "PvP" | "PvPvE" | "Training" | "Special";
startDateTime: string;
estimatedDuration: number;
map: number | Map;
campaign: number | Campaign;
};
ownershipAndStatus: {
status:
| "Concept"
| "Planning"
| "Ready"
| "Scheduled"
| "Active"
| "Completed"
| "Cancelled";
};
missionRoles?: {
maxPlayers: number;
} | null;
};
}
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-blue-500/20 text-blue-400",
side: "bg-muted text-muted-foreground",
external: "bg-amber-500/20 text-amber-400",
};
const operationTypeLabels: Record<string, string> = {
main: "Main",
side: "Side",
external: "External",
};
export function MissionCard({ mission }: MissionCardProps) {
const map =
typeof mission.classification.map === "object"
? (mission.classification.map as Map)
: null;
const campaign =
typeof mission.classification.campaign === "object"
? (mission.classification.campaign as Campaign)
: null;
const startDate = new Date(mission.classification.startDateTime);
const dateStr = startDate.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
const timeStr = startDate.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
return (
<Link href={`/intelligence/missions/${mission.id}`}>
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
>
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<CrosshairIcon />
</ItemMedia>
<ItemTitle className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground">
{mission.codeName}
</span>
<span>{mission.name}</span>
</ItemTitle>
</div>
<div className="flex items-center gap-2">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`}
>
{operationTypeLabels[mission.operationType] ?? mission.operationType}
</span>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
>
{mission.ownershipAndStatus.status}
</span>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3 w-full">
<p className="text-sm text-muted-foreground line-clamp-2">
{mission.summary}
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<CalendarDaysIcon className="size-3" />
{dateStr} at {timeStr}
</span>
{map && (
<span className="flex items-center gap-1">
<MapPinIcon className="size-3" />
{map.name}
</span>
)}
{mission.missionRoles?.maxPlayers && (
<span className="flex items-center gap-1">
<UsersIcon className="size-3" />
{mission.missionRoles.maxPlayers} slots
</span>
)}
{campaign && (
<span className="text-primary">{campaign.name}</span>
)}
</div>
</div>
</ItemContent>
<ItemDescription>{mission.summary}</ItemDescription>
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
View details <ChevronRightIcon className="size-3" />
</span>
</Item>
</Link>
);
}

View file

@ -0,0 +1,136 @@
import { RichText } from "@payloadcms/richtext-lexical/react";
import { RadioIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
interface FrequencyEntry {
frequency: number;
channel?: number | null;
netName: string;
freqUsage: string;
}
interface MissionCommsProps {
commsAndDocs: {
radioPlan?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ("ltr" | "rtl") | null;
format:
| "left"
| "start"
| "center"
| "right"
| "end"
| "justify"
| "";
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
frequencies?: FrequencyEntry[] | null;
} | null;
}
export function MissionComms({ commsAndDocs }: MissionCommsProps) {
if (!commsAndDocs) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<RadioIcon className="size-4 text-muted-foreground" />
<ItemTitle>Communications</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No communications plan available.
</p>
</ItemContent>
</Item>
);
}
const { radioPlan, frequencies } = commsAndDocs;
const hasFrequencies = frequencies && frequencies.length > 0;
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<RadioIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Communications
</h2>
</div>
<Item variant="outline">
<ItemContent className="flex flex-col gap-4">
{radioPlan && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Radio Plan
</span>
<div className="text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
<RichText data={radioPlan} />
</div>
</div>
)}
{hasFrequencies && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Frequencies
</span>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Frequency
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Channel
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Net Name
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Usage
</th>
</tr>
</thead>
<tbody>
{frequencies!.map((freq, index) => (
<tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-mono font-medium">
{freq.frequency}
</td>
<td className="px-4 py-2 text-muted-foreground">
{freq.channel ?? "—"}
</td>
<td className="px-4 py-2 font-medium">
{freq.netName}
</td>
<td className="px-4 py-2 text-muted-foreground">
{freq.freqUsage}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!radioPlan && !hasFrequencies && (
<p className="text-sm text-muted-foreground italic">
No communications details provided.
</p>
)}
</ItemContent>
</Item>
</div>
);
}

View file

@ -0,0 +1,78 @@
import { ShieldIcon } from "lucide-react";
import type { Faction } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
interface FactionEntry {
faction: Faction | number;
threatLevel: "ally" | "friendly" | "neutral" | "hostile";
}
interface MissionFactionsProps {
factionsInvolved: FactionEntry[] | null;
}
const threatLevelColors: Record<string, string> = {
ally: "bg-green-500/20 text-green-400",
friendly: "bg-blue-500/20 text-blue-400",
neutral: "bg-yellow-500/20 text-yellow-400",
hostile: "bg-red-500/20 text-red-400",
};
export function MissionFactions({ factionsInvolved }: MissionFactionsProps) {
if (!factionsInvolved || factionsInvolved.length === 0) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ShieldIcon className="size-4 text-muted-foreground" />
<ItemTitle>Factions</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No factions assigned.
</p>
</ItemContent>
</Item>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<ShieldIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Factions Involved
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{factionsInvolved.map((entry, index) => {
const faction =
typeof entry.faction === "object" ? entry.faction : null;
return (
<Item key={index} variant="outline">
<ItemHeader>
<ItemTitle>
{faction?.name ?? `Faction #${entry.faction}`}
</ItemTitle>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${threatLevelColors[entry.threatLevel] ?? "bg-muted text-muted-foreground"}`}
>
{entry.threatLevel}
</span>
</ItemHeader>
{faction && (
<ItemContent>
<span className="text-xs text-muted-foreground">
{faction.type}
</span>
</ItemContent>
)}
</Item>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
"use client";
import { useState, useMemo } from "react";
import type { Campaign } from "@/payload-types";
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface MissionFiltersProps {
campaigns: Campaign[];
onFilterChange: (filters: {
campaignId: number | null;
operationType: string | null;
status: string | null;
}) => void;
}
const operationTypes = [
{ label: "Main", value: "main" },
{ label: "Side", value: "side" },
{ label: "External", value: "external" },
];
const statuses = [
"Concept",
"Planning",
"Ready",
"Scheduled",
"Active",
"Completed",
"Cancelled",
];
export function MissionFilters({
campaigns,
onFilterChange,
}: MissionFiltersProps) {
const [campaignId, setCampaignId] = useState<number | null>(null);
const [operationType, setOperationType] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
function updateCampaign(id: number | null) {
setCampaignId(id);
onFilterChange({ campaignId: id, operationType, status });
}
function updateOpType(type: string | null) {
setOperationType(type);
onFilterChange({ campaignId, operationType: type, status });
}
function updateStatus(s: string | null) {
setStatus(s);
onFilterChange({ campaignId, operationType, status: s });
}
return (
<div className="flex flex-col gap-3">
{campaigns.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Campaign
</span>
<div className="flex flex-wrap gap-1.5">
<Button
variant={campaignId === null ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => updateCampaign(null)}
>
All
</Button>
{campaigns.map((c) => (
<Button
key={c.id}
variant={campaignId === c.id ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => updateCampaign(c.id)}
>
{c.name}
</Button>
))}
</div>
</div>
)}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Operation Type
</span>
<div className="flex flex-wrap gap-1.5">
<Button
variant={operationType === null ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => updateOpType(null)}
>
All
</Button>
{operationTypes.map((t) => (
<Button
key={t.value}
variant={operationType === t.value ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => updateOpType(t.value)}
>
{t.label}
</Button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Status
</span>
<div className="flex flex-wrap gap-1.5">
<Button
variant={status === null ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => updateStatus(null)}
>
All
</Button>
{statuses.map((s) => (
<Button
key={s}
variant={status === s ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => updateStatus(s)}
>
{s}
</Button>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { CrosshairIcon } from "lucide-react";
interface MissionHeaderProps {
name: string;
codeName: string;
operationType: "main" | "side" | "external";
status: string;
}
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",
};
export function MissionHeader({
name,
codeName,
operationType,
status,
}: MissionHeaderProps) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<span className="font-mono text-xs text-muted-foreground uppercase tracking-wider">
{codeName}
</span>
</div>
<h1 className="text-2xl font-bold tracking-tight">{name}</h1>
<div className="flex flex-wrap items-center gap-2">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[status] ?? "bg-muted text-muted-foreground"}`}
>
{status}
</span>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[operationType] ?? "bg-muted text-muted-foreground"}`}
>
{operationType}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,145 @@
import { CheckIcon, TruckIcon, XIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
interface AssetEntry {
assetType: string;
assetCount?: string | null;
doesRespawn?: boolean | null;
assetCallsign?: string | null;
}
interface MissionLogisticsProps {
logisticsAndSupport: {
insertion?: string[] | null;
extraction?: string[] | null;
assetsAvailable?: AssetEntry[] | null;
} | null;
}
export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps) {
if (!logisticsAndSupport) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<TruckIcon className="size-4 text-muted-foreground" />
<ItemTitle>Logistics &amp; Support</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No logistics information available.
</p>
</ItemContent>
</Item>
);
}
const { insertion, extraction, assetsAvailable } = logisticsAndSupport;
const hasInsertion = insertion && insertion.length > 0;
const hasExtraction = extraction && extraction.length > 0;
const hasAssets = assetsAvailable && assetsAvailable.length > 0;
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<TruckIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Logistics &amp; Support
</h2>
</div>
<Item variant="outline">
<ItemContent className="flex flex-col gap-4">
{hasInsertion && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Insertion
</span>
<div className="flex flex-wrap gap-1.5">
{insertion!.map((method) => (
<span
key={method}
className="text-xs font-medium bg-muted border border-border px-2 py-0.5 rounded-md capitalize"
>
{method}
</span>
))}
</div>
</div>
)}
{hasExtraction && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Extraction
</span>
<div className="flex flex-wrap gap-1.5">
{extraction!.map((method) => (
<span
key={method}
className="text-xs font-medium bg-muted border border-border px-2 py-0.5 rounded-md capitalize"
>
{method}
</span>
))}
</div>
</div>
)}
{hasAssets && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Assets Available
</span>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Asset Type
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Count
</th>
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Respawn
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Callsign
</th>
</tr>
</thead>
<tbody>
{assetsAvailable!.map((asset, index) => (
<tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-medium">
{asset.assetType}
</td>
<td className="px-4 py-2 text-muted-foreground">
{asset.assetCount ?? "—"}
</td>
<td className="px-4 py-2 text-center">
{asset.doesRespawn ? (
<CheckIcon className="size-3.5 text-green-400 inline" />
) : (
<XIcon className="size-3.5 text-muted-foreground inline" />
)}
</td>
<td className="px-4 py-2 text-muted-foreground font-mono text-xs">
{asset.assetCallsign ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!hasInsertion && !hasExtraction && !hasAssets && (
<p className="text-sm text-muted-foreground italic">
No logistics details provided.
</p>
)}
</ItemContent>
</Item>
</div>
);
}

View file

@ -0,0 +1,84 @@
import { CheckIcon, CrosshairIcon, XIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
interface Objective {
title: string;
description?: string | null;
missionCritical?: boolean | null;
successOrFailConditions?: string | null;
}
interface MissionObjectivesProps {
objectives: Objective[] | null;
}
export function MissionObjectives({ objectives }: MissionObjectivesProps) {
if (!objectives || objectives.length === 0) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<ItemTitle>Objectives</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No objectives defined.
</p>
</ItemContent>
</Item>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Objectives
</h2>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Title
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Description
</th>
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Critical
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Success / Fail Conditions
</th>
</tr>
</thead>
<tbody>
{objectives.map((obj, index) => (
<tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-medium">{obj.title}</td>
<td className="px-4 py-2 text-muted-foreground">
{obj.description ?? "—"}
</td>
<td className="px-4 py-2 text-center">
{obj.missionCritical ? (
<CheckIcon className="size-3.5 text-green-400 inline" />
) : (
<XIcon className="size-3.5 text-muted-foreground inline" />
)}
</td>
<td className="px-4 py-2 text-muted-foreground">
{obj.successOrFailConditions ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,110 @@
import { CheckIcon, UsersIcon, XIcon } from "lucide-react";
import type { Qualification, Rank } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
interface RoleEntry {
roleName: string;
missionCritical?: boolean | null;
requiredMos?: (Qualification | number)[] | null;
minimumRank?: Rank | number | null;
}
interface MissionRolesProps {
roles: RoleEntry[] | null;
notes?: string | null;
maxPlayers: number;
}
function resolveNames(items: (Qualification | number)[]): string {
return items
.map((item) => {
if (typeof item === "object" && item !== null) return item.name;
return `#${item}`;
})
.join(", ");
}
export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
if (!roles || roles.length === 0) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<UsersIcon className="size-4 text-muted-foreground" />
<ItemTitle>Roles</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No roles defined.
</p>
</ItemContent>
</Item>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<UsersIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Roles
</h2>
<span className="ml-auto text-xs font-mono text-muted-foreground">
Max Players:{" "}
<span className="text-foreground font-semibold">{maxPlayers}</span>
</span>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Role Name
</th>
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Critical
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Required MOS
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Minimum Rank
</th>
</tr>
</thead>
<tbody>
{roles.map((role, index) => (
<tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-medium">{role.roleName}</td>
<td className="px-4 py-2 text-center">
{role.missionCritical ? (
<CheckIcon className="size-3.5 text-green-400 inline" />
) : (
<XIcon className="size-3.5 text-muted-foreground inline" />
)}
</td>
<td className="px-4 py-2 text-muted-foreground">
{role.requiredMos && role.requiredMos.length > 0
? resolveNames(role.requiredMos)
: "—"}
</td>
<td className="px-4 py-2 text-muted-foreground">
{typeof role.minimumRank === "object" &&
role.minimumRank !== null
? role.minimumRank.name
: typeof role.minimumRank === "number"
? `#${role.minimumRank}`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{notes && (
<p className="text-sm text-muted-foreground italic">{notes}</p>
)}
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { useState, useMemo } from "react";
import type { Mission, Campaign, Map } from "@/payload-types";
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
import { MissionFilters } from "@/components/frontend/intelligence/MissionFilters";
interface MissionsListProps {
missions: Mission[];
campaigns: Campaign[];
}
export function MissionsList({ missions, campaigns }: MissionsListProps) {
const [filters, setFilters] = useState<{
campaignId: number | null;
operationType: string | null;
status: string | null;
}>({ campaignId: null, operationType: null, status: null });
const filtered = useMemo(() => {
return missions.filter((m) => {
if (filters.campaignId) {
const campaignId =
typeof m.classification.campaign === "object"
? (m.classification.campaign as Campaign).id
: m.classification.campaign;
if (campaignId !== filters.campaignId) return false;
}
if (filters.operationType && m.operationType !== filters.operationType) {
return false;
}
if (
filters.status &&
m.ownershipAndStatus.status !== filters.status
) {
return false;
}
return true;
});
}, [missions, filters]);
return (
<div className="flex flex-col gap-4">
<MissionFilters campaigns={campaigns} onFilterChange={setFilters} />
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No missions found.</p>
{filters.campaignId || filters.operationType || filters.status ? (
<button
onClick={() =>
setFilters({
campaignId: null,
operationType: null,
status: null,
})
}
className="text-sm text-primary hover:underline mt-1"
>
Clear filters
</button>
) : null}
</div>
) : (
<div className="flex flex-col gap-2">
{filtered.map((mission) => (
<MissionCard key={mission.id} mission={mission} />
))}
</div>
)}
</div>
);
}