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