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:
@@ -0,0 +1,93 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { useLocation, Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { useGetTool, getGetToolQueryKey } from "@workspace/api-client-react";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
|
||||||
|
type Crumb = { href?: string; label: string };
|
||||||
|
|
||||||
|
function buildCrumbs(location: string, t: TFunction): Crumb[] {
|
||||||
|
const crumbs: Crumb[] = [];
|
||||||
|
|
||||||
|
if (location.startsWith("/tools")) {
|
||||||
|
crumbs.push({ href: "/tools", label: t("nav.browseTools") });
|
||||||
|
const match = location.match(/^\/tools\/(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
crumbs.push({ href: `/tools/${match[1]}`, label: t("browse.tool") });
|
||||||
|
if (location.includes("/edit")) crumbs.push({ label: t("common.edit") });
|
||||||
|
} else if (location.startsWith("/tools/new")) {
|
||||||
|
crumbs.push({ label: t("nav.addTool") });
|
||||||
|
}
|
||||||
|
} else if (location.startsWith("/admin")) {
|
||||||
|
crumbs.push({ href: "/admin", label: t("nav.admin") });
|
||||||
|
if (location.startsWith("/admin/redundancy")) crumbs.push({ label: t("nav.redundancy") });
|
||||||
|
} else if (location.startsWith("/watchlist")) {
|
||||||
|
crumbs.push({ label: t("nav.watchlist") });
|
||||||
|
} else if (location.startsWith("/trash")) {
|
||||||
|
crumbs.push({ label: t("nav.trash") });
|
||||||
|
} else if (location.startsWith("/compare")) {
|
||||||
|
crumbs.push({ label: t("compare.title") });
|
||||||
|
} else if (location.startsWith("/analytics")) {
|
||||||
|
crumbs.push({ label: t("nav.analytics") });
|
||||||
|
} else if (location.startsWith("/login")) {
|
||||||
|
crumbs.push({ label: t("auth.signIn") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs() {
|
||||||
|
const [location] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const detailMatch = location.match(/^\/tools\/(\d+)/);
|
||||||
|
const toolId = detailMatch ? parseInt(detailMatch[1], 10) : 0;
|
||||||
|
const { data: tool } = useGetTool(Number.isFinite(toolId) ? toolId : 0, {
|
||||||
|
query: {
|
||||||
|
queryKey: getGetToolQueryKey(toolId),
|
||||||
|
enabled: Number.isFinite(toolId) && toolId > 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const crumbs = buildCrumbs(location, t);
|
||||||
|
if (crumbs.length <= 1) return null;
|
||||||
|
|
||||||
|
if (toolId > 0 && tool?.name) {
|
||||||
|
const idx = crumbs.findIndex((c) => c.href === `/tools/${toolId}`);
|
||||||
|
if (idx >= 0) crumbs[idx].label = tool.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb className="min-w-0">
|
||||||
|
<BreadcrumbList className="flex-nowrap">
|
||||||
|
{crumbs.map((crumb, i) => {
|
||||||
|
const isLast = i === crumbs.length - 1;
|
||||||
|
return (
|
||||||
|
<Fragment key={i}>
|
||||||
|
{i > 0 && <BreadcrumbSeparator />}
|
||||||
|
<BreadcrumbItem className="min-w-0">
|
||||||
|
{isLast || !crumb.href ? (
|
||||||
|
<BreadcrumbPage className="text-sm truncate">{crumb.label}</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink asChild className="text-sm">
|
||||||
|
<Link href={crumb.href} className="truncate">
|
||||||
|
{crumb.label}
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getRecentTools } from "@/lib/recent-tools";
|
||||||
import {
|
import {
|
||||||
Compass,
|
Compass,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
@@ -20,16 +21,32 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Wrench,
|
Wrench,
|
||||||
Star,
|
Star,
|
||||||
|
History,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const openRequesters = new Set<() => void>();
|
||||||
|
|
||||||
|
export function requestOpenCommandPalette() {
|
||||||
|
for (const request of openRequesters) request();
|
||||||
|
}
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAuthenticated, isAdmin, hasFeature } = useAuth();
|
const { isAuthenticated, isAdmin, hasFeature } = useAuth();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [recent, setRecent] = useState(() => getRecentTools());
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const request = () => setOpen(true);
|
||||||
|
openRequesters.add(request);
|
||||||
|
return () => {
|
||||||
|
openRequesters.delete(request);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -42,7 +59,11 @@ export function CommandPalette() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) setSearch("");
|
if (!open) {
|
||||||
|
setSearch("");
|
||||||
|
} else {
|
||||||
|
setRecent(getRecentTools());
|
||||||
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const { data: tools } = useListTools(
|
const { data: tools } = useListTools(
|
||||||
@@ -63,6 +84,7 @@ export function CommandPalette() {
|
|||||||
|
|
||||||
const showTrash = hasFeature("trash") || isAdmin;
|
const showTrash = hasFeature("trash") || isAdmin;
|
||||||
const showWatchlist = (hasFeature("watchlist") || isAdmin) && isAuthenticated;
|
const showWatchlist = (hasFeature("watchlist") || isAdmin) && isAuthenticated;
|
||||||
|
const searching = search.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -76,9 +98,21 @@ export function CommandPalette() {
|
|||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{search ? `No tools found for "${search}".` : "Start typing to search tools."}
|
{searching ? t("command.noResults", { query: search }) : t("command.startTyping")}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup heading="Navigate">
|
|
||||||
|
{!searching && recent.length > 0 && (
|
||||||
|
<CommandGroup heading={t("command.recent")}>
|
||||||
|
{recent.map((tool) => (
|
||||||
|
<CommandItem key={tool.id} onSelect={() => go(`/tools/${tool.id}`)}>
|
||||||
|
<History className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="truncate">{tool.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CommandGroup heading={t("command.navigate")}>
|
||||||
<CommandItem onSelect={() => go("/tools")}>
|
<CommandItem onSelect={() => go("/tools")}>
|
||||||
<Compass className="mr-2 h-4 w-4" /> {t("nav.browseTools")}
|
<Compass className="mr-2 h-4 w-4" /> {t("nav.browseTools")}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -104,10 +138,10 @@ export function CommandPalette() {
|
|||||||
</CommandItem>
|
</CommandItem>
|
||||||
)}
|
)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{search.trim().length > 0 && (
|
{searching && (
|
||||||
<>
|
<>
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
<CommandGroup heading="Tools">
|
<CommandGroup heading={t("command.tools")}>
|
||||||
{(tools ?? []).slice(0, 10).map((tool) => (
|
{(tools ?? []).slice(0, 10).map((tool) => (
|
||||||
<CommandItem key={tool.id} onSelect={() => go(`/tools/${tool.id}`)}>
|
<CommandItem key={tool.id} onSelect={() => go(`/tools/${tool.id}`)}>
|
||||||
<Wrench className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
<Wrench className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Kbd } from "@/components/ui/kbd";
|
||||||
|
import { requestOpenCommandPalette } from "@/components/command-palette";
|
||||||
|
|
||||||
|
export function HeaderSearch() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden md:inline-flex justify-start gap-2 w-56 text-muted-foreground"
|
||||||
|
onClick={requestOpenCommandPalette}
|
||||||
|
data-testid="button-header-search"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="flex-1 text-left text-sm truncate">{t("nav.search")}</span>
|
||||||
|
<Kbd className="hidden lg:inline-flex">
|
||||||
|
<span>⌘</span>K
|
||||||
|
</Kbd>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,126 +1,123 @@
|
|||||||
import { Link, useLocation } from "wouter";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
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 }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { t } = useTranslation();
|
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({
|
const { data: version } = useGetVersion({
|
||||||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const links = [
|
const mainLinks = [
|
||||||
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||||
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||||
{ href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle },
|
{ href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle },
|
||||||
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||||
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
|
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
|
||||||
...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
|
...(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(
|
const adminLinks = [
|
||||||
(link) => location === link.href || (link.href !== "/" && location.startsWith(link.href)),
|
{ href: "/admin", label: t("nav.admin"), icon: ShieldCheck },
|
||||||
);
|
{ href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle },
|
||||||
|
];
|
||||||
|
|
||||||
const mobileLinks = [
|
function isActive(href: string) {
|
||||||
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
return location === href || (href !== "/" && location.startsWith(href));
|
||||||
{ 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-background text-foreground">
|
<SidebarProvider>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<aside className="w-64 border-r bg-card flex flex-col hidden md:flex">
|
<Sidebar collapsible="icon">
|
||||||
<div className="p-6 border-b">
|
<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">
|
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-xl">
|
||||||
<Wrench className="w-6 h-6" />
|
<Wrench className="w-6 h-6 shrink-0" />
|
||||||
<span>toolr</span>
|
<span className="group-data-[collapsible=icon]:hidden">toolr</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</SidebarHeader>
|
||||||
<nav className="flex-1 p-4 space-y-2">
|
<SidebarContent>
|
||||||
{links.map((link) => {
|
<SidebarGroup>
|
||||||
const isActive = location === link.href || (link.href !== "/" && location.startsWith(link.href));
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{mainLinks.map((link) => {
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<SidebarMenuItem key={link.href}>
|
||||||
key={link.href}
|
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
|
||||||
href={link.href}
|
<Link 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 />
|
||||||
>
|
<span>{link.label}</span>
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
{link.label}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
<div className="p-4 border-t">
|
</SidebarGroup>
|
||||||
{isLoading ? (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<SidebarGroup>
|
||||||
<Skeleton className="w-8 h-8 rounded-full" />
|
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
||||||
<Skeleton className="h-4 w-24" />
|
{t("nav.admin")}
|
||||||
</div>
|
</SidebarGroupLabel>
|
||||||
) : isAuthenticated && user ? (
|
<SidebarGroupContent>
|
||||||
<div className="space-y-2">
|
<SidebarMenu>
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50">
|
{adminLinks.map((link) => {
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
|
const Icon = link.icon;
|
||||||
<User className="w-4 h-4 text-primary" />
|
return (
|
||||||
</div>
|
<SidebarMenuItem key={link.href}>
|
||||||
<div className="min-w-0">
|
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
|
||||||
<p className="text-sm font-medium truncate text-foreground">
|
<Link href={link.href}>
|
||||||
{user.name || user.preferredUsername || "User"}
|
<Icon />
|
||||||
</p>
|
<span>{link.label}</span>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
</Link>
|
||||||
<span className="text-[10px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary">{tier}</span>
|
</SidebarMenuButton>
|
||||||
{user.email && <span className="text-xs text-muted-foreground truncate">{user.email}</span>}
|
</SidebarMenuItem>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</SidebarMenu>
|
||||||
<Button
|
</SidebarGroupContent>
|
||||||
variant="ghost"
|
</SidebarGroup>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</SidebarContent>
|
||||||
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
|
<SidebarFooter className="gap-2">
|
||||||
<span className="font-medium">toolr</span>
|
<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 ? (
|
{version?.commitSha ? (
|
||||||
<a
|
<a
|
||||||
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
|
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-mono hover:underline"
|
className="font-mono hover:underline truncate group-data-[collapsible=icon]:hidden"
|
||||||
title={version.commitSha}
|
title={version.commitSha}
|
||||||
>
|
>
|
||||||
{version.version && version.version !== "dev"
|
{version.version && version.version !== "dev"
|
||||||
@@ -128,66 +125,64 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
: `sha-${version.commitSha.slice(0, 7)}`}
|
: `sha-${version.commitSha.slice(0, 7)}`}
|
||||||
</a>
|
</a>
|
||||||
) : version?.version && version.version !== "dev" ? (
|
) : version?.version && version.version !== "dev" ? (
|
||||||
<span className="font-mono">{version.version}</span>
|
<span className="font-mono group-data-[collapsible=icon]:hidden">{version.version}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<SidebarInset>
|
||||||
<header className="hidden md:flex items-center justify-between border-b bg-card px-6 py-3">
|
<header className="flex items-center justify-between gap-2 border-b bg-card px-3 md:px-5 h-14 shrink-0">
|
||||||
<h1 className="text-sm font-semibold text-muted-foreground">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
{activeLink?.label ?? "toolr"}
|
<SidebarTrigger className="h-8 w-8 shrink-0" data-testid="button-sidebar-toggle" />
|
||||||
</h1>
|
<Link
|
||||||
<div className="flex items-center gap-1">
|
href="/"
|
||||||
<LanguageSwitcher />
|
className="md:hidden flex items-center gap-2 text-primary font-bold text-lg shrink-0 pl-1"
|
||||||
<ThemeToggle />
|
>
|
||||||
</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" />
|
<Wrench className="w-5 h-5" />
|
||||||
<span>toolr</span>
|
<span>toolr</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-1">
|
<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 />
|
<LanguageSwitcher />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
isAuthenticated ? (
|
<div className="md:hidden">
|
||||||
<Button variant="ghost" size="sm" onClick={logout} data-testid="button-logout-mobile">
|
{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" />
|
<LogOut className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => login(location)}
|
||||||
|
data-testid="button-login-mobile"
|
||||||
|
>
|
||||||
<LogIn className="w-4 h-4 mr-1" />
|
<LogIn className="w-4 h-4 mr-1" />
|
||||||
{t("auth.signIn")}
|
{t("auth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function initials(name?: string | null): string {
|
||||||
|
if (!name) return "?";
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "?";
|
||||||
|
return parts
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const [location, navigate] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const showWatchlist = hasFeature("watchlist");
|
||||||
|
const showTrash = hasFeature("trash");
|
||||||
|
const displayName = user?.name || user?.preferredUsername || "User";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={() => login(location)}
|
||||||
|
data-testid="button-login"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
{t("auth.signIn")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(path: string) {
|
||||||
|
setOpen(false);
|
||||||
|
navigate(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-3 px-3 py-2 h-auto"
|
||||||
|
data-testid="button-user-menu"
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback className="bg-primary/15 text-primary text-xs font-semibold">
|
||||||
|
{initials(displayName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="min-w-0 flex-1 text-left">
|
||||||
|
<span className="block text-sm font-medium truncate">{displayName}</span>
|
||||||
|
<span className="block text-[11px] text-muted-foreground truncate">
|
||||||
|
{user.email ?? tier}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between gap-2 font-normal">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-foreground truncate">{displayName}</span>
|
||||||
|
{user.email && <span className="block text-xs text-muted-foreground truncate">{user.email}</span>}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-[10px] uppercase tracking-wider",
|
||||||
|
isAdmin && "border-primary text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAdmin ? "admin" : tier}
|
||||||
|
</Badge>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{(showWatchlist || showTrash) && (
|
||||||
|
<>
|
||||||
|
{showWatchlist && (
|
||||||
|
<DropdownMenuItem onSelect={() => go("/watchlist")}>
|
||||||
|
<Bookmark className="mr-2 h-4 w-4" />
|
||||||
|
{t("nav.watchlist")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{showTrash && (
|
||||||
|
<DropdownMenuItem onSelect={() => go("/trash")}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("nav.trash")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={logout}
|
||||||
|
data-testid="button-logout"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
{t("auth.signOut")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"watchlist": "Merkliste",
|
"watchlist": "Merkliste",
|
||||||
"trash": "Papierkorb",
|
"trash": "Papierkorb",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"redundancy": "Redundanz"
|
"redundancy": "Redundanz",
|
||||||
|
"search": "Tools suchen…"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "Anmelden",
|
"signIn": "Anmelden",
|
||||||
@@ -164,5 +165,12 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"text": "Diese Seite existiert nicht.",
|
"text": "Diese Seite existiert nicht.",
|
||||||
"backHome": "Zurück zur Startseite"
|
"backHome": "Zurück zur Startseite"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"navigate": "Navigation",
|
||||||
|
"recent": "Zuletzt besucht",
|
||||||
|
"tools": "Tools",
|
||||||
|
"noResults": "Keine Tools für „{{query}}“ gefunden.",
|
||||||
|
"startTyping": "Beginne zu tippen, um Tools zu suchen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"watchlist": "Watchlist",
|
"watchlist": "Watchlist",
|
||||||
"trash": "Trash",
|
"trash": "Trash",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"redundancy": "Redundancy"
|
"redundancy": "Redundancy",
|
||||||
|
"search": "Search tools…"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "Sign in",
|
"signIn": "Sign in",
|
||||||
@@ -164,5 +165,12 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"text": "This page doesn't exist.",
|
"text": "This page doesn't exist.",
|
||||||
"backHome": "Back to Home"
|
"backHome": "Back to Home"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"navigate": "Navigate",
|
||||||
|
"recent": "Recent",
|
||||||
|
"tools": "Tools",
|
||||||
|
"noResults": "No tools found for \"{{query}}\".",
|
||||||
|
"startTyping": "Start typing to search tools."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const STORAGE_KEY = "toolrate-recent";
|
||||||
|
const MAX_ITEMS = 5;
|
||||||
|
|
||||||
|
export type RecentTool = { id: number; name: string };
|
||||||
|
|
||||||
|
export function getRecentTools(): RecentTool[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter(
|
||||||
|
(item): item is RecentTool =>
|
||||||
|
!!item && typeof item.id === "number" && typeof item.name === "string",
|
||||||
|
)
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecentTool(id: number, name: string) {
|
||||||
|
if (typeof window === "undefined" || !Number.isFinite(id) || !name) return;
|
||||||
|
try {
|
||||||
|
const next = [{ id, name }, ...getRecentTools().filter((t) => t.id !== id)].slice(0, MAX_ITEMS);
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { customFetch } from "@workspace/api-client-react";
|
import { customFetch } from "@workspace/api-client-react";
|
||||||
|
import { recordRecentTool } from "@/lib/recent-tools";
|
||||||
|
|
||||||
const ratingSchema = z.object({
|
const ratingSchema = z.object({
|
||||||
usefulness: z.number().min(1).max(5),
|
usefulness: z.number().min(1).max(5),
|
||||||
@@ -202,6 +203,10 @@ export default function ToolDetail() {
|
|||||||
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool?.id && tool.name) recordRecentTool(tool.id, tool.name);
|
||||||
|
}, [tool]);
|
||||||
|
|
||||||
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
|
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
|
||||||
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
|
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user