diff --git a/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx b/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx new file mode 100644 index 0000000..77b80c3 --- /dev/null +++ b/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx @@ -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 = { + 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 = { + 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 = { + 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 = {}; + for (const m of missions) { + const s = m.ownershipAndStatus.status; + statusCounts[s] = (statusCounts[s] ?? 0) + 1; + } + + return ( +
+ + + Back to Campaigns + + +
+
+
+ +

{campaign.name}

+
+
+ + {campaign.status === "inProgress" + ? "In Progress" + : campaign.status.charAt(0).toUpperCase() + + campaign.status.slice(1)} + + {startDate && ( + + + {startDate} + {endDate ? ` — ${endDate}` : " — Present"} + + )} +
+
+
+ +

{campaign.summary}

+ +
+ {/* ── Main Content (2/3) ──────────────────────────── */} +
+ {campaign.description && ( + + + Description + + + + + + )} + +
+
+

+ Missions +

+ + {missions.length} mission{missions.length !== 1 ? "s" : ""} + +
+ {missions.length > 0 ? ( +
+ {missions.map((mission) => ( + + + +
+ + {mission.codeName} + + {mission.name} +
+ + {mission.ownershipAndStatus.status} + +
+ + + {mission.summary} + + +
+ + ))} +
+ ) : ( + + +

+ No missions in this campaign yet. +

+
+
+ )} +
+
+ + {/* ── Right Sidebar (1/3) ─────────────────────────── */} +
+ {/* Campaign Stats */} + + +
+ + Campaign Stats +
+
+ +
+
+ Total Missions + {missions.length} +
+ {Object.entries(statusCounts).map(([status, count]) => ( +
+ + + {status} + + {count} +
+ ))} + {campaign.startDate && ( +
+ Duration + + {startDate} + {endDate ? ` — ${endDate}` : " — Present"} + +
+ )} +
+
+
+ + {/* Faction Roster */} + {factions.length > 0 && ( + + +
+ + Faction Roster +
+ + View all + +
+ +
+ {factions.map((faction) => ( + + +
+ + {faction.name} + + + {faction.type === "majorFaction" + ? "Major" + : faction.type === "minorFaction" + ? "Minor" + : faction.type} + +
+ + + ))} +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/app/(frontend)/intelligence/campaigns/page.tsx b/src/app/(frontend)/intelligence/campaigns/page.tsx new file mode 100644 index 0000000..7224043 --- /dev/null +++ b/src/app/(frontend)/intelligence/campaigns/page.tsx @@ -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 = { + inProgress: 0, + concept: 1, + completed: 2, + }; + const sorted = [...campaigns].sort( + (a, b) => + (statusOrder[a.status] ?? 99) - (statusOrder[b.status] ?? 99), + ); + + return ( +
+
+
+ +

Campaigns

+
+

+ {campaigns.length} campaign{campaigns.length !== 1 ? "s" : ""}{" "} + across all theaters of operation. +

+
+ + {sorted.length > 0 ? ( +
+ {sorted.map((campaign) => ( + + ))} +
+ ) : ( +
+

No campaigns found.

+
+ )} +
+ ); +} diff --git a/src/app/(frontend)/intelligence/factions/page.tsx b/src/app/(frontend)/intelligence/factions/page.tsx new file mode 100644 index 0000000..af74cff --- /dev/null +++ b/src/app/(frontend)/intelligence/factions/page.tsx @@ -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 = { + majorFaction: 0, + minorFaction: 1, + corporation: 2, + special: 3, + }; + const sorted = [...factions].sort( + (a, b) => (typeOrder[a.type] ?? 99) - (typeOrder[b.type] ?? 99), + ); + + return ( +
+
+
+ +

Factions

+
+

+ {factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in + the theater of operations. +

+
+ + {sorted.length > 0 ? ( +
+ {sorted.map((faction) => ( + + ))} +
+ ) : ( +
+

No factions found.

+
+ )} +
+ ); +} diff --git a/src/app/(frontend)/intelligence/missions/[id]/page.tsx b/src/app/(frontend)/intelligence/missions/[id]/page.tsx new file mode 100644 index 0000000..542edd1 --- /dev/null +++ b/src/app/(frontend)/intelligence/missions/[id]/page.tsx @@ -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 = { + 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 ( +
+ + + Back to Missions + + + + +
+ {/* ── Main Content (2/3) ──────────────────────────── */} +
+
+ + + + + + Date + {dateStr} + + + + + + + + Time & Duration + + {timeStr} · {durationStr} + + + + {map && ( + + + + + + Map + {map.name} + + + )} + {campaign && ( + + + + + + + Campaign + {campaign.name} + + + + )} +
+ +

{mission.summary}

+ + + + Mission Details + + +
+
+ Type +

+ {mission.classification.missionType} +

+
+
+ Operation Type +

+ {mission.operationType} +

+
+
+ Max Players +

+ {mission.missionRoles?.maxPlayers ?? "—"} +

+
+
+
+
+ + + + + + + + + + + + +
+ + {/* ── Right Sidebar (1/3) ─────────────────────────── */} +
+ {/* Campaign Card */} + {campaign && ( + + +
+ + Campaign +
+ + View + +
+ +
+
+

{campaign.name}

+ + {campaign.status === "inProgress" + ? "In Progress" + : campaign.status} + +
+

+ {campaign.summary} +

+
+
+
+ )} + + {/* Sibling Missions */} + {siblingMissions.length > 0 && ( + + +
+ + Other Missions in Campaign +
+
+ +
+ {siblingMissions.map((m) => { + const mDate = new Date( + m.classification.startDateTime, + ); + return ( + +
+ + {m.codeName} + + + {m.name} + +
+
+ + {m.ownershipAndStatus.status} + + +
+ + ); + })} +
+
+
+ )} + + {/* Server Details */} + {mission.gameDetails?.serverDetails?.serverIp && ( + + +
+ + Server +
+
+ +
+
+ Address + + {mission.gameDetails.serverDetails.serverIp} + +
+
+ Port + + {mission.gameDetails.serverDetails.serverPort} + +
+
+
+
+ )} +
+
+
+ ); +} diff --git a/src/app/(frontend)/intelligence/missions/page.tsx b/src/app/(frontend)/intelligence/missions/page.tsx new file mode 100644 index 0000000..9cd29d1 --- /dev/null +++ b/src/app/(frontend)/intelligence/missions/page.tsx @@ -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 ( +
+
+
+ +

Missions

+
+

+ {missions.length} mission{missions.length !== 1 ? "s" : ""} on file. +

+
+ + +
+ ); +} diff --git a/src/app/(frontend)/intelligence/page.tsx b/src/app/(frontend)/intelligence/page.tsx new file mode 100644 index 0000000..71f2caa --- /dev/null +++ b/src/app/(frontend)/intelligence/page.tsx @@ -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 ( +
+
+
+ +

Intelligence

+
+

+ Campaigns, missions, and faction intelligence for the theater of + operations. +

+
+ +
+ + + + Campaigns + + + + + Active and completed campaigns across all theaters. + + + + + + + + Missions + + + + + Operation briefings, objectives, and schedules. + + + + + + + + Factions + + + + + {factions.length} faction{factions.length !== 1 ? "s" : ""}{" "} + tracked in the AO. + + + + +
+ + {campaigns.length > 0 && ( +
+

+ Active Campaigns +

+
+ {campaigns.map((campaign) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/frontend/intelligence/CampaignCard.tsx b/src/components/frontend/intelligence/CampaignCard.tsx new file mode 100644 index 0000000..c7c072b --- /dev/null +++ b/src/components/frontend/intelligence/CampaignCard.tsx @@ -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 = { + 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 = { + 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 ( + + + +
+ + + + {campaign.name} +
+ + {statusLabels[campaign.status] ?? campaign.status} + +
+ +
+

+ {campaign.summary} +

+ {(startDate || endDate) && ( +
+ + {startDate && {startDate}} + {startDate && endDate && —} + {endDate && {endDate}} +
+ )} +
+
+ {campaign.summary} + + View details + +
+ + ); +} diff --git a/src/components/frontend/intelligence/FactionCard.tsx b/src/components/frontend/intelligence/FactionCard.tsx new file mode 100644 index 0000000..6b273f3 --- /dev/null +++ b/src/components/frontend/intelligence/FactionCard.tsx @@ -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 = { + 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 = { + 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 ( + + +
+ + + + {faction.name} +
+ + {typeLabels[faction.type] ?? faction.type} + +
+ +
+ {truncatedDescription && ( +

{truncatedDescription}

+ )} +
+ {faction.playableFaction && ( + + Playable + + )} + {faction.startingFaction && ( + + Starting + + )} + {campaignCount > 0 && ( + + + {campaignCount} campaign{campaignCount !== 1 ? "s" : ""} + + )} +
+
+
+ + {truncatedDescription ?? "No description provided."} + +
+ ); +} diff --git a/src/components/frontend/intelligence/MissionBriefingSection.tsx b/src/components/frontend/intelligence/MissionBriefingSection.tsx new file mode 100644 index 0000000..daa2323 --- /dev/null +++ b/src/components/frontend/intelligence/MissionBriefingSection.tsx @@ -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 ( + + +
+ + Briefing +
+
+ +

+ No briefing available. +

+
+
+ ); + } + + return ( +
+
+ +

+ Briefing +

+
+ {briefing.map((entry, index) => ( + + + {entry.sectionName} + + + + + + ))} +
+ ); +} diff --git a/src/components/frontend/intelligence/MissionCard.tsx b/src/components/frontend/intelligence/MissionCard.tsx new file mode 100644 index 0000000..2ac5e7b --- /dev/null +++ b/src/components/frontend/intelligence/MissionCard.tsx @@ -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 = { + 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 = { + 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 = { + 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 ( + + + +
+ + + + + + {mission.codeName} + + {mission.name} + +
+
+ + {operationTypeLabels[mission.operationType] ?? mission.operationType} + + + {mission.ownershipAndStatus.status} + +
+
+ +
+

+ {mission.summary} +

+
+ + + {dateStr} at {timeStr} + + {map && ( + + + {map.name} + + )} + {mission.missionRoles?.maxPlayers && ( + + + {mission.missionRoles.maxPlayers} slots + + )} + {campaign && ( + {campaign.name} + )} +
+
+
+ {mission.summary} + + View details + +
+ + ); +} diff --git a/src/components/frontend/intelligence/MissionComms.tsx b/src/components/frontend/intelligence/MissionComms.tsx new file mode 100644 index 0000000..8ad325f --- /dev/null +++ b/src/components/frontend/intelligence/MissionComms.tsx @@ -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 ( + + +
+ + Communications +
+
+ +

+ No communications plan available. +

+
+
+ ); + } + + const { radioPlan, frequencies } = commsAndDocs; + const hasFrequencies = frequencies && frequencies.length > 0; + + return ( +
+
+ +

+ Communications +

+
+ + + {radioPlan && ( +
+ + Radio Plan + +
+ +
+
+ )} + {hasFrequencies && ( +
+ + Frequencies + +
+ + + + + + + + + + + {frequencies!.map((freq, index) => ( + + + + + + + ))} + +
+ Frequency + + Channel + + Net Name + + Usage +
+ {freq.frequency} + + {freq.channel ?? "—"} + + {freq.netName} + + {freq.freqUsage} +
+
+
+ )} + {!radioPlan && !hasFrequencies && ( +

+ No communications details provided. +

+ )} +
+
+
+ ); +} diff --git a/src/components/frontend/intelligence/MissionFactions.tsx b/src/components/frontend/intelligence/MissionFactions.tsx new file mode 100644 index 0000000..90a3007 --- /dev/null +++ b/src/components/frontend/intelligence/MissionFactions.tsx @@ -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 = { + 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 ( + + +
+ + Factions +
+
+ +

+ No factions assigned. +

+
+
+ ); + } + + return ( +
+
+ +

+ Factions Involved +

+
+
+ {factionsInvolved.map((entry, index) => { + const faction = + typeof entry.faction === "object" ? entry.faction : null; + + return ( + + + + {faction?.name ?? `Faction #${entry.faction}`} + + + {entry.threatLevel} + + + {faction && ( + + + {faction.type} + + + )} + + ); + })} +
+
+ ); +} diff --git a/src/components/frontend/intelligence/MissionFilters.tsx b/src/components/frontend/intelligence/MissionFilters.tsx new file mode 100644 index 0000000..53b348f --- /dev/null +++ b/src/components/frontend/intelligence/MissionFilters.tsx @@ -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(null); + const [operationType, setOperationType] = useState(null); + const [status, setStatus] = useState(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 ( +
+ {campaigns.length > 0 && ( +
+ + Campaign + +
+ + {campaigns.map((c) => ( + + ))} +
+
+ )} + +
+ + Operation Type + +
+ + {operationTypes.map((t) => ( + + ))} +
+
+ +
+ + Status + +
+ + {statuses.map((s) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/frontend/intelligence/MissionHeader.tsx b/src/components/frontend/intelligence/MissionHeader.tsx new file mode 100644 index 0000000..84a4b84 --- /dev/null +++ b/src/components/frontend/intelligence/MissionHeader.tsx @@ -0,0 +1,55 @@ +import { CrosshairIcon } from "lucide-react"; + +interface MissionHeaderProps { + name: string; + codeName: string; + operationType: "main" | "side" | "external"; + status: string; +} + +const statusColors: Record = { + 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 = { + 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 ( +
+
+ + + {codeName} + +
+

{name}

+
+ + {status} + + + {operationType} + +
+
+ ); +} diff --git a/src/components/frontend/intelligence/MissionLogistics.tsx b/src/components/frontend/intelligence/MissionLogistics.tsx new file mode 100644 index 0000000..9a5f505 --- /dev/null +++ b/src/components/frontend/intelligence/MissionLogistics.tsx @@ -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 ( + + +
+ + Logistics & Support +
+
+ +

+ No logistics information available. +

+
+
+ ); + } + + const { insertion, extraction, assetsAvailable } = logisticsAndSupport; + const hasInsertion = insertion && insertion.length > 0; + const hasExtraction = extraction && extraction.length > 0; + const hasAssets = assetsAvailable && assetsAvailable.length > 0; + + return ( +
+
+ +

+ Logistics & Support +

+
+ + + {hasInsertion && ( +
+ + Insertion + +
+ {insertion!.map((method) => ( + + {method} + + ))} +
+
+ )} + {hasExtraction && ( +
+ + Extraction + +
+ {extraction!.map((method) => ( + + {method} + + ))} +
+
+ )} + {hasAssets && ( +
+ + Assets Available + +
+ + + + + + + + + + + {assetsAvailable!.map((asset, index) => ( + + + + + + + ))} + +
+ Asset Type + + Count + + Respawn + + Callsign +
+ {asset.assetType} + + {asset.assetCount ?? "—"} + + {asset.doesRespawn ? ( + + ) : ( + + )} + + {asset.assetCallsign ?? "—"} +
+
+
+ )} + {!hasInsertion && !hasExtraction && !hasAssets && ( +

+ No logistics details provided. +

+ )} +
+
+
+ ); +} diff --git a/src/components/frontend/intelligence/MissionObjectives.tsx b/src/components/frontend/intelligence/MissionObjectives.tsx new file mode 100644 index 0000000..4cfa0a1 --- /dev/null +++ b/src/components/frontend/intelligence/MissionObjectives.tsx @@ -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 ( + + +
+ + Objectives +
+
+ +

+ No objectives defined. +

+
+
+ ); + } + + return ( +
+
+ +

+ Objectives +

+
+
+ + + + + + + + + + + {objectives.map((obj, index) => ( + + + + + + + ))} + +
+ Title + + Description + + Critical + + Success / Fail Conditions +
{obj.title} + {obj.description ?? "—"} + + {obj.missionCritical ? ( + + ) : ( + + )} + + {obj.successOrFailConditions ?? "—"} +
+
+
+ ); +} diff --git a/src/components/frontend/intelligence/MissionRoles.tsx b/src/components/frontend/intelligence/MissionRoles.tsx new file mode 100644 index 0000000..bdcfb3e --- /dev/null +++ b/src/components/frontend/intelligence/MissionRoles.tsx @@ -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 ( + + +
+ + Roles +
+
+ +

+ No roles defined. +

+
+
+ ); + } + + return ( +
+
+ +

+ Roles +

+ + Max Players:{" "} + {maxPlayers} + +
+
+ + + + + + + + + + + {roles.map((role, index) => ( + + + + + + + ))} + +
+ Role Name + + Critical + + Required MOS + + Minimum Rank +
{role.roleName} + {role.missionCritical ? ( + + ) : ( + + )} + + {role.requiredMos && role.requiredMos.length > 0 + ? resolveNames(role.requiredMos) + : "—"} + + {typeof role.minimumRank === "object" && + role.minimumRank !== null + ? role.minimumRank.name + : typeof role.minimumRank === "number" + ? `#${role.minimumRank}` + : "—"} +
+
+ {notes && ( +

{notes}

+ )} +
+ ); +} diff --git a/src/components/frontend/intelligence/MissionsList.tsx b/src/components/frontend/intelligence/MissionsList.tsx new file mode 100644 index 0000000..1e83a61 --- /dev/null +++ b/src/components/frontend/intelligence/MissionsList.tsx @@ -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 ( +
+ + + {filtered.length === 0 ? ( +
+

No missions found.

+ {filters.campaignId || filters.operationType || filters.status ? ( + + ) : null} +
+ ) : ( +
+ {filtered.map((mission) => ( + + ))} +
+ )} +
+ ); +}