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:
parent
98e171d088
commit
25c088aebf
8 changed files with 912 additions and 30 deletions
|
|
@ -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("/");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
91
src/components/frontend/dashboard/ActiveCampaigns.tsx
Normal file
91
src/components/frontend/dashboard/ActiveCampaigns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/frontend/dashboard/MissionBriefing.tsx
Normal file
160
src/components/frontend/dashboard/MissionBriefing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/frontend/dashboard/ProfileSummary.tsx
Normal file
96
src/components/frontend/dashboard/ProfileSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/frontend/dashboard/QuickStats.tsx
Normal file
79
src/components/frontend/dashboard/QuickStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/frontend/dashboard/RecentEvents.tsx
Normal file
104
src/components/frontend/dashboard/RecentEvents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/components/frontend/dashboard/UpcomingMissions.tsx
Normal file
114
src/components/frontend/dashboard/UpcomingMissions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue