feat(intelligence): refresh mission presentation
This commit is contained in:
parent
9bf984fc96
commit
3f4403d151
8 changed files with 173 additions and 325 deletions
10
DESIGN.md
10
DESIGN.md
|
|
@ -67,6 +67,16 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
|
|||
- **Motion**: no section-level motion.
|
||||
- **Layout**: vertical stack.
|
||||
|
||||
### Mission card
|
||||
|
||||
- **Structure**: linked `Item` with `ItemHeader`, `ItemMedia`, `ItemTitle`, `ItemContent`, and `ItemDescription` primitives.
|
||||
- **Variants**: `default` uses the neutral outline surface; `image` adds an optional cover image with a translucent gradient overlay. The contextual default is `image` when a cover image URL is available.
|
||||
- **Metadata**: compact cards show operation/status, date/time, map, player slots, and campaign; full cards additionally show estimated duration.
|
||||
- **States**: default, image, hover, focus-visible, and reduced-content/full-detail display.
|
||||
- **Accessibility**: the entire card is one keyboard-reachable link; decorative cover images use empty alt text and are hidden from assistive technology.
|
||||
- **Motion**: existing 200ms scale, border, and foreground transitions communicate the linked-card affordance; no decorative animation.
|
||||
- **Layout**: responsive metadata wraps on narrow screens while the image layer stays absolutely positioned behind content.
|
||||
|
||||
### Preference row
|
||||
|
||||
- **Structure**: `label.flex.items-center.justify-between` with text on the left and a shadcn control on the right.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
||||
import { statusColors as missionStatusColors } from "@/lib/intelligence/missionColors";
|
||||
|
||||
interface CampaignDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
|
|
@ -31,16 +33,6 @@ const factionTypeColors: Record<string, string> = {
|
|||
special: "bg-purple-500/20 text-purple-400",
|
||||
};
|
||||
|
||||
const missionStatusColors: Record<string, string> = {
|
||||
Concept: "bg-muted text-muted-foreground",
|
||||
Planning: "bg-muted text-muted-foreground",
|
||||
Ready: "bg-blue-500/20 text-blue-400",
|
||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
||||
Active: "bg-green-500/20 text-green-400",
|
||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
||||
Cancelled: "bg-red-500/20 text-red-400",
|
||||
};
|
||||
|
||||
export default async function CampaignDetailPage({ params }: CampaignDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const payload = await getPayload({ config });
|
||||
|
|
@ -198,40 +190,12 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
|
|||
{missions.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{missions.map((mission) => (
|
||||
<Link key={mission.id} href={`/intelligence/missions/${mission.id}`}>
|
||||
<Item
|
||||
variant="outline"
|
||||
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] group relative border border-transparent hover:border-white/75"
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none opacity-25 overflow-hidden after:bg-linear-0 after:bg-linear-180 after:from-transparent after:to-black/42 after:inset-0 after:z-10 after:absolute group-hover:after:opacity-0 after:transition-opacity">
|
||||
<img
|
||||
src={(mission.coverImage as Media)?.url ?? undefined}
|
||||
className="w-full h-full aspect-video object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
<ItemHeader>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{mission.codeName}
|
||||
</span>
|
||||
<ItemTitle>{mission.name}</ItemTitle>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
||||
missionStatusColors[mission.ownershipAndStatus.status] ??
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{mission.ownershipAndStatus.status}
|
||||
</span>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<ItemDescription className="line-clamp-1">
|
||||
{mission.summary}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</Link>
|
||||
<MissionCard
|
||||
key={mission.id}
|
||||
mission={mission}
|
||||
variant="image"
|
||||
showFullDetails={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ShieldIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
|
||||
interface FactionDetailPageProps {
|
||||
|
|
@ -36,16 +37,6 @@ const typeLabels: Record<string, string> = {
|
|||
special: "Special",
|
||||
};
|
||||
|
||||
const missionStatusColors: Record<string, string> = {
|
||||
Concept: "bg-muted text-muted-foreground",
|
||||
Planning: "bg-muted text-muted-foreground",
|
||||
Ready: "bg-blue-500/20 text-blue-400",
|
||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
||||
Active: "bg-green-500/20 text-green-400",
|
||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
||||
Cancelled: "bg-red-500/20 text-red-400",
|
||||
};
|
||||
|
||||
const campaignStatusColors: Record<string, string> = {
|
||||
concept: "bg-muted text-muted-foreground",
|
||||
inProgress: "bg-blue-500/20 text-blue-400",
|
||||
|
|
@ -134,13 +125,16 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
|
|||
where: { "factionsInvolved.faction": { equals: faction.id } },
|
||||
sort: "-classification.startDateTime",
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
depth: 1,
|
||||
select: {
|
||||
name: true,
|
||||
codeName: true,
|
||||
summary: true,
|
||||
operationType: true,
|
||||
ownershipAndStatus: true,
|
||||
classification: true,
|
||||
missionRoles: true,
|
||||
coverImage: true,
|
||||
},
|
||||
})
|
||||
.catch(() => ({ docs: [] as Mission[] }));
|
||||
|
|
@ -303,34 +297,7 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
|
|||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{missions.map((mission) => (
|
||||
<Link key={mission.id} href={`/intelligence/missions/${mission.id}`}>
|
||||
<Item
|
||||
variant="outline"
|
||||
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
|
||||
>
|
||||
<ItemHeader>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{mission.codeName}
|
||||
</span>
|
||||
<ItemTitle>{mission.name}</ItemTitle>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
||||
missionStatusColors[mission.ownershipAndStatus.status] ??
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{mission.ownershipAndStatus.status}
|
||||
</span>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{mission.summary}
|
||||
</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</Link>
|
||||
<MissionCard key={mission.id} mission={mission} showFullDetails={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -114,21 +114,28 @@ export default async function HomePage() {
|
|||
missionRoles: briefingMission.missionRoles
|
||||
? { maxPlayers: briefingMission.missionRoles.maxPlayers }
|
||||
: null,
|
||||
coverImage: briefingMission.coverImage,
|
||||
}
|
||||
: null;
|
||||
: null;
|
||||
|
||||
const upcomingMissions = upcomingRes.docs.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
codeName: m.codeName,
|
||||
summary: m.summary,
|
||||
operationType: m.operationType,
|
||||
classification: {
|
||||
missionType: m.classification.missionType,
|
||||
startDateTime: m.classification.startDateTime,
|
||||
estimatedDuration: m.classification.estimatedDuration,
|
||||
map: m.classification.map as number | Map,
|
||||
campaign: m.classification.campaign as number | Campaign,
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
status: m.ownershipAndStatus.status,
|
||||
},
|
||||
missionRoles: m.missionRoles ? { maxPlayers: m.missionRoles.maxPlayers } : null,
|
||||
coverImage: m.coverImage,
|
||||
}));
|
||||
|
||||
// Fetch recent events
|
||||
|
|
|
|||
|
|
@ -1,41 +1,14 @@
|
|||
import Link from "next/link";
|
||||
import type { Campaign, Map } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { CalendarDaysIcon, ClockIcon, CrosshairIcon, MapPinIcon, UsersIcon } from "lucide-react";
|
||||
import {
|
||||
MissionCard,
|
||||
type MissionCardProps,
|
||||
} from "@/components/frontend/intelligence/MissionCard";
|
||||
import { CrosshairIcon } from "lucide-react";
|
||||
|
||||
interface MissionBriefingProps {
|
||||
mission: {
|
||||
id: number;
|
||||
name: string;
|
||||
codeName: string;
|
||||
summary: string;
|
||||
operationType: "main" | "side" | "external";
|
||||
classification: {
|
||||
missionType: string;
|
||||
startDateTime: string;
|
||||
estimatedDuration: number;
|
||||
map: number | Map;
|
||||
campaign: number | Campaign;
|
||||
};
|
||||
ownershipAndStatus: {
|
||||
status: string;
|
||||
};
|
||||
missionRoles?: {
|
||||
maxPlayers: number;
|
||||
} | null;
|
||||
} | null;
|
||||
readonly mission: MissionCardProps["mission"] | null;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Concept: "bg-muted text-muted-foreground",
|
||||
Planning: "bg-muted text-muted-foreground",
|
||||
Ready: "bg-blue-500/20 text-blue-400",
|
||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
||||
Active: "bg-green-500/20 text-green-400",
|
||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
||||
Cancelled: "bg-red-500/20 text-red-400",
|
||||
};
|
||||
|
||||
export function MissionBriefing({ mission }: MissionBriefingProps) {
|
||||
if (!mission) {
|
||||
return (
|
||||
|
|
@ -53,82 +26,5 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const map =
|
||||
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null;
|
||||
const campaign =
|
||||
typeof mission.classification.campaign === "object"
|
||||
? (mission.classification.campaign as Campaign)
|
||||
: 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 durationMins = mission.classification.estimatedDuration % 60;
|
||||
const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
|
||||
|
||||
return (
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CrosshairIcon className="size-4 text-muted-foreground" />
|
||||
<ItemTitle>Mission Briefing</ItemTitle>
|
||||
</div>
|
||||
<Link
|
||||
href={`/intelligence/missions/${mission.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
</ItemHeader>
|
||||
<ItemContent className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</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>
|
||||
<h3 className="text-base font-semibold">{mission.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p>
|
||||
|
||||
<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>
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="size-3" />
|
||||
{durationStr}
|
||||
</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>
|
||||
)}
|
||||
{campaign && <span className="text-primary">{campaign.name}</span>}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
return <MissionCard mission={mission} showFullDetails={true} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,13 @@
|
|||
import Link from "next/link";
|
||||
import type { Campaign, Map } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ChevronRightIcon, CrosshairIcon } from "lucide-react";
|
||||
|
||||
interface MissionEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
codeName: string;
|
||||
classification: {
|
||||
startDateTime: string;
|
||||
map: number | Map;
|
||||
campaign: number | Campaign;
|
||||
};
|
||||
ownershipAndStatus: {
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
import {
|
||||
MissionCard,
|
||||
type MissionCardProps,
|
||||
} from "@/components/frontend/intelligence/MissionCard";
|
||||
import { CrosshairIcon } from "lucide-react";
|
||||
|
||||
interface UpcomingMissionsProps {
|
||||
missions: MissionEntry[];
|
||||
readonly missions: MissionCardProps["mission"][];
|
||||
}
|
||||
|
||||
export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
|
||||
|
|
@ -47,56 +36,16 @@ export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
|
|||
</div>
|
||||
<Link
|
||||
href="/intelligence/missions"
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{missions.map((mission) => {
|
||||
const startDate = new Date(mission.classification.startDateTime);
|
||||
const dateStr = startDate.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const timeStr = startDate.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const campaign =
|
||||
typeof mission.classification.campaign === "object"
|
||||
? (mission.classification.campaign as Campaign)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={mission.id}
|
||||
href={`/intelligence/missions/${mission.id}`}
|
||||
className="flex items-start gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="flex flex-col items-center text-center min-w-[2.5rem]">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{startDate.toLocaleDateString("en-US", { weekday: "short" })}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{dateStr.split(" ")[1]}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{timeStr}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{mission.codeName}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
||||
{mission.name}
|
||||
</span>
|
||||
{campaign && (
|
||||
<span className="text-[10px] text-muted-foreground">{campaign.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity mt-1 shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{missions.map((mission) => (
|
||||
<MissionCard key={mission.id} mission={mission} showFullDetails={false} />
|
||||
))}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Campaign, Map } from "@/payload-types";
|
||||
import type { Campaign, Map, Media } from "@/payload-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
operationTypeColors,
|
||||
operationTypeLabels,
|
||||
statusColors,
|
||||
} from "@/lib/intelligence/missionColors";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
|
|
@ -13,62 +19,63 @@ import {
|
|||
import {
|
||||
CalendarDaysIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
CrosshairIcon,
|
||||
MapPinIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface MissionCardProps {
|
||||
mission: {
|
||||
id: number;
|
||||
name: string;
|
||||
codeName: string;
|
||||
summary: string;
|
||||
operationType: "main" | "side" | "external";
|
||||
classification: {
|
||||
missionType: "Zeus" | "PvE" | "PvP" | "PvPvE" | "Training" | "Special";
|
||||
startDateTime: string;
|
||||
estimatedDuration: number;
|
||||
map: number | Map;
|
||||
campaign: number | Campaign;
|
||||
export interface MissionCardProps {
|
||||
readonly mission: {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly codeName: string;
|
||||
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;
|
||||
};
|
||||
ownershipAndStatus: {
|
||||
status: "Concept" | "Planning" | "Ready" | "Scheduled" | "Active" | "Completed" | "Cancelled";
|
||||
readonly ownershipAndStatus: {
|
||||
readonly status:
|
||||
| "Concept"
|
||||
| "Planning"
|
||||
| "Ready"
|
||||
| "Scheduled"
|
||||
| "Active"
|
||||
| "Completed"
|
||||
| "Cancelled";
|
||||
};
|
||||
missionRoles?: {
|
||||
maxPlayers: number;
|
||||
readonly missionRoles?: {
|
||||
readonly maxPlayers: number;
|
||||
} | null;
|
||||
readonly coverImage?: number | Media | null;
|
||||
};
|
||||
readonly variant?: "default" | "image";
|
||||
readonly showFullDetails?: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Concept: "bg-muted text-muted-foreground",
|
||||
Planning: "bg-muted text-muted-foreground",
|
||||
Ready: "bg-blue-500/20 text-blue-400",
|
||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
||||
Active: "bg-green-500/20 text-green-400",
|
||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
||||
Cancelled: "bg-red-500/20 text-red-400",
|
||||
};
|
||||
|
||||
const operationTypeColors: Record<string, string> = {
|
||||
main: "bg-blue-500/20 text-blue-400",
|
||||
side: "bg-muted text-muted-foreground",
|
||||
external: "bg-amber-500/20 text-amber-400",
|
||||
};
|
||||
|
||||
const operationTypeLabels: Record<string, string> = {
|
||||
main: "Main",
|
||||
side: "Side",
|
||||
external: "External",
|
||||
};
|
||||
|
||||
export function MissionCard({ mission }: MissionCardProps) {
|
||||
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 as Map) : null;
|
||||
typeof mission.classification.map === "object" && mission.classification.map !== null
|
||||
? mission.classification.map
|
||||
: null;
|
||||
const campaign =
|
||||
typeof mission.classification.campaign === "object"
|
||||
? (mission.classification.campaign as Campaign)
|
||||
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
|
||||
? mission.classification.campaign
|
||||
: null;
|
||||
|
||||
const startDate = new Date(mission.classification.startDateTime);
|
||||
|
|
@ -81,14 +88,36 @@ export function MissionCard({ mission }: MissionCardProps) {
|
|||
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}`}>
|
||||
<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="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<ItemHeader>
|
||||
{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 />
|
||||
|
|
@ -111,32 +140,37 @@ export function MissionCard({ mission }: MissionCardProps) {
|
|||
</span>
|
||||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||
<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">
|
||||
<CalendarDaysIcon className="size-3" />
|
||||
{dateStr} at {timeStr}
|
||||
<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>
|
||||
)}
|
||||
{campaign && <span className="text-primary">{campaign.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{campaign && <span className="text-primary">{campaign.name}</span>}
|
||||
</div>
|
||||
</ItemContent>
|
||||
<ItemDescription>{mission.summary}</ItemDescription>
|
||||
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
|
||||
<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>
|
||||
|
|
|
|||
21
src/lib/intelligence/missionColors.ts
Normal file
21
src/lib/intelligence/missionColors.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export const statusColors: Record<string, string> = {
|
||||
Concept: "bg-muted text-muted-foreground",
|
||||
Planning: "bg-muted text-muted-foreground",
|
||||
Ready: "bg-blue-500/20 text-blue-400",
|
||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
||||
Active: "bg-green-500/20 text-green-400",
|
||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
||||
Cancelled: "bg-red-500/20 text-red-400",
|
||||
};
|
||||
|
||||
export const operationTypeColors: Record<string, string> = {
|
||||
main: "bg-blue-500/20 text-blue-400",
|
||||
side: "bg-muted text-muted-foreground",
|
||||
external: "bg-amber-500/20 text-amber-400",
|
||||
};
|
||||
|
||||
export const operationTypeLabels: Record<string, string> = {
|
||||
main: "Main",
|
||||
side: "Side",
|
||||
external: "External",
|
||||
};
|
||||
Loading…
Reference in a new issue