1
0
Fork 0
polaris-task-force/src/components/frontend/intelligence/MissionCard.tsx
Z8MB1E 7180b0aa4c feat(missions): make code name optional, add UGC cover images
- Missions.codeName is now optional (still unique when set); migration included
- Campaign and side-op dialogs accept an optional cover image (uploadUgcImage)
- Reminder toasts, evaluation aggregates, calendar, cards and mission headers
  handle a null code name
- Mission read filter no longer hides drafts by status; visibility follows
  ownershipAndStatus.visibility only
2026-09-10 20:02:47 -04:00

195 lines
7.1 KiB
TypeScript

"use client";
import Link from "next/link";
import type { Campaign, Map, Media, User } from "@/payload-types";
import { cn } from "@/lib/utils";
import {
operationTypeColors,
operationTypeLabels,
statusColors,
} from "@/lib/intelligence/missionColors";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
CalendarDaysIcon,
ChevronRightIcon,
ClockIcon,
CrosshairIcon,
MapPinIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
export interface MissionCardProps {
readonly mission: {
readonly id: number;
readonly name: string;
readonly codeName?: string | null;
readonly summary: string;
readonly operationType: "main" | "side" | "external";
readonly classification: {
readonly missionType: "Zeus" | "PvE" | "PvP" | "PvPvE" | "Training" | "Special";
readonly startDateTime: string;
readonly estimatedDuration: number;
readonly map: number | Map;
readonly campaign?: number | Campaign | null;
};
readonly ownershipAndStatus: {
readonly status:
| "Concept"
| "Planning"
| "Ready"
| "Scheduled"
| "Active"
| "Completed"
| "Cancelled";
};
readonly missionRoles?: {
readonly maxPlayers: number;
} | null;
readonly createdBy?: number | User | null;
readonly coverImage?: number | Media | null;
};
readonly variant?: "default" | "image";
readonly showFullDetails?: boolean;
}
export function MissionCard({
mission,
variant,
showFullDetails = false,
}: MissionCardProps) {
const resolvedVariant = variant ?? (mission.coverImage ? "image" : "default");
const coverImage =
typeof mission.coverImage === "object" && mission.coverImage !== null
? mission.coverImage
: null;
const coverImageUrl = coverImage?.url ?? null;
const map =
typeof mission.classification.map === "object" && mission.classification.map !== null
? mission.classification.map
: null;
const campaign =
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
? mission.classification.campaign
: null;
const createdByUser =
typeof mission.createdBy === "object" && mission.createdBy !== null
? mission.createdBy
: null;
const hostLabel = createdByUser ? (createdByUser.displayName || createdByUser.username) : null;
const showHost = mission.operationType === "side" && hostLabel !== 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",
});
const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
const durationMinutes = mission.classification.estimatedDuration % 60;
const durationLabel =
durationMinutes > 0 ? `${durationHours}h ${durationMinutes}m` : `${durationHours}h`;
const imageCard = resolvedVariant === "image" && coverImageUrl !== null;
return (
<Link
href={`/intelligence/missions/${mission.id}`}
className="block rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Item
variant="outline"
className={cn(
"group relative cursor-pointer overflow-hidden transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]",
imageCard &&
"border border-transparent hover:border-white/75",
)}
>
{resolvedVariant === "image" && coverImageUrl !== null && (
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-25 after:absolute after:inset-0 after:z-10 after:bg-linear-0 after:bg-linear-180 after:from-transparent after:to-black/42 after:transition-opacity group-hover:after:opacity-0">
<img
src={coverImageUrl}
alt=""
aria-hidden="true"
className="h-full w-full aspect-video object-cover object-center"
/>
</div>
)}
<ItemHeader className="relative z-20">
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<CrosshairIcon />
</ItemMedia>
<ItemTitle className="flex flex-col items-start text-start">
{mission.codeName && (
<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} Operation
</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 className="relative z-20 w-full">
<ItemDescription className={showFullDetails ? "line-clamp-2" : "line-clamp-1"}>
{mission.summary}
</ItemDescription>
<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>
{showFullDetails && (
<span className="flex items-center gap-1">
<ClockIcon className="size-3" />
{durationLabel}
</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>
)}
{showHost && (
<span className="flex items-center gap-1">
<UserIcon className="size-3" />
Hosted by {hostLabel}
</span>
)}
{campaign && <span className="text-primary">{campaign.name}</span>}
</div>
</ItemContent>
<span className="relative z-20 flex items-center gap-1 text-xs text-muted-foreground transition-colors group-hover:text-primary">
View details <ChevronRightIcon className="size-3" />
</span>
</Item>
</Link>
);
}