feat(nav): collapsible sidebar + mobile drawer, user menu, breadcrumbs, polished cmd+k, header search
Build & Push Docker Image / build (push) Successful in 2m46s
Build & Push Docker Image / build (push) Successful in 2m46s
This commit is contained in:
@@ -1,193 +1,188 @@
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2, Bookmark } from "lucide-react";
|
||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Trash2, Bookmark } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { UserMenu } from "@/components/user-menu";
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs";
|
||||
import { HeaderSearch } from "@/components/header-search";
|
||||
import {
|
||||
SidebarProvider,
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [location] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth();
|
||||
const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth();
|
||||
const { data: version } = useGetVersion({
|
||||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||
});
|
||||
|
||||
const links = [
|
||||
const mainLinks = [
|
||||
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||
{ href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle },
|
||||
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
|
||||
...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
|
||||
...(isAdmin ? [{ href: "/admin", label: t("nav.admin"), icon: ShieldCheck }] : []),
|
||||
...(isAdmin ? [{ href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle }] : []),
|
||||
];
|
||||
|
||||
const activeLink = links.find(
|
||||
(link) => location === link.href || (link.href !== "/" && location.startsWith(link.href)),
|
||||
);
|
||||
const adminLinks = [
|
||||
{ href: "/admin", label: t("nav.admin"), icon: ShieldCheck },
|
||||
{ href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle },
|
||||
];
|
||||
|
||||
const mobileLinks = [
|
||||
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
|
||||
...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
|
||||
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||
].slice(0, 5);
|
||||
function isActive(href: string) {
|
||||
return location === href || (href !== "/" && location.startsWith(href));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background text-foreground">
|
||||
<SidebarProvider>
|
||||
<CommandPalette />
|
||||
<aside className="w-64 border-r bg-card flex flex-col hidden md:flex">
|
||||
<div className="p-6 border-b">
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="flex h-14 items-center justify-between border-b px-4">
|
||||
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-xl">
|
||||
<Wrench className="w-6 h-6" />
|
||||
<span>toolr</span>
|
||||
<Wrench className="w-6 h-6 shrink-0" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">toolr</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{links.map((link) => {
|
||||
const isActive = location === link.href || (link.href !== "/" && location.startsWith(link.href));
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${isActive ? "bg-primary text-primary-foreground font-medium" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<Skeleton className="w-8 h-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
) : isAuthenticated && user ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate text-foreground">
|
||||
{user.name || user.preferredUsername || "User"}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary">{tier}</span>
|
||||
{user.email && <span className="text-xs text-muted-foreground truncate">{user.email}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-destructive"
|
||||
onClick={logout}
|
||||
data-testid="button-logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("auth.signOut")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={() => login(location)}
|
||||
data-testid="button-login"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
{isLocalMode ? t("auth.signIn") : t("auth.signInKeycloak")}
|
||||
</Button>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mainLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<SidebarMenuItem key={link.href}>
|
||||
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
|
||||
<Link href={link.href}>
|
||||
<Icon />
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{isAdmin && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
||||
{t("nav.admin")}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{adminLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<SidebarMenuItem key={link.href}>
|
||||
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
|
||||
<Link href={link.href}>
|
||||
<Icon />
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span className="font-medium">toolr</span>
|
||||
{version?.commitSha ? (
|
||||
<a
|
||||
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono hover:underline"
|
||||
title={version.commitSha}
|
||||
>
|
||||
{version.version && version.version !== "dev"
|
||||
? version.version
|
||||
: `sha-${version.commitSha.slice(0, 7)}`}
|
||||
</a>
|
||||
) : version?.version && version.version !== "dev" ? (
|
||||
<span className="font-mono">{version.version}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<header className="hidden md:flex items-center justify-between border-b bg-card px-6 py-3">
|
||||
<h1 className="text-sm font-semibold text-muted-foreground">
|
||||
{activeLink?.label ?? "toolr"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="gap-2">
|
||||
<UserMenu />
|
||||
<div className="border-t pt-2 flex items-center justify-between text-[11px] text-muted-foreground px-2 group-data-[collapsible=icon]:justify-center">
|
||||
<span className="font-medium group-data-[collapsible=icon]:hidden">toolr</span>
|
||||
{version?.commitSha ? (
|
||||
<a
|
||||
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono hover:underline truncate group-data-[collapsible=icon]:hidden"
|
||||
title={version.commitSha}
|
||||
>
|
||||
{version.version && version.version !== "dev"
|
||||
? version.version
|
||||
: `sha-${version.commitSha.slice(0, 7)}`}
|
||||
</a>
|
||||
) : version?.version && version.version !== "dev" ? (
|
||||
<span className="font-mono group-data-[collapsible=icon]:hidden">{version.version}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<header className="md:hidden border-b p-4 flex items-center justify-between bg-card">
|
||||
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-lg">
|
||||
<Wrench className="w-5 h-5" />
|
||||
<span>toolr</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset>
|
||||
<header className="flex items-center justify-between gap-2 border-b bg-card px-3 md:px-5 h-14 shrink-0">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<SidebarTrigger className="h-8 w-8 shrink-0" data-testid="button-sidebar-toggle" />
|
||||
<Link
|
||||
href="/"
|
||||
className="md:hidden flex items-center gap-2 text-primary font-bold text-lg shrink-0 pl-1"
|
||||
>
|
||||
<Wrench className="w-5 h-5" />
|
||||
<span>toolr</span>
|
||||
</Link>
|
||||
<div className="hidden md:block min-w-0 pl-1">
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<HeaderSearch />
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
{!isLoading && (
|
||||
isAuthenticated ? (
|
||||
<Button variant="ghost" size="sm" onClick={logout} data-testid="button-logout-mobile">
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile">
|
||||
<LogIn className="w-4 h-4 mr-1" />
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
)
|
||||
<div className="md:hidden">
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={logout}
|
||||
aria-label={t("auth.signOut")}
|
||||
data-testid="button-logout-mobile"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => login(location)}
|
||||
data-testid="button-login-mobile"
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-1" />
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 p-6 md:p-8 overflow-auto pb-24 md:pb-8">
|
||||
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav className="md:hidden fixed bottom-0 inset-x-0 z-40 border-t bg-card/95 backdrop-blur flex">
|
||||
{mobileLinks.map((link) => {
|
||||
const isActive = location === link.href || (link.href !== "/" && location.startsWith(link.href));
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex-1 flex flex-col items-center gap-0.5 py-2.5 text-[10px] font-medium transition-colors ${
|
||||
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user