feat(campaigns): add cover image and mission stats to campaign cards
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
b14e04c487
commit
11afd905f5
3 changed files with 154 additions and 83 deletions
|
|
@ -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<string, number> = {
|
||||
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() {
|
|||
<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.
|
||||
{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) => (
|
||||
{campaignsWithStats.map((campaign) => (
|
||||
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Campaign, "id" | "name" | "summary" | "status" | "startDate" | "endDate"> {
|
||||
readonly coverImageUrl: string | null;
|
||||
readonly missionCount: number;
|
||||
readonly avgPlayers: number;
|
||||
readonly avgDurationMinutes: number;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
type CampaignCardInput = CampaignCardCampaign | Campaign;
|
||||
|
||||
interface CampaignCardProps {
|
||||
readonly campaign: CampaignCardInput;
|
||||
}
|
||||
|
||||
const statusColors: Record<Campaign["status"], string> = {
|
||||
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<string, string> = {
|
||||
const statusLabels: Record<Campaign["status"], 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",
|
||||
})
|
||||
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 (
|
||||
<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)]"
|
||||
<Link
|
||||
href={`/intelligence/campaigns/${campaign.id}`}
|
||||
aria-label={`Open campaign ${campaign.name}`}
|
||||
className="block rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<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>
|
||||
<article className="group relative aspect-[4/3] overflow-hidden rounded-lg border border-border/70 bg-card transition-colors duration-300 group-hover:border-foreground/30">
|
||||
<div className="absolute inset-0 scale-100 transition-transform duration-500 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
{coverImageUrl !== null ? (
|
||||
<img
|
||||
src={coverImageUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 size-full object-cover opacity-30 transition-opacity duration-500 ease-out group-hover:opacity-50 motion-reduce:transition-none"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-gradient-to-br from-muted to-muted/50 opacity-30 transition-opacity duration-500 ease-out group-hover:opacity-50 motion-reduce:transition-none"
|
||||
/>
|
||||
)}
|
||||
</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" />
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"
|
||||
/>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"absolute right-3 top-3 z-10 rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wider",
|
||||
statusColors[campaign.status],
|
||||
)}
|
||||
>
|
||||
{statusLabels[campaign.status]}
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center px-6 pb-6 text-center">
|
||||
<div className="flex max-w-full flex-col items-center gap-2">
|
||||
<h2 className="text-balance text-center text-xl font-bold text-white drop-shadow-lg">
|
||||
{campaign.name}
|
||||
</h2>
|
||||
<p className="text-sm uppercase tracking-wider text-white/70 drop-shadow">
|
||||
{statusLabels[campaign.status]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="absolute inset-x-0 bottom-0 z-20 flex translate-y-4 items-center bg-black/60 px-2 py-2.5 opacity-0 backdrop-blur-sm transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 motion-reduce:transition-none">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-1.5 border-r border-white/15 text-xs text-white/80">
|
||||
<CrosshairIcon aria-hidden="true" className="size-3.5 shrink-0 text-white/60" />
|
||||
<span className="sr-only">Missions</span>
|
||||
<span className="truncate">{missionCount}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-1.5 border-r border-white/15 text-xs text-white/80">
|
||||
<UsersIcon aria-hidden="true" className="size-3.5 shrink-0 text-white/60" />
|
||||
<span className="sr-only">Avg players</span>
|
||||
<span className="truncate">{playersLabel}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-1.5 text-xs text-white/80">
|
||||
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0 text-white/60" />
|
||||
<span className="sr-only">Avg duration</span>
|
||||
<span className="truncate">{durationLabel}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue