diff --git a/src/app/(frontend)/intelligence/campaigns/page.tsx b/src/app/(frontend)/intelligence/campaigns/page.tsx index 7224043..565584f 100644 --- a/src/app/(frontend)/intelligence/campaigns/page.tsx +++ b/src/app/(frontend)/intelligence/campaigns/page.tsx @@ -1,8 +1,8 @@ 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"; +import type { Media } from "@/payload-types"; export const metadata = { title: "Campaigns — Polaris Task Force", @@ -15,9 +15,10 @@ export default async function CampaignsPage() { collection: "campaigns", sort: "-createdAt", limit: 100, + depth: 1, }); - const campaigns = campaignsRes.docs as Campaign[]; + const campaigns = campaignsRes.docs; const statusOrder: Record = { inProgress: 0, @@ -25,8 +26,47 @@ export default async function CampaignsPage() { completed: 2, }; const sorted = [...campaigns].sort( - (a, b) => - (statusOrder[a.status] ?? 99) - (statusOrder[b.status] ?? 99), + (a, b) => (statusOrder[a.status] ?? 99) - (statusOrder[b.status] ?? 99), + ); + + const campaignsWithStats = await Promise.all( + sorted.map(async (campaign) => { + const missionsRes = await payload.find({ + collection: "missions", + where: { "classification.campaign": { equals: campaign.id } }, + depth: 0, + limit: 100, + select: { + missionRoles: true, + classification: true, + ownershipAndStatus: true, + }, + }); + + const missionCount = missionsRes.docs.length; + const totalPlayers = missionsRes.docs.reduce( + (total, mission) => total + mission.missionRoles.maxPlayers, + 0, + ); + const totalDurationMinutes = missionsRes.docs.reduce( + (total, mission) => total + mission.classification.estimatedDuration, + 0, + ); + + const coverImage: Media | null = + typeof campaign.coverImage === "object" && campaign.coverImage !== null + ? campaign.coverImage + : null; + const coverImageUrl = coverImage?.url ?? null; + + return { + ...campaign, + coverImageUrl, + missionCount, + avgPlayers: missionCount === 0 ? 0 : totalPlayers / missionCount, + avgDurationMinutes: missionCount === 0 ? 0 : totalDurationMinutes / missionCount, + }; + }), ); return ( @@ -37,14 +77,14 @@ export default async function CampaignsPage() {

Campaigns

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

{sorted.length > 0 ? (
- {sorted.map((campaign) => ( + {campaignsWithStats.map((campaign) => ( ))}
diff --git a/src/collections/intelligence/Campaigns.ts b/src/collections/intelligence/Campaigns.ts index 5870ad4..93f995d 100644 --- a/src/collections/intelligence/Campaigns.ts +++ b/src/collections/intelligence/Campaigns.ts @@ -58,5 +58,14 @@ export const Campaigns: CollectionConfig = { name: "endDate", type: "date", }, + { + name: "coverImage", + type: "upload", + relationTo: "media", + admin: { + description: + "Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.", + }, + }, ], }; diff --git a/src/components/frontend/intelligence/CampaignCard.tsx b/src/components/frontend/intelligence/CampaignCard.tsx index c7c072b..554fdb8 100644 --- a/src/components/frontend/intelligence/CampaignCard.tsx +++ b/src/components/frontend/intelligence/CampaignCard.tsx @@ -1,98 +1,120 @@ "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"; +import type { Campaign } from "@/payload-types"; +import { cn } from "@/lib/utils"; +import { ClockIcon, CrosshairIcon, UsersIcon } from "lucide-react"; -interface CampaignCardProps { - campaign: { - id: number; - name: string; - summary: string; - status: "concept" | "inProgress" | "completed"; - startDate?: string | null; - endDate?: string | null; - }; +interface CampaignCardCampaign + extends Pick { + readonly coverImageUrl: string | null; + readonly missionCount: number; + readonly avgPlayers: number; + readonly avgDurationMinutes: number; } -const statusColors: Record = { +type CampaignCardInput = CampaignCardCampaign | Campaign; + +interface CampaignCardProps { + readonly campaign: CampaignCardInput; +} + +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", + completed: "bg-emerald-500/20 text-emerald-400", }; -const statusLabels: Record = { +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; + const coverImageUrl = + "coverImageUrl" in campaign + ? campaign.coverImageUrl + : typeof campaign.coverImage === "object" && campaign.coverImage !== null + ? (campaign.coverImage.url ?? null) + : null; + const missionCount = "missionCount" in campaign ? campaign.missionCount : 0; + const avgPlayers = "avgPlayers" in campaign ? campaign.avgPlayers : 0; + const avgDurationMinutes = "avgDurationMinutes" in campaign ? campaign.avgDurationMinutes : 0; + + const roundedDuration = Math.round(avgDurationMinutes); + const durationHours = Math.floor(roundedDuration / 60); + const durationMinutes = roundedDuration % 60; + const durationLabel = + roundedDuration >= 60 ? `${durationHours}h ${durationMinutes}m` : `${roundedDuration}m`; + const playersLabel = Number.isInteger(avgPlayers) ? avgPlayers.toString() : avgPlayers.toFixed(1); return ( - - - -
- - - - {campaign.name} -
- - {statusLabels[campaign.status] ?? campaign.status} - -
- -
-

- {campaign.summary} -

- {(startDate || endDate) && ( -
- - {startDate && {startDate}} - {startDate && endDate && —} - {endDate && {endDate}} -
- )} -
-
- {campaign.summary} - - View details + +
+
+ {coverImageUrl !== null ? ( + + ) : ( + + +
); }