1
0
Fork 0

feat(intelligence): refresh mission presentation

This commit is contained in:
Jason Fraley 2026-08-20 19:56:41 -04:00
parent 9bf984fc96
commit 3f4403d151
8 changed files with 173 additions and 325 deletions

View file

@ -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.

View file

@ -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>
) : (

View file

@ -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>

View file

@ -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

View file

@ -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} />;
}

View file

@ -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>

View file

@ -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>

View 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",
};