293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import type { Campaign } from "@/payload-types";
|
|
import { cn } from "@/lib/utils";
|
|
import { ClockIcon, CrosshairIcon, UsersIcon } from "lucide-react";
|
|
|
|
interface CampaignCardCampaign extends Pick<
|
|
Campaign,
|
|
"id" | "name" | "summary" | "status" | "startDate" | "endDate"
|
|
> {
|
|
readonly coverImageUrl: string | null;
|
|
readonly missionCount: number;
|
|
readonly completedMissions: number;
|
|
readonly avgPlayers: number;
|
|
readonly avgDurationMinutes: number;
|
|
}
|
|
|
|
type CampaignCardInput = CampaignCardCampaign | Campaign;
|
|
|
|
interface CampaignCardProps {
|
|
readonly campaign: CampaignCardInput;
|
|
readonly size?: "lg" | "sm";
|
|
}
|
|
|
|
const statusColors: Record<Campaign["status"], string> = {
|
|
concept: "bg-muted text-muted-foreground",
|
|
inProgress: "bg-blue-500/20 text-blue-400",
|
|
completed: "bg-emerald-500/20 text-emerald-400",
|
|
};
|
|
|
|
const statusLabels: Record<Campaign["status"], string> = {
|
|
concept: "Concept",
|
|
inProgress: "In Progress",
|
|
completed: "Completed",
|
|
};
|
|
|
|
const officialStatusLabels: Record<Campaign["status"], string> = {
|
|
concept: "Planning",
|
|
inProgress: "Active Deployment",
|
|
completed: "Concluded",
|
|
};
|
|
|
|
const statusTextColors: Record<Campaign["status"], string> = {
|
|
concept: "text-muted-foreground",
|
|
inProgress: "text-blue-400",
|
|
completed: "text-emerald-400",
|
|
};
|
|
|
|
export function CampaignCard({ campaign, size = "lg" }: CampaignCardProps) {
|
|
const isSmall = size === "sm";
|
|
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 completedMissions =
|
|
"completedMissions" in campaign ? campaign.completedMissions : 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);
|
|
|
|
const owner = (campaign as any).owner;
|
|
const ownerName =
|
|
owner && typeof owner === "object" && "displayName" in owner
|
|
? owner.displayName
|
|
: "Unknown";
|
|
|
|
const refCode = String(campaign.id).padStart(3, "0");
|
|
const statusLabel = isSmall ? statusLabels[campaign.status] : officialStatusLabels[campaign.status];
|
|
const readoutCells = [
|
|
{ label: "Msns", value: missionCount.toString(), mono: true },
|
|
{ label: "Done", value: completedMissions.toString(), mono: true },
|
|
{ label: "Avg Squad", value: playersLabel, mono: true },
|
|
{ label: "Duration", value: durationLabel, mono: true },
|
|
];
|
|
|
|
return (
|
|
<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"
|
|
>
|
|
<article
|
|
className={cn(
|
|
"group relative overflow-hidden border border-border/70 bg-card transition-colors duration-300 group-hover:border-foreground/30",
|
|
isSmall ? "aspect-[16/9] rounded-lg" : "aspect-[4/3] rounded-none",
|
|
)}
|
|
>
|
|
<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={cn(
|
|
"absolute inset-0 size-full object-cover transition-opacity duration-500 ease-out motion-reduce:transition-none",
|
|
isSmall ? "opacity-30 group-hover:opacity-50" : "opacity-15 group-hover:opacity-25",
|
|
)}
|
|
/>
|
|
{!isSmall && (
|
|
<>
|
|
<img
|
|
src={coverImageUrl}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="glitch-ghost-img glitch-ghost-img-a absolute inset-0 size-full object-cover"
|
|
/>
|
|
<img
|
|
src={coverImageUrl}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="glitch-ghost-img glitch-ghost-img-b absolute inset-0 size-full object-cover"
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<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>
|
|
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"absolute inset-0 bg-gradient-to-t",
|
|
isSmall ? "from-black/80 via-black/40 to-transparent" : "from-black/85 via-black/60 to-black/25",
|
|
)}
|
|
/>
|
|
|
|
{!isSmall && (
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 z-[5] opacity-[0.04] transition-opacity duration-500 group-hover:opacity-[0.07] motion-reduce:transition-none"
|
|
style={{
|
|
backgroundImage:
|
|
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
|
|
backgroundSize: "28px 28px",
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isSmall && (
|
|
<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],
|
|
)}
|
|
>
|
|
{statusLabel}
|
|
</span>
|
|
)}
|
|
|
|
{!isSmall && (
|
|
<div
|
|
aria-hidden="true"
|
|
className="absolute inset-x-0 top-0 z-20 flex items-center justify-between border-b border-white/15 bg-black/50 px-3 py-1.5 backdrop-blur-sm"
|
|
>
|
|
<span className="font-mono text-[9px] uppercase tracking-widest text-white/60">
|
|
Official // Unit Command
|
|
</span>
|
|
<span className="font-mono text-[9px] uppercase tracking-widest text-white/60">
|
|
REF {refCode}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{!isSmall && (
|
|
<div aria-hidden="true" className="pointer-events-none absolute inset-2 z-30">
|
|
<span className="absolute left-0 top-0 size-3 border-l border-t border-white/25 transition-colors duration-300 group-hover:border-white/60" />
|
|
<span className="absolute right-0 top-0 size-3 border-r border-t border-white/25 transition-colors duration-300 group-hover:border-white/60" />
|
|
<span className="absolute bottom-0 left-0 size-3 border-b border-l border-white/25 transition-colors duration-300 group-hover:border-white/60" />
|
|
<span className="absolute bottom-0 right-0 size-3 border-b border-r border-white/25 transition-colors duration-300 group-hover:border-white/60" />
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
"absolute inset-0 z-10 flex items-center justify-center text-center",
|
|
isSmall ? "px-12 pb-6 pt-8" : "px-10",
|
|
)}
|
|
>
|
|
<div className="flex max-w-full flex-col items-center gap-2">
|
|
<h2
|
|
data-text={campaign.name}
|
|
className={cn(
|
|
"text-balance text-center font-bold text-white drop-shadow-lg",
|
|
isSmall ? "text-lg" : "glitch-hover text-xl",
|
|
)}
|
|
>
|
|
{campaign.name}
|
|
{!isSmall && (
|
|
<>
|
|
<span aria-hidden="true" className="glitch-ghost glitch-shard-a">
|
|
{campaign.name}
|
|
</span>
|
|
<span aria-hidden="true" className="glitch-ghost glitch-shard-b">
|
|
{campaign.name}
|
|
</span>
|
|
<span aria-hidden="true" className="glitch-ghost glitch-shard-c">
|
|
{campaign.name}
|
|
</span>
|
|
</>
|
|
)}
|
|
</h2>
|
|
{campaign.summary && (
|
|
<p
|
|
className={cn(
|
|
"line-clamp-2 max-w-prose text-pretty text-white/70 drop-shadow",
|
|
isSmall ? "text-xs" : "glitch-soft text-sm",
|
|
)}
|
|
>
|
|
{campaign.summary}
|
|
</p>
|
|
)}
|
|
{!isSmall && (
|
|
<p
|
|
className={cn(
|
|
"glitch-soft glitch-soft-late uppercase tracking-wider drop-shadow",
|
|
statusTextColors[campaign.status],
|
|
)}
|
|
>
|
|
{officialStatusLabels[campaign.status]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isSmall ? (
|
|
<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>
|
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-1.5 text-xs text-white/80">
|
|
<div className="text-right">
|
|
<div className="truncate">{ownerName}</div>
|
|
<span className="text-[10px] uppercase tracking-wider text-white/60">Owner</span>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
) : (
|
|
<footer className="absolute inset-x-0 bottom-0 z-20 flex items-stretch border-t border-white/15 bg-black/60 px-2 py-1.5 backdrop-blur-sm">
|
|
{readoutCells.map((cell, index) => (
|
|
<div
|
|
key={cell.label}
|
|
className={cn(
|
|
"flex min-w-0 flex-1 items-center justify-center gap-1.5 px-1",
|
|
index < readoutCells.length - 1 && "border-r border-white/15",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"truncate text-xs text-white/80",
|
|
cell.mono && "font-mono tabular-nums",
|
|
)}
|
|
>
|
|
{cell.value}
|
|
</span>
|
|
<span className="shrink-0 text-[9px] uppercase tracking-wider text-white/50">
|
|
{cell.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</footer>
|
|
)}
|
|
</article>
|
|
</Link>
|
|
);
|
|
}
|