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:
parent
25c088aebf
commit
28a148c06f
18 changed files with 2229 additions and 0 deletions
321
src/app/(frontend)/intelligence/campaigns/[id]/page.tsx
Normal file
321
src/app/(frontend)/intelligence/campaigns/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/app/(frontend)/intelligence/campaigns/page.tsx
Normal file
58
src/app/(frontend)/intelligence/campaigns/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/app/(frontend)/intelligence/factions/page.tsx
Normal file
58
src/app/(frontend)/intelligence/factions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
src/app/(frontend)/intelligence/missions/[id]/page.tsx
Normal file
366
src/app/(frontend)/intelligence/missions/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/app/(frontend)/intelligence/missions/page.tsx
Normal file
47
src/app/(frontend)/intelligence/missions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/app/(frontend)/intelligence/page.tsx
Normal file
120
src/app/(frontend)/intelligence/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/frontend/intelligence/CampaignCard.tsx
Normal file
98
src/components/frontend/intelligence/CampaignCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/frontend/intelligence/FactionCard.tsx
Normal file
110
src/components/frontend/intelligence/FactionCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/components/frontend/intelligence/MissionCard.tsx
Normal file
160
src/components/frontend/intelligence/MissionCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/components/frontend/intelligence/MissionComms.tsx
Normal file
136
src/components/frontend/intelligence/MissionComms.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/frontend/intelligence/MissionFactions.tsx
Normal file
78
src/components/frontend/intelligence/MissionFactions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/components/frontend/intelligence/MissionFilters.tsx
Normal file
143
src/components/frontend/intelligence/MissionFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/frontend/intelligence/MissionHeader.tsx
Normal file
55
src/components/frontend/intelligence/MissionHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/frontend/intelligence/MissionLogistics.tsx
Normal file
145
src/components/frontend/intelligence/MissionLogistics.tsx
Normal 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/frontend/intelligence/MissionObjectives.tsx
Normal file
84
src/components/frontend/intelligence/MissionObjectives.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/frontend/intelligence/MissionRoles.tsx
Normal file
110
src/components/frontend/intelligence/MissionRoles.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/frontend/intelligence/MissionsList.tsx
Normal file
73
src/components/frontend/intelligence/MissionsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue