Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
This commit is contained in:
116
apps/web/src/components/layout/AppSidebar.tsx
Normal file
116
apps/web/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NavLink, useLocation } from 'react-router';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { Logo } from '@/components/brand/Logo';
|
||||
import { ServerSelector } from './ServerSelector';
|
||||
import { navigation, isNavGroup, type NavItem, type NavGroup } from './nav-data';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NavMenuItem({ item }: { item: NavItem }) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
end={item.href === '/'}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(isActive && 'bg-sidebar-accent text-sidebar-accent-foreground')
|
||||
}
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NavMenuGroup({ group }: { group: NavGroup }) {
|
||||
const location = useLocation();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const isActive = group.children.some((child) =>
|
||||
location.pathname.startsWith(child.href)
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible defaultOpen={isActive} className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton className={cn(isActive && 'font-medium')}>
|
||||
<group.icon className="size-4" />
|
||||
<span>{group.name}</span>
|
||||
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{group.children.map((child) => (
|
||||
<SidebarMenuSubItem key={child.href}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink
|
||||
to={child.href}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(isActive && 'bg-sidebar-accent text-sidebar-accent-foreground')
|
||||
}
|
||||
>
|
||||
<child.icon className="size-4" />
|
||||
<span>{child.name}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader className="border-b p-0">
|
||||
<div className="flex h-14 items-center px-4">
|
||||
<Logo size="md" />
|
||||
</div>
|
||||
<ServerSelector />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navigation.map((entry) => {
|
||||
if (isNavGroup(entry)) {
|
||||
return <NavMenuGroup key={entry.name} group={entry} />;
|
||||
}
|
||||
return <NavMenuItem key={entry.href} item={entry} />;
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/components/layout/Header.tsx
Normal file
76
apps/web/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { LogOut, Settings } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { ModeToggle } from '@/components/ui/mode-toggle';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export function Header() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const initials = user?.username
|
||||
? user.username.slice(0, 2).toUpperCase()
|
||||
: 'U';
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<ModeToggle />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
{user?.thumbUrl && (
|
||||
<AvatarImage src={user.thumbUrl} alt={user.username} />
|
||||
)}
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xs">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium">{user?.username}</p>
|
||||
{user?.email && (
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/settings')}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/components/layout/Layout.tsx
Normal file
21
apps/web/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Outlet } from 'react-router';
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { AppSidebar } from './AppSidebar';
|
||||
import { Header } from './Header';
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
<ScrollArea className="flex-1">
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</ScrollArea>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
59
apps/web/src/components/layout/ServerSelector.tsx
Normal file
59
apps/web/src/components/layout/ServerSelector.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useServer } from '@/hooks/useServer';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { MediaServerIcon } from '@/components/icons/MediaServerIcon';
|
||||
|
||||
export function ServerSelector() {
|
||||
const { servers, selectedServerId, selectServer, isLoading, isFetching } = useServer();
|
||||
|
||||
// Show skeleton while loading initially or refetching with no cached data
|
||||
if (isLoading || (servers.length === 0 && isFetching)) {
|
||||
return (
|
||||
<div className="px-4 py-2">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No servers available
|
||||
if (servers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only show selector if there are multiple servers
|
||||
if (servers.length === 1) {
|
||||
const server = servers[0]!;
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground">
|
||||
<MediaServerIcon type={server.type} className="h-4 w-4" />
|
||||
<span className="truncate font-medium">{server.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2">
|
||||
<Select value={selectedServerId ?? undefined} onValueChange={selectServer}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder="Select server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<MediaServerIcon type={server.type} className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{server.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/components/layout/nav-data.ts
Normal file
48
apps/web/src/components/layout/nav-data.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Map,
|
||||
BarChart3,
|
||||
Users,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Film,
|
||||
UserCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
name: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
children: NavItem[];
|
||||
}
|
||||
|
||||
export type NavEntry = NavItem | NavGroup;
|
||||
|
||||
export function isNavGroup(entry: NavEntry): entry is NavGroup {
|
||||
return 'children' in entry;
|
||||
}
|
||||
|
||||
export const navigation: NavEntry[] = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Map', href: '/map', icon: Map },
|
||||
{
|
||||
name: 'Stats',
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{ name: 'Activity', href: '/stats/activity', icon: TrendingUp },
|
||||
{ name: 'Library', href: '/stats/library', icon: Film },
|
||||
{ name: 'Users', href: '/stats/users', icon: UserCircle },
|
||||
],
|
||||
},
|
||||
{ name: 'Users', href: '/users', icon: Users },
|
||||
{ name: 'Rules', href: '/rules', icon: Shield },
|
||||
{ name: 'Violations', href: '/violations', icon: AlertTriangle },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
];
|
||||
Reference in New Issue
Block a user