Improve sidebar: collapsible, grouped sections, scrollable nav, pinned footer
- Sidebar collapses to icon-only mode with toggle button - Nav items grouped into sections (Customers, Repairs, Storage, Admin) - Each section is independently collapsible - Middle nav area scrolls when items overflow - Help, profile, and sign out pinned to bottom
This commit is contained in:
@@ -8,7 +8,7 @@ import { myPermissionsOptions } from '@/api/rbac'
|
|||||||
import { moduleListOptions } from '@/api/modules'
|
import { moduleListOptions } from '@/api/modules'
|
||||||
import { Avatar } from '@/components/shared/avatar-upload'
|
import { Avatar } from '@/components/shared/avatar-upload'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings } from 'lucide-react'
|
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft } from 'lucide-react'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated')({
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
@@ -60,23 +60,50 @@ function StoreLogo() {
|
|||||||
return <img src={src} alt={storeData?.name ?? 'Store'} className="max-h-10 max-w-[180px] object-contain" />
|
return <img src={src} alt={storeData?.name ?? 'Store'} className="max-h-10 max-w-[180px] object-contain" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <h2 className="text-lg font-semibold text-sidebar-foreground">{storeData?.name ?? 'Forte'}</h2>
|
return <h2 className="text-lg font-semibold text-sidebar-foreground">{storeData?.name ?? 'LunarFront'}</h2>
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
|
function NavLink({ to, icon, label, collapsed }: { to: string; icon: React.ReactNode; label: string; collapsed?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to as '/accounts'}
|
to={to as '/accounts'}
|
||||||
search={{} as any}
|
search={{} as any}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
|
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
|
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
|
||||||
|
title={collapsed ? label : undefined}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
{!collapsed && label}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NavGroup({ label, children, collapsed }: { label: string; children: React.ReactNode; collapsed?: boolean }) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
|
||||||
|
if (collapsed) return <div className="space-y-1">{children}</div>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center justify-between w-full px-3 mb-1 mt-3 group cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide group-hover:text-sidebar-foreground/70">{label}</span>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 text-sidebar-foreground/40 transition-transform ${open ? '' : '-rotate-90'}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && <div className="space-y-1">{children}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AuthenticatedLayout() {
|
function AuthenticatedLayout() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
@@ -117,76 +144,92 @@ function AuthenticatedLayout() {
|
|||||||
const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view')
|
const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view')
|
||||||
const canViewUsers = !permissionsLoaded || hasPermission('users.view')
|
const canViewUsers = !permissionsLoaded || hasPermission('users.view')
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<nav className="w-56 border-r border-border bg-sidebar min-h-screen flex flex-col">
|
<nav className={`${collapsed ? 'w-14' : 'w-56'} border-r border-border bg-sidebar h-screen flex flex-col sticky top-0 transition-[width] duration-200`}>
|
||||||
<div className="p-4 space-y-1">
|
{/* Header — logo & collapse toggle */}
|
||||||
<StoreLogo />
|
<div className={`p-3 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
||||||
<p className="text-[10px] text-sidebar-foreground/40 tracking-wide">Amplified by <span className="font-semibold text-sidebar-foreground/60">Forte</span></p>
|
{!collapsed && (
|
||||||
</div>
|
<div className="min-w-0">
|
||||||
|
<StoreLogo />
|
||||||
{/* Sidebar links use `as any` on search because TanStack Router
|
<p className="text-[10px] text-sidebar-foreground/40 tracking-wide">Powered by <span className="font-semibold text-sidebar-foreground/60">LunarFront</span></p>
|
||||||
requires the full validated search shape, but these links just
|
|
||||||
navigate to the page with default params. */}
|
|
||||||
<div className="flex-1 px-3 space-y-1">
|
|
||||||
{canViewAccounts && (
|
|
||||||
<>
|
|
||||||
<NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" />
|
|
||||||
<NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isModuleEnabled('repairs') && canViewRepairs && (
|
|
||||||
<>
|
|
||||||
<NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Repairs" />
|
|
||||||
<NavLink to="/repair-batches" icon={<Package className="h-4 w-4" />} label="Repair Batches" />
|
|
||||||
{hasPermission('repairs.admin') && (
|
|
||||||
<NavLink to="/repairs/templates" icon={<ClipboardList className="h-4 w-4" />} label="Repair Templates" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isModuleEnabled('files') && (
|
|
||||||
<NavLink to="/files" icon={<FolderOpen className="h-4 w-4" />} label="Files" />
|
|
||||||
)}
|
|
||||||
{isModuleEnabled('vault') && (
|
|
||||||
<NavLink to="/vault" icon={<KeyRound className="h-4 w-4" />} label="Vault" />
|
|
||||||
)}
|
|
||||||
{canViewUsers && (
|
|
||||||
<div className="mt-4 mb-1 px-3">
|
|
||||||
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide">Admin</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-sidebar-foreground/50 hover:text-sidebar-foreground"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
{collapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable nav links */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 space-y-1 scrollbar-thin">
|
||||||
|
{canViewAccounts && (
|
||||||
|
<NavGroup label="Customers" collapsed={collapsed}>
|
||||||
|
<NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" collapsed={collapsed} />
|
||||||
|
<NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" collapsed={collapsed} />
|
||||||
|
</NavGroup>
|
||||||
|
)}
|
||||||
|
{isModuleEnabled('repairs') && canViewRepairs && (
|
||||||
|
<NavGroup label="Repairs" collapsed={collapsed}>
|
||||||
|
<NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Tickets" collapsed={collapsed} />
|
||||||
|
<NavLink to="/repair-batches" icon={<Package className="h-4 w-4" />} label="Batches" collapsed={collapsed} />
|
||||||
|
{hasPermission('repairs.admin') && (
|
||||||
|
<NavLink to="/repairs/templates" icon={<ClipboardList className="h-4 w-4" />} label="Templates" collapsed={collapsed} />
|
||||||
|
)}
|
||||||
|
</NavGroup>
|
||||||
|
)}
|
||||||
|
{(isModuleEnabled('files') || isModuleEnabled('vault')) && (
|
||||||
|
<NavGroup label="Storage" collapsed={collapsed}>
|
||||||
|
{isModuleEnabled('files') && (
|
||||||
|
<NavLink to="/files" icon={<FolderOpen className="h-4 w-4" />} label="Files" collapsed={collapsed} />
|
||||||
|
)}
|
||||||
|
{isModuleEnabled('vault') && (
|
||||||
|
<NavLink to="/vault" icon={<KeyRound className="h-4 w-4" />} label="Vault" collapsed={collapsed} />
|
||||||
|
)}
|
||||||
|
</NavGroup>
|
||||||
|
)}
|
||||||
{canViewUsers && (
|
{canViewUsers && (
|
||||||
<>
|
<NavGroup label="Admin" collapsed={collapsed}>
|
||||||
<NavLink to="/users" icon={<UserCog className="h-4 w-4" />} label="Users" />
|
<NavLink to="/users" icon={<UserCog className="h-4 w-4" />} label="Users" collapsed={collapsed} />
|
||||||
<NavLink to="/roles" icon={<Shield className="h-4 w-4" />} label="Roles" />
|
<NavLink to="/roles" icon={<Shield className="h-4 w-4" />} label="Roles" collapsed={collapsed} />
|
||||||
<NavLink to="/settings" icon={<Settings className="h-4 w-4" />} label="Settings" />
|
<NavLink to="/settings" icon={<Settings className="h-4 w-4" />} label="Settings" collapsed={collapsed} />
|
||||||
</>
|
</NavGroup>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 border-t border-sidebar-border space-y-1">
|
{/* Pinned footer — help, profile, sign out */}
|
||||||
<NavLink to="/help" icon={<HelpCircle className="h-4 w-4" />} label="Help" />
|
<div className="shrink-0 p-2 border-t border-sidebar-border space-y-1">
|
||||||
|
<NavLink to="/help" icon={<HelpCircle className="h-4 w-4" />} label="Help" collapsed={collapsed} />
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent w-full"
|
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent w-full"
|
||||||
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground w-full' }}
|
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground w-full' }}
|
||||||
|
title={collapsed ? `${user?.firstName} ${user?.lastName}` : undefined}
|
||||||
>
|
>
|
||||||
{user?.id ? <Avatar entityType="user" entityId={user.id} size="sm" /> : <User className="h-4 w-4" />}
|
{user?.id ? <Avatar entityType="user" entityId={user.id} size="sm" /> : <User className="h-4 w-4" />}
|
||||||
<span className="truncate">{user?.firstName} {user?.lastName}</span>
|
{!collapsed && <span className="truncate">{user?.firstName} {user?.lastName}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-2 text-sm text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
className={`w-full justify-start gap-2 text-sm text-sidebar-foreground/70 hover:text-sidebar-foreground ${collapsed ? 'px-3' : ''}`}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
title={collapsed ? 'Sign out' : undefined}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
Sign out
|
{!collapsed && 'Sign out'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6 min-h-screen">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user