- 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
195 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|