feat: name current view in role-switcher pill, add Mentors sidebar entry

- Switcher trigger now shows the current view's icon + label with a
  chevron (e.g. "Admin View ⌄") instead of the vague "Switch View".
  Dropdown adds a header, marks the current view with a checkmark,
  and lists each accessible alternative explicitly.
- Adds a "Mentors" entry to the admin sidebar between Juries and
  Awards so the existing /admin/mentors page is reachable from nav.
This commit is contained in:
Matt
2026-04-28 16:32:51 +02:00
parent e37f3a5874
commit 11ab0943f6
2 changed files with 38 additions and 18 deletions

View File

@@ -30,6 +30,7 @@ import {
LogOut, LogOut,
ChevronRight, ChevronRight,
BookOpen, BookOpen,
GraduationCap,
Handshake, Handshake,
History, History,
Trophy, Trophy,
@@ -85,6 +86,11 @@ const navigation: NavItem[] = [
href: '/admin/juries', href: '/admin/juries',
icon: Scale, icon: Scale,
}, },
{
name: 'Mentors',
href: '/admin/mentors',
icon: GraduationCap,
},
{ {
name: 'Awards', name: 'Awards',
href: '/admin/awards', href: '/admin/awards',

View File

@@ -4,7 +4,8 @@ import { useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { import {
ArrowRightLeft, Check,
ChevronDown,
Eye, Eye,
Handshake, Handshake,
LayoutDashboard, LayoutDashboard,
@@ -16,6 +17,8 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -33,12 +36,8 @@ export const ROLE_SWITCH_OPTIONS: Record<string, RoleSwitchOption> = {
AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy }, AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy },
} }
/**
* Returns the list of dashboards the current user can switch to,
* excluding the one matching `currentBasePath`. Deduplicates admin paths
* (SUPER_ADMIN + PROGRAM_ADMIN both go to /admin).
*/
export function useRoleSwitcher(currentBasePath: string): { export function useRoleSwitcher(currentBasePath: string): {
currentOption: RoleSwitchOption | null
switchableRoles: Array<[string, RoleSwitchOption]> switchableRoles: Array<[string, RoleSwitchOption]>
isImpersonating: boolean isImpersonating: boolean
} { } {
@@ -46,32 +45,47 @@ export function useRoleSwitcher(currentBasePath: string): {
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? [] const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const isImpersonating = !!session?.user?.impersonating const isImpersonating = !!session?.user?.impersonating
const switchableRoles = useMemo(() => { const { currentOption, switchableRoles } = useMemo(() => {
return Object.entries(ROLE_SWITCH_OPTIONS) const accessible = Object.entries(ROLE_SWITCH_OPTIONS)
.filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== currentBasePath) .filter(([role]) => userRoles.includes(role as UserRole))
.filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i) .filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i)
const current = accessible.find(([, opt]) => opt.path === currentBasePath)?.[1] ?? null
const others = accessible.filter(([, opt]) => opt.path !== currentBasePath)
return { currentOption: current, switchableRoles: others }
}, [userRoles, currentBasePath]) }, [userRoles, currentBasePath])
return { switchableRoles, isImpersonating } return { currentOption, switchableRoles, isImpersonating }
} }
/** /**
* Top-right "Switch View" pill. Hidden for single-role users and during * Top-right view-switcher pill. Trigger names the current view; the dropdown
* impersonation. Shows a popover listing alternate dashboards. * lists alternative views. Hidden for single-view users and during impersonation.
*/ */
export function RoleSwitcherPill({ currentBasePath }: { currentBasePath: string }) { export function RoleSwitcherPill({ currentBasePath }: { currentBasePath: string }) {
const { switchableRoles, isImpersonating } = useRoleSwitcher(currentBasePath) const { currentOption, switchableRoles, isImpersonating } = useRoleSwitcher(currentBasePath)
if (switchableRoles.length === 0 || isImpersonating) return null if (switchableRoles.length === 0 || isImpersonating || !currentOption) return null
const CurrentIcon = currentOption.icon
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5"> <Button variant="outline" size="sm" className="gap-1.5" aria-label="Switch view">
<ArrowRightLeft className="h-3.5 w-3.5" /> <CurrentIcon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Switch View</span> <span className="hidden sm:inline">{currentOption.label}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44"> <DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuLabel className="text-muted-foreground text-xs font-normal">
Switch to another view
</DropdownMenuLabel>
<DropdownMenuItem disabled className="opacity-100">
<CurrentIcon className="mr-2 h-4 w-4" />
<span className="flex-1">{currentOption.label}</span>
<Check className="text-muted-foreground h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuSeparator />
{switchableRoles.map(([role, opt]) => ( {switchableRoles.map(([role, opt]) => (
<DropdownMenuItem key={role} asChild> <DropdownMenuItem key={role} asChild>
<Link href={opt.path as Route} className="flex cursor-pointer items-center"> <Link href={opt.path as Route} className="flex cursor-pointer items-center">