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

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 },
];