diff --git a/DESIGN.md b/DESIGN.md index a021a98..5649209 100644 --- a/DESIGN.md +++ b/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. diff --git a/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx b/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx index 5911626..4ef687f 100644 --- a/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx +++ b/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx @@ -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 = { special: "bg-purple-500/20 text-purple-400", }; -const missionStatusColors: Record = { - 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 ? (
{missions.map((mission) => ( - - -
- -
- -
- - {mission.codeName} - - {mission.name} -
- - {mission.ownershipAndStatus.status} - -
- - - {mission.summary} - - -
- + ))}
) : ( diff --git a/src/app/(frontend)/intelligence/factions/[id]/page.tsx b/src/app/(frontend)/intelligence/factions/[id]/page.tsx index 1f34d37..1e3942b 100644 --- a/src/app/(frontend)/intelligence/factions/[id]/page.tsx +++ b/src/app/(frontend)/intelligence/factions/[id]/page.tsx @@ -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 = { special: "Special", }; -const missionStatusColors: Record = { - 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 = { 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
{missions.map((mission) => ( - - - -
- - {mission.codeName} - - {mission.name} -
- - {mission.ownershipAndStatus.status} - -
- -

- {mission.summary} -

-
-
- + ))}
diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx index d98de7e..a8d7235 100644 --- a/src/app/(frontend)/page.tsx +++ b/src/app/(frontend)/page.tsx @@ -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 diff --git a/src/components/frontend/dashboard/MissionBriefing.tsx b/src/components/frontend/dashboard/MissionBriefing.tsx index 55e0d80..70e610b 100644 --- a/src/components/frontend/dashboard/MissionBriefing.tsx +++ b/src/components/frontend/dashboard/MissionBriefing.tsx @@ -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 = { - 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 ( - - -
- - Mission Briefing -
- - View details - -
- -
-
-
- {mission.codeName} - - {mission.ownershipAndStatus.status} - -
-

{mission.name}

-
-
- -

{mission.summary}

- -
- - - {dateStr} at {timeStr} - - - - {durationStr} - - {map && ( - - - {map.name} - - )} - {mission.missionRoles?.maxPlayers && ( - - - {mission.missionRoles.maxPlayers} slots - - )} - {campaign && {campaign.name}} -
-
-
- ); + return ; } diff --git a/src/components/frontend/dashboard/UpcomingMissions.tsx b/src/components/frontend/dashboard/UpcomingMissions.tsx index c8faf92..68dd1cc 100644 --- a/src/components/frontend/dashboard/UpcomingMissions.tsx +++ b/src/components/frontend/dashboard/UpcomingMissions.tsx @@ -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) { View all
- {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 ( - -
- - {startDate.toLocaleDateString("en-US", { weekday: "short" })} - - {dateStr.split(" ")[1]} - {timeStr} -
-
- - {mission.codeName} - - - {mission.name} - - {campaign && ( - {campaign.name} - )} -
- - - ); - })} + {missions.map((mission) => ( + + ))}
diff --git a/src/components/frontend/intelligence/MissionCard.tsx b/src/components/frontend/intelligence/MissionCard.tsx index b13b371..a09505c 100644 --- a/src/components/frontend/intelligence/MissionCard.tsx +++ b/src/components/frontend/intelligence/MissionCard.tsx @@ -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 = { - 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 = { - 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 = { - 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 ( - + - + {resolvedVariant === "image" && coverImageUrl !== null && ( +
+ +
+ )} +
@@ -111,32 +140,37 @@ export function MissionCard({ mission }: MissionCardProps) {
- -
-

{mission.summary}

-
+ + + {mission.summary} + +
+ + + {dateStr} at {timeStr} + + {showFullDetails && ( - - {dateStr} at {timeStr} + + {durationLabel} - {map && ( - - - {map.name} - - )} - {mission.missionRoles?.maxPlayers && ( - - - {mission.missionRoles.maxPlayers} slots - - )} - {campaign && {campaign.name}} -
+ )} + {map && ( + + + {map.name} + + )} + {mission.missionRoles?.maxPlayers && ( + + + {mission.missionRoles.maxPlayers} slots + + )} + {campaign && {campaign.name}}
- {mission.summary} - + View details diff --git a/src/lib/intelligence/missionColors.ts b/src/lib/intelligence/missionColors.ts new file mode 100644 index 0000000..ff397fa --- /dev/null +++ b/src/lib/intelligence/missionColors.ts @@ -0,0 +1,21 @@ +export const statusColors: Record = { + 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 = { + 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 = { + main: "Main", + side: "Side", + external: "External", +};