diff --git a/src/app/(frontend)/dashboard/page.tsx b/src/app/(frontend)/dashboard/page.tsx index 61c0faf..24119b8 100644 --- a/src/app/(frontend)/dashboard/page.tsx +++ b/src/app/(frontend)/dashboard/page.tsx @@ -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 ( -
test
- ) + redirect("/"); } diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx index 07c06c8..d77c42b 100644 --- a/src/app/(frontend)/page.tsx +++ b/src/app/(frontend)/page.tsx @@ -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 ( +
+ +
+

Welcome to Polaris Task Force

+

+ Log in to access the operations dashboard. +

+
+ + Go to login + +
+ ); + } + + 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 ( -
- {/**/} +
+
+

+ Welcome back, {profileUser?.displayName ?? user.username} +

+

+ Here's your operations dashboard. +

+
- {/**/} +
+ {/* ── Main Content (2/3) ──────────────────────────── */} +
+
+ {profile && profileUser && ( + + )} + +
+ + + + +
+ + {/* ── Right Sidebar (1/3) ─────────────────────────── */} +
+ + +
+
); } diff --git a/src/components/frontend/dashboard/ActiveCampaigns.tsx b/src/components/frontend/dashboard/ActiveCampaigns.tsx new file mode 100644 index 0000000..2c08909 --- /dev/null +++ b/src/components/frontend/dashboard/ActiveCampaigns.tsx @@ -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 = { + inProgress: "bg-blue-500/20 text-blue-400", + concept: "bg-muted text-muted-foreground", +}; + +export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) { + if (campaigns.length === 0) { + return ( + + +
+ + Active Campaigns +
+
+ +

+ No active campaigns. +

+
+
+ ); + } + + return ( + + +
+ + Active Campaigns +
+ + View all + +
+ +
+ {campaigns.map((campaign) => ( + + +
+
+ + {campaign.name} + + + {campaign.status === "inProgress" ? "Active" : campaign.status} + +
+ + {campaign.summary} + +
+ + + ))} +
+
+
+ ); +} diff --git a/src/components/frontend/dashboard/MissionBriefing.tsx b/src/components/frontend/dashboard/MissionBriefing.tsx new file mode 100644 index 0000000..5c17b20 --- /dev/null +++ b/src/components/frontend/dashboard/MissionBriefing.tsx @@ -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 = { + 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 ( + + +
+ + Mission Briefing +
+
+ +

+ No missions currently scheduled. +

+
+
+ ); + } + + 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} + )} +
+
+
+ ); +} diff --git a/src/components/frontend/dashboard/ProfileSummary.tsx b/src/components/frontend/dashboard/ProfileSummary.tsx new file mode 100644 index 0000000..507266f --- /dev/null +++ b/src/components/frontend/dashboard/ProfileSummary.tsx @@ -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 & { insigniaUrl?: string | null }; + xpLevel: ResolvedLevel; + assignments: Pick[]; +} + +export function ProfileSummary({ + displayName, + username, + rank, + xpLevel, + assignments, +}: ProfileSummaryProps) { + return ( + + +
+ + Service Summary +
+ + View full record + +
+ +
+ {rank.insigniaUrl && ( + {rank.name} + )} +
+

+ {displayName} +

+
+ {rank.name} + | + + {xpLevel.levelName} + +
+
+
+ +
+
+
+
+ + {xpLevel.xp.toLocaleString()} XP + +
+ + {assignments.length > 0 && ( +
+ {assignments.map((a) => ( + + {a.callsign ? `[${a.callsign}] ` : ""} + {a.name} + + ({a.type}) + + + ))} +
+ )} + + + ); +} diff --git a/src/components/frontend/dashboard/QuickStats.tsx b/src/components/frontend/dashboard/QuickStats.tsx new file mode 100644 index 0000000..e462ad8 --- /dev/null +++ b/src/components/frontend/dashboard/QuickStats.tsx @@ -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 ( +
+ + + + + + Active Campaigns + {activeCampaigns} + + + + + + + + Total Missions + {totalMissions} + + + + + + + + Structures + {totalStructures} + + + + + + + + Factions + {totalFactions} + + + + + + + + Operators + {totalMembers} + + +
+ ); +} diff --git a/src/components/frontend/dashboard/RecentEvents.tsx b/src/components/frontend/dashboard/RecentEvents.tsx new file mode 100644 index 0000000..d9dffdb --- /dev/null +++ b/src/components/frontend/dashboard/RecentEvents.tsx @@ -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 ( + + +
+ + Recent Events +
+
+ +

+ No recent events recorded. +

+
+
+ ); + } + + return ( + + +
+ + Recent Events +
+
+ +
+ {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 ( +
+ + {timeAgo(event.timestamp)} + +
+

+ {actorName && ( + {actorName} + )} + {event.message} +

+ + {event.type} + +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/frontend/dashboard/UpcomingMissions.tsx b/src/components/frontend/dashboard/UpcomingMissions.tsx new file mode 100644 index 0000000..bd4c20c --- /dev/null +++ b/src/components/frontend/dashboard/UpcomingMissions.tsx @@ -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 ( + + +
+ + Upcoming Missions +
+
+ +

+ No missions scheduled. +

+
+
+ ); + } + + return ( + + +
+ + Upcoming Missions +
+ + 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} + + )} +
+ + + ); + })} +
+
+
+ ); +}