1
0
Fork 0

refactor(nav): sidebar minigames group, inline admin link, hidden map entry

Move minigame links from the user dropdown into a NavMinigames sidebar group, inline the admin link in AppSidebar via a new canAccessAdminPanel helper, guard NavCore against empty groups, drop the feedback secondary link, and add an empty navMap slot with a restore comment so the World Map ships hidden while /map stays live.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-16 23:14:16 -04:00
parent fcf220b961
commit ad2fbb167a
7 changed files with 131 additions and 78 deletions

View file

@ -11,6 +11,7 @@ import { Metadata } from "next";
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel"; import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { canAccessAdminPanel } from "@/utils/access-control/hasPermission";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts"; import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts"; import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime"; import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
@ -77,6 +78,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const shimsGlobal = await payload.findGlobal({ slug: "shims" }); const shimsGlobal = await payload.findGlobal({ slug: "shims" });
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>; const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
let isAdmin = false;
if (user) { if (user) {
const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] = const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
await Promise.all([ await Promise.all([
@ -121,6 +124,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
xpLevel = resolveLevel(userXp, experienceRes.docs); xpLevel = resolveLevel(userXp, experienceRes.docs);
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? ""; currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
isAdmin = await canAccessAdminPanel(payload, user);
} }
return ( return (
@ -141,7 +146,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
rank: currentRank, rank: currentRank,
}} }}
xpLevel={xpLevel} xpLevel={xpLevel}
adminUrl={payloadConfig.routes.admin} adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
hasIntelligence={isIntelligence} hasIntelligence={isIntelligence}
hasLogistics={isLogistics} hasLogistics={isLogistics}
showCallsign={user.preferences?.display?.showCallsign ?? false} showCallsign={user.preferences?.display?.showCallsign ?? false}

View file

@ -14,7 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { NavCore } from "@/components/frontend/blocks/NavCore"; import { NavCore } from "@/components/frontend/blocks/NavCore";
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary"; import { NavMinigames } from "@/components/frontend/blocks/NavMinigames";
import { NavUser } from "@/components/frontend/blocks/NavUser"; import { NavUser } from "@/components/frontend/blocks/NavUser";
import { import {
Sidebar, Sidebar,
@ -149,6 +149,7 @@ export function AppSidebar({
<SidebarContent className="overflow-hidden"> <SidebarContent className="overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto group-data-[collapsible=icon]:overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto group-data-[collapsible=icon]:overflow-hidden">
<NavCore label="Platform" items={data.navMain} /> <NavCore label="Platform" items={data.navMain} />
<NavCore label="Map" items={data.navMap} />
{hasIntelligence && ( {hasIntelligence && (
<NavCore label="Intelligence" items={data.navIntelligence} /> <NavCore label="Intelligence" items={data.navIntelligence} />
)} )}
@ -162,14 +163,19 @@ export function AppSidebar({
{/*<NavProjects projects={data.projects} />*/} {/*<NavProjects projects={data.projects} />*/}
</div> </div>
<hr /> <hr />
<NavSecondary <SidebarMenu className="px-1">
items={ {adminUrl && (
adminUrl <SidebarMenuItem>
? [...data.navSecondary, { title: "Admin", url: adminUrl, icon: Shield }] <SidebarMenuButton size="sm" asChild>
: data.navSecondary <Link href={adminUrl}>
} <Shield />
className="mt-auto" <span>Admin</span>
/> </Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
<div className="pb-1"><NavMinigames /></div>
</SidebarMenu>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<div className="flex flex-col"> <div className="flex flex-col">

View file

@ -32,6 +32,8 @@ export function NavCore({
}[]; }[];
}[]; }[];
}) { }) {
if (items.length === 0) return null;
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel> <SidebarGroupLabel>{label}</SidebarGroupLabel>

View file

@ -0,0 +1,83 @@
"use client";
import { Bird, Bomb, ChevronRight, Club, Crosshair, FileTextIcon, Gamepad2, Radio, Spade } from "lucide-react";
import Link from "next/link";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
const coreGames = [
{ title: "Flight Simulator", url: "/minigames/flappy", icon: Bird },
{ title: "Minefield Clearance", url: "/minigames/eod", icon: Bomb },
{ title: "Radio Traffic", url: "/minigames/radio", icon: Radio },
{ title: "Qualification Course", url: "/minigames/qualification", icon: Crosshair },
{ title: "OPORD Recall", url: "/minigames/opord", icon: FileTextIcon },
];
const cardtableGames = [
{ title: "Poker", url: "/minigames/poker", icon: Spade },
{ title: "Blackjack", url: "/minigames/blackjack", icon: Club },
];
export function NavMinigames() {
const { isMobile } = useSidebar();
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="sm">
<Gamepad2 />
<span>Minigames</span>
<ChevronRight className="ml-auto size-4 text-sidebar-foreground/50" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel>Core</DropdownMenuLabel>
{coreGames.map((game) => (
<DropdownMenuItem key={game.url} asChild>
<Link href={game.url}>
<game.icon />
{game.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col">
<span>Cardtable</span>
<span className="text-xs font-normal text-muted-foreground">Earn chips to spend by playing Core minigames!</span>
</DropdownMenuLabel>
{cardtableGames.map((game) => (
<DropdownMenuItem key={game.url} asChild>
<Link href={game.url}>
<game.icon />
{game.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
}

View file

@ -2,17 +2,10 @@
import { import {
BadgeCheck, BadgeCheck,
Bird,
Bomb,
ChevronsUpDown, ChevronsUpDown,
Crosshair,
FileTextIcon, FileTextIcon,
Gamepad2,
LogOut, LogOut,
Mic, Mic,
Radio,
Spade,
Club,
Users2, Users2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -27,9 +20,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@ -139,56 +129,6 @@ export function NavUser({
Service Record Service Record
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Gamepad2 />
Exercises
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem asChild>
<Link href="/minigames/flappy">
<Bird />
Flight Simulator
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/minigames/eod">
<Bomb />
Minefield Clearance
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/minigames/radio">
<Radio />
Radio Traffic
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/minigames/qualification">
<Crosshair />
Qualification Course
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/minigames/opord">
<FileTextIcon />
OPORD Recall
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/minigames/poker">
<Spade />
Poker
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/minigames/blackjack">
<Club />
Blackjack
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>

View file

@ -14,7 +14,6 @@ import {
Package, Package,
ScrollText, ScrollText,
Search, Search,
Send,
ShoppingBag, ShoppingBag,
Swords, Swords,
TrendingUp, TrendingUp,
@ -24,6 +23,10 @@ import {
} from "lucide-react"; } from "lucide-react";
export const sidebarData = { export const sidebarData = {
// World Map hidden until the feature ships; the /map route and all of its
// functionality stay live for direct-URL access. Restore with:
// [{ title: "World Map", url: "/map", icon: MapIcon }] (and the MapIcon import).
navMap: [],
navHelpdesk: [ navHelpdesk: [
{ {
title: "Helpdesk", title: "Helpdesk",
@ -157,11 +160,5 @@ export const sidebarData = {
icon: ScrollText, icon: ScrollText,
}, },
], ],
navSecondary: [ navSecondary: [],
{
title: "Feedback",
url: "https://onyxsimple.youtrack.cloud",
icon: Send,
},
],
}; };

View file

@ -227,6 +227,26 @@ export function requireCampaignOwnership(permission: Permission) {
}; };
} }
/**
* Check whether a user can access the Payload admin panel.
*
* A user has admin panel access if they are a superuser OR hold any
* `admin:<slug>:manage` permission (which is what `requireAdminPageAccess`
* requires for any collection).
*/
export async function canAccessAdminPanel(
payload: Payload,
user: UserLike | null | undefined,
): Promise<boolean> {
if (!user) return false;
const { permissions, isSuperuser } = await loadUserPermissions(payload, user);
if (isSuperuser) return true;
for (const p of permissions) {
if (p.startsWith("admin:") && p.endsWith(":manage")) return true;
}
return false;
}
/** /**
* Resolve the ids of every user holding a superuser role. * Resolve the ids of every user holding a superuser role.
* *