feat: add AppShell with persistent icon sidebar and mobile bottom tab bar

This commit is contained in:
2026-03-21 20:59:09 +01:00
parent 5c69ef716b
commit 710ac253e2
3 changed files with 121 additions and 7 deletions

View File

@@ -3,19 +3,20 @@ import { SteamCMDProvider } from '@/lib/context/SteamCMDContext';
import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext';
import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer';
import { requireAuth } from '@/lib/auth/server';
import { AppShell } from '@/components/ui/AppShell';
import { hasPermission } from '@/lib/schemas';
export default async function DashboardLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
export default async function DashboardLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const session = await requireAuth();
const showUsers = hasPermission(session.user!, 'membership.view');
return (
<WebSocketProvider openToken={session.openToken!}>
<SteamCMDProvider>
<ServerCreationPopupProvider>
{children}
<ServerCreationPopupContainer />
<AppShell showUsers={showUsers}>
{children}
<ServerCreationPopupContainer />
</AppShell>
</ServerCreationPopupProvider>
</SteamCMDProvider>
</WebSocketProvider>

View File

@@ -0,0 +1,19 @@
// NOTE: intentionally no 'use client' — must remain a Server Component
import { Sidebar } from './Sidebar';
interface AppShellProps {
children: React.ReactNode;
showUsers: boolean;
}
export function AppShell({ children, showUsers }: AppShellProps) {
return (
<div className="min-h-screen bg-gh-base">
<Sidebar showUsers={showUsers} />
{/* Offset for sidebar (desktop: left margin, mobile: bottom padding) */}
<div className="pb-14 md:pb-0 md:pl-14">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
interface SidebarProps {
showUsers: boolean; // true if user has membership.view permission
}
function ServerIcon({ active }: { active: boolean }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className={cn('h-5 w-5', active ? 'text-gh-blue' : 'text-gh-subtle')}>
<rect x="2" y="3" width="20" height="5" rx="1" />
<rect x="2" y="10" width="20" height="5" rx="1" />
<rect x="2" y="17" width="20" height="5" rx="1" />
<circle cx="6" cy="5.5" r="1" fill="currentColor" />
<circle cx="6" cy="12.5" r="1" fill="currentColor" />
<circle cx="6" cy="19.5" r="1" fill="currentColor" />
</svg>
);
}
function UsersIcon({ active }: { active: boolean }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className={cn('h-5 w-5', active ? 'text-gh-blue' : 'text-gh-subtle')}>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
);
}
export function Sidebar({ showUsers }: SidebarProps) {
const pathname = usePathname();
const isServers = !pathname.startsWith('/dashboard/membership');
const isUsers = pathname.startsWith('/dashboard/membership');
const navItems = [
{ href: '/dashboard', label: 'Servers', active: isServers, icon: <ServerIcon active={isServers} /> },
...(showUsers ? [{ href: '/dashboard/membership', label: 'Users', active: isUsers, icon: <UsersIcon active={isUsers} /> }] : []),
];
return (
<>
{/* Desktop icon sidebar */}
<aside className="fixed inset-y-0 left-0 z-40 hidden w-14 flex-col items-center border-r border-gh-border-muted bg-gh-sidebar py-3 md:flex">
{/* Logo */}
<div className="mb-4 flex h-8 w-8 items-center justify-center rounded-lg bg-gh-blue">
<svg viewBox="0 0 24 24" fill="none" stroke="#0d1117" strokeWidth={2.5} className="h-4 w-4">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
{/* Nav icons */}
<nav className="flex flex-1 flex-col items-center gap-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
title={item.label}
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
item.active ? 'bg-gh-blue-bg' : 'hover:bg-gh-overlay'
)}
>
{item.icon}
</Link>
))}
</nav>
{/* Avatar placeholder */}
<div className="mt-auto h-7 w-7 rounded-full border-2 border-gh-border bg-gh-overlay" />
</aside>
{/* Mobile bottom tab bar */}
<nav className="fixed inset-x-0 bottom-0 z-40 flex h-14 items-center justify-around border-t border-gh-border-muted bg-gh-sidebar md:hidden">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex flex-col items-center justify-center gap-0.5 px-4 py-2 text-xs transition-colors',
item.active ? 'text-gh-blue' : 'text-gh-subtle'
)}
>
{item.icon}
<span>{item.label}</span>
</Link>
))}
</nav>
</>
);
}