1
0
Fork 0

feat: add operations dashboard with mission briefing and stats

- Redesign homepage as authenticated operations dashboard
- Add ProfileSummary, MissionBriefing, RecentEvents, QuickStats components
- Add UpcomingMissions and ActiveCampaigns sidebar widgets
- Redirect /dashboard to / (homepage is now the dashboard)
- Show welcome screen with login link for unauthenticated users
This commit is contained in:
Jason Fraley 2026-07-31 20:01:50 -04:00
parent 98e171d088
commit 25c088aebf
8 changed files with 912 additions and 30 deletions

View file

@ -1,21 +1,5 @@
import { AppSidebar } from "@/components/frontend/blocks/AppSidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { redirect } from "next/navigation";
export default function DashboardPage() {
return (
<div>test</div>
)
redirect("/");
}

View file

@ -1,21 +1,275 @@
import React from "react";
import "./styles.css";
import { PayloadButtons } from "@/components/frontend/PayloadButtons";
import { SiteHeader } from "@/components/frontend/SiteHeader";
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import type {
User,
Rank,
Media,
Assignment,
Campaign,
Map,
GameStructure,
} from "@/payload-types";
import { resolveLevel } from "@/utils/xp/resolveLevel";
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";
import { MissionBriefing } from "@/components/frontend/dashboard/MissionBriefing";
import { RecentEvents } from "@/components/frontend/dashboard/RecentEvents";
import { QuickStats } from "@/components/frontend/dashboard/QuickStats";
import { UpcomingMissions } from "@/components/frontend/dashboard/UpcomingMissions";
import { ActiveCampaigns } from "@/components/frontend/dashboard/ActiveCampaigns";
import { ArrowRightIcon, ShieldIcon } from "lucide-react";
export default async function HomePage() {
// const headers = await getHeaders();
// const payloadConfig = await config;
// const payload = await getPayload({ config: payloadConfig });
// const { user } = await payload.auth({ headers });
const payload = await getPayload({ config });
const headers = await nextHeaders();
const { user } = await payload.auth({ headers, canSetHeaders: false });
// const fileURL = `webstorm://file/${fileURLToPath(import.meta.url)}`;
if (!user) {
return (
<div className="p-5 flex flex-col items-center justify-center min-h-[60vh] gap-4">
<ShieldIcon className="size-12 text-muted-foreground" />
<div className="text-center">
<h1 className="text-xl font-semibold">Welcome to Polaris Task Force</h1>
<p className="text-sm text-muted-foreground mt-1">
Log in to access the operations dashboard.
</p>
</div>
<Link
href="/admin/login"
className="text-sm text-primary hover:underline flex items-center gap-1"
>
Go to login <ArrowRightIcon className="size-3" />
</Link>
</div>
);
}
const userId = user.id;
const [profileRes, experienceRes, assignmentRes] = await Promise.all([
payload.find({
collection: "profiles",
where: { user: { equals: userId } },
depth: 2,
select: {
user: true,
rank: true,
progression: { xp: true, awards: true },
createdAt: true,
},
}),
payload.find({
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: { name: true, requiredPoints: true },
}),
payload.find({
collection: "assignments",
where: { members: { equals: userId } },
limit: 10,
select: { name: true, type: true, callsign: true },
}),
]);
const profile = profileRes.docs[0];
const xp = profile?.progression?.xp ?? 0;
const xpLevel = resolveLevel(xp, experienceRes.docs);
const profileUser = profile?.user as User | undefined;
const rank = profile?.rank as Rank | undefined;
const assignments = assignmentRes.docs as Pick<
Assignment,
"name" | "type" | "callsign"
>[];
const rankData = rank
? {
name: rank.name,
abbreviation: rank.abbreviation,
insigniaUrl:
rank.insignia && typeof rank.insignia === "object"
? (rank.insignia as Media).url
: null,
}
: { name: "Unknown", abbreviation: "?" };
// Fetch upcoming missions (Scheduled or Ready) — up to 5 for the sidebar
const upcomingRes = await payload
.find({
collection: "missions",
where: {
"ownershipAndStatus.status": { in: ["Scheduled", "Ready"] },
},
sort: "classification.startDateTime",
limit: 5,
depth: 2,
})
.catch(() => ({ docs: [] }));
// If no upcoming, get most recently completed for the briefing card
let briefingMission = upcomingRes.docs[0] ?? null;
if (!briefingMission) {
const completedRes = await payload
.find({
collection: "missions",
where: {
"ownershipAndStatus.status": { equals: "Completed" },
},
sort: "-classification.startDateTime",
limit: 1,
depth: 2,
})
.catch(() => ({ docs: [] }));
briefingMission = completedRes.docs[0] ?? null;
}
const mission = briefingMission
? {
id: briefingMission.id,
name: briefingMission.name,
codeName: briefingMission.codeName,
summary: briefingMission.summary,
operationType: briefingMission.operationType,
classification: {
missionType: briefingMission.classification.missionType,
startDateTime: briefingMission.classification.startDateTime,
estimatedDuration: briefingMission.classification.estimatedDuration,
map: briefingMission.classification.map as number | Map,
campaign: briefingMission.classification.campaign as number | Campaign,
},
ownershipAndStatus: {
status: briefingMission.ownershipAndStatus.status,
},
missionRoles: briefingMission.missionRoles
? { maxPlayers: briefingMission.missionRoles.maxPlayers }
: null,
}
: null;
const upcomingMissions = upcomingRes.docs.map((m) => ({
id: m.id,
name: m.name,
codeName: m.codeName,
classification: {
startDateTime: m.classification.startDateTime,
map: m.classification.map as number | Map,
campaign: m.classification.campaign as number | Campaign,
},
ownershipAndStatus: {
status: m.ownershipAndStatus.status,
},
}));
// Fetch recent events
const eventsRes = await payload.find({
collection: "game-event-logs",
sort: "-timestamp",
limit: 8,
depth: 1,
});
const events = eventsRes.docs.map((e) => ({
id: e.id,
timestamp: e.timestamp,
type: e.type,
message: e.message,
system: e.system ?? true,
actor: e.actor as number | User | null | undefined,
structure: e.structure as
| number
| GameStructure
| null
| undefined,
}));
// Fetch active campaigns for the sidebar
const activeCampaignsRes = await payload
.find({
collection: "campaigns",
where: { status: { equals: "inProgress" } },
limit: 5,
sort: "-createdAt",
})
.catch(() => ({ docs: [] }));
const activeCampaigns = activeCampaignsRes.docs.map((c) => ({
id: c.id,
name: c.name,
summary: c.summary,
status: c.status,
}));
// Fetch counts
const [campaignsRes, missionsCountRes, structuresRes, factionsRes, profilesRes] =
await Promise.all([
payload.find({
collection: "campaigns",
where: { status: { equals: "inProgress" } },
limit: 0,
}),
payload.find({
collection: "missions",
limit: 0,
}).catch(() => ({ totalDocs: 0 })),
payload.find({
collection: "game-structures",
limit: 0,
}),
payload.find({
collection: "factions",
limit: 0,
}),
payload.find({
collection: "profiles",
limit: 0,
}),
]);
return (
<div>
{/*<SiteHeader />*/}
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold">
Welcome back, {profileUser?.displayName ?? user.username}
</h1>
<p className="text-sm text-muted-foreground">
Here&apos;s your operations dashboard.
</p>
</div>
{/*<PayloadButtons />*/}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* ── Main Content (2/3) ──────────────────────────── */}
<div className="xl:col-span-2 flex flex-col gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{profile && profileUser && (
<ProfileSummary
displayName={profileUser.displayName}
username={profileUser.username}
rank={rankData}
xpLevel={xpLevel}
assignments={assignments}
/>
)}
<MissionBriefing mission={mission} />
</div>
<RecentEvents events={events} currentUserId={userId} />
<QuickStats
activeCampaigns={campaignsRes.totalDocs}
totalMissions={missionsCountRes.totalDocs}
totalStructures={structuresRes.totalDocs}
totalFactions={factionsRes.totalDocs}
totalMembers={profilesRes.totalDocs}
/>
</div>
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
<div className="flex flex-col gap-4">
<UpcomingMissions missions={upcomingMissions} />
<ActiveCampaigns campaigns={activeCampaigns} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,91 @@
import Link from "next/link";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { ChevronRightIcon, FlagIcon } from "lucide-react";
interface CampaignEntry {
id: number;
name: string;
summary: string;
status: string;
}
interface ActiveCampaignsProps {
campaigns: CampaignEntry[];
}
const statusColors: Record<string, string> = {
inProgress: "bg-blue-500/20 text-blue-400",
concept: "bg-muted text-muted-foreground",
};
export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) {
if (campaigns.length === 0) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<FlagIcon className="size-4 text-muted-foreground" />
<ItemTitle>Active Campaigns</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No active campaigns.
</p>
</ItemContent>
</Item>
);
}
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<FlagIcon className="size-4 text-muted-foreground" />
<ItemTitle>Active Campaigns</ItemTitle>
</div>
<Link
href="/intelligence/campaigns"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
View all
</Link>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
{campaigns.map((campaign) => (
<Link
key={campaign.id}
href={`/intelligence/campaigns/${campaign.id}`}
className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors group"
>
<FlagIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
{campaign.name}
</span>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 ${statusColors[campaign.status] ?? "bg-muted text-muted-foreground"}`}
>
{campaign.status === "inProgress" ? "Active" : campaign.status}
</span>
</div>
<span className="text-xs text-muted-foreground truncate">
{campaign.summary}
</span>
</div>
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</Link>
))}
</div>
</ItemContent>
</Item>
);
}

View file

@ -0,0 +1,160 @@
import Link from "next/link";
import type { Campaign, Map } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import {
CalendarDaysIcon,
ClockIcon,
CrosshairIcon,
MapPinIcon,
UsersIcon,
} 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;
}
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 (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<ItemTitle>Mission Briefing</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No missions currently scheduled.
</p>
</ItemContent>
</Item>
);
}
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>
);
}

View file

@ -0,0 +1,96 @@
import Link from "next/link";
import type { Rank, Media, Assignment } from "@/payload-types";
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { BadgeCheckIcon, ShieldIcon, SparkleIcon, UserIcon } from "lucide-react";
interface ProfileSummaryProps {
displayName: string;
username: string;
rank: Pick<Rank, "name" | "abbreviation"> & { insigniaUrl?: string | null };
xpLevel: ResolvedLevel;
assignments: Pick<Assignment, "name" | "type" | "callsign">[];
}
export function ProfileSummary({
displayName,
username,
rank,
xpLevel,
assignments,
}: ProfileSummaryProps) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<UserIcon className="size-4 text-muted-foreground" />
<ItemTitle>Service Summary</ItemTitle>
</div>
<Link
href={`/profile/${username}`}
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
View full record
</Link>
</ItemHeader>
<ItemContent className="flex flex-col gap-4">
<div className="flex items-start gap-4">
{rank.insigniaUrl && (
<img
src={rank.insigniaUrl}
alt={rank.name}
className="size-14 object-contain shrink-0"
/>
)}
<div className="flex flex-col gap-1 min-w-0">
<h3 className="text-lg font-bold tracking-tight uppercase">
{displayName}
</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{rank.name}</span>
<span className="text-border">|</span>
<span className="uppercase tracking-wide text-xs font-semibold text-primary">
{xpLevel.levelName}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden max-w-xs">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${Math.min(xpLevel.progress, 100)}%` }}
/>
</div>
<span className="text-xs font-mono text-muted-foreground">
{xpLevel.xp.toLocaleString()} XP
</span>
</div>
{assignments.length > 0 && (
<div className="flex flex-wrap gap-2">
{assignments.map((a) => (
<span
key={a.name}
className="text-xs font-mono bg-muted border border-border px-2.5 py-1 rounded-md"
>
{a.callsign ? `[${a.callsign}] ` : ""}
{a.name}
<span className="text-muted-foreground ml-1.5">
({a.type})
</span>
</span>
))}
</div>
)}
</ItemContent>
</Item>
);
}

View file

@ -0,0 +1,79 @@
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
Building2Icon,
CrosshairIcon,
ShieldIcon,
UsersIcon,
} from "lucide-react";
interface QuickStatsProps {
activeCampaigns: number;
totalMissions: number;
totalStructures: number;
totalFactions: number;
totalMembers: number;
}
export function QuickStats({
activeCampaigns,
totalMissions,
totalStructures,
totalFactions,
totalMembers,
}: QuickStatsProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<CrosshairIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Active Campaigns</ItemTitle>
<ItemDescription>{activeCampaigns}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<CrosshairIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Total Missions</ItemTitle>
<ItemDescription>{totalMissions}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<Building2Icon />
</ItemMedia>
<ItemContent>
<ItemTitle>Structures</ItemTitle>
<ItemDescription>{totalStructures}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<ShieldIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Factions</ItemTitle>
<ItemDescription>{totalFactions}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<UsersIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Operators</ItemTitle>
<ItemDescription>{totalMembers}</ItemDescription>
</ItemContent>
</Item>
</div>
);
}

View file

@ -0,0 +1,104 @@
import type { User, GameStructure } from "@/payload-types";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { ScrollTextIcon } from "lucide-react";
interface EventEntry {
id: number;
timestamp: string;
type: string;
message: string;
system: boolean;
actor?: number | User | null;
structure?: number | GameStructure | null;
}
interface RecentEventsProps {
events: EventEntry[];
currentUserId?: number;
}
function timeAgo(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
if (events.length === 0) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ScrollTextIcon className="size-4 text-muted-foreground" />
<ItemTitle>Recent Events</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No recent events recorded.
</p>
</ItemContent>
</Item>
);
}
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ScrollTextIcon className="size-4 text-muted-foreground" />
<ItemTitle>Recent Events</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
{events.map((event) => {
const actor =
typeof event.actor === "object" && event.actor !== null
? (event.actor as User)
: null;
const actorName =
actor?.id === currentUserId
? "You"
: actor?.displayName ?? actor?.username ?? null;
return (
<div
key={event.id}
className="flex items-start gap-3 text-sm py-1.5 border-b border-border/50 last:border-0"
>
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap pt-0.5">
{timeAgo(event.timestamp)}
</span>
<div className="flex flex-col gap-0.5 min-w-0">
<p className="text-sm leading-snug">
{actorName && (
<span className="font-medium">{actorName} </span>
)}
{event.message}
</p>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
{event.type}
</span>
</div>
</div>
);
})}
</div>
</ItemContent>
</Item>
);
}

View file

@ -0,0 +1,114 @@
import Link from "next/link";
import type { Campaign, Map } from "@/payload-types";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { CalendarDaysIcon, 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;
};
}
interface UpcomingMissionsProps {
missions: MissionEntry[];
}
export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
if (missions.length === 0) {
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<ItemTitle>Upcoming Missions</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No missions scheduled.
</p>
</ItemContent>
</Item>
);
}
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<ItemTitle>Upcoming Missions</ItemTitle>
</div>
<Link
href="/intelligence/missions"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
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>
);
})}
</div>
</ItemContent>
</Item>
);
}