003fbee20f
Remove the "Extensions" section from the sidebar nav. Instead, each app card on the Apps page shows an "Extension" button when the current user has access to that app's plugin (matched by service ID). The button links to /settings/plugins/:id alongside the existing admin Settings button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { NavLink, useLocation } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Home,
|
|
Grid2X2,
|
|
Settings,
|
|
ShieldCheck,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
ChevronDown,
|
|
LogOut,
|
|
UserCircle,
|
|
FileText,
|
|
Folder,
|
|
Users,
|
|
UsersRound,
|
|
Palette,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import ThemeToggle from "@/components/ThemeToggle";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { getMe, listCategories } from "@/api/client";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function Sidebar() {
|
|
const [sidebarExpanded, setSidebarExpanded] = useState(true);
|
|
const { logout } = useAuth();
|
|
const location = useLocation();
|
|
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
|
|
|
const isAppsRoute = location.pathname.startsWith("/apps");
|
|
const isDocsRoute = location.pathname.startsWith("/apps/documents");
|
|
const isAdminRoute = location.pathname.startsWith("/admin");
|
|
|
|
const [appsOpen, setAppsOpen] = useState(isAppsRoute);
|
|
const [docsOpen, setDocsOpen] = useState(isDocsRoute);
|
|
const [adminOpen, setAdminOpen] = useState(isAdminRoute);
|
|
|
|
// Auto-open sections when navigating to their routes
|
|
useEffect(() => {
|
|
if (isAppsRoute) setAppsOpen(true);
|
|
}, [isAppsRoute]);
|
|
|
|
useEffect(() => {
|
|
if (isDocsRoute) setDocsOpen(true);
|
|
}, [isDocsRoute]);
|
|
|
|
useEffect(() => {
|
|
if (isAdminRoute) setAdminOpen(true);
|
|
}, [isAdminRoute]);
|
|
|
|
const { data: categories = [] } = useQuery({
|
|
queryKey: ["categories"],
|
|
queryFn: listCategories,
|
|
enabled: appsOpen && docsOpen && !!user,
|
|
});
|
|
|
|
const navItemClass = (isActive: boolean) =>
|
|
cn(
|
|
"flex items-center rounded-lg transition-colors",
|
|
sidebarExpanded ? "px-3 py-2 gap-3" : "justify-center py-3",
|
|
isActive
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
|
);
|
|
|
|
const subItemClass = (isActive: boolean) =>
|
|
cn(
|
|
"flex items-center rounded-lg transition-colors text-sm",
|
|
"pl-8 pr-3 py-1.5 gap-2",
|
|
isActive
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
|
);
|
|
|
|
const subSubItemClass = (isActive: boolean) =>
|
|
cn(
|
|
"flex items-center rounded-lg transition-colors text-sm",
|
|
"pl-12 pr-3 py-1 gap-2",
|
|
isActive
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
|
);
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
"flex flex-col h-screen bg-surface border-r border-border transition-all duration-200 shrink-0",
|
|
sidebarExpanded ? "w-56" : "w-16"
|
|
)}
|
|
>
|
|
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto overflow-x-hidden">
|
|
{/* Home */}
|
|
<NavLink
|
|
to="/"
|
|
end
|
|
className={({ isActive }) => navItemClass(isActive)}
|
|
>
|
|
<Home className="h-5 w-5 shrink-0" />
|
|
{sidebarExpanded && (
|
|
<span className="text-sm font-medium whitespace-nowrap">Home</span>
|
|
)}
|
|
</NavLink>
|
|
|
|
{/* Apps — expandable */}
|
|
<div>
|
|
{sidebarExpanded ? (
|
|
<div
|
|
className={cn(
|
|
"flex items-center rounded-lg transition-colors",
|
|
isAppsRoute
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
|
)}
|
|
>
|
|
<NavLink
|
|
to="/apps"
|
|
end
|
|
className="flex items-center gap-3 px-3 py-2 flex-1 min-w-0"
|
|
>
|
|
<Grid2X2 className="h-5 w-5 shrink-0" />
|
|
<span className="text-sm font-medium whitespace-nowrap">Apps</span>
|
|
</NavLink>
|
|
<button
|
|
onClick={() => setAppsOpen((o) => !o)}
|
|
className="px-2 py-2 rounded-r-lg"
|
|
aria-label={appsOpen ? "Collapse apps" : "Expand apps"}
|
|
>
|
|
{appsOpen ? (
|
|
<ChevronDown className="h-4 w-4 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 shrink-0" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<NavLink
|
|
to="/apps"
|
|
end
|
|
className={({ isActive }) => navItemClass(isActive)}
|
|
>
|
|
<Grid2X2 className="h-5 w-5 shrink-0" />
|
|
</NavLink>
|
|
)}
|
|
|
|
{/* Apps sub-items — only when sidebar is expanded and appsOpen */}
|
|
{sidebarExpanded && appsOpen && (
|
|
<div className="mt-0.5 space-y-0.5">
|
|
{/* Documents service */}
|
|
<div>
|
|
<div
|
|
className={cn(
|
|
"flex items-center rounded-lg transition-colors text-sm",
|
|
isDocsRoute
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
|
)}
|
|
>
|
|
<NavLink
|
|
to="/apps/documents"
|
|
end
|
|
className="flex items-center gap-2 pl-8 pr-2 py-1.5 flex-1 min-w-0"
|
|
>
|
|
<FileText className="h-4 w-4 shrink-0" />
|
|
<span className="whitespace-nowrap">Documents</span>
|
|
</NavLink>
|
|
<button
|
|
onClick={() => setDocsOpen((o) => !o)}
|
|
className="px-2 py-1.5 rounded-r-lg"
|
|
aria-label={docsOpen ? "Collapse documents" : "Expand documents"}
|
|
>
|
|
{docsOpen ? (
|
|
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Categories */}
|
|
{docsOpen && (
|
|
<div className="mt-0.5 space-y-0.5">
|
|
{categories.map((cat) => (
|
|
<NavLink
|
|
key={cat.id}
|
|
to={`/apps/documents?category_id=${cat.id}`}
|
|
className={({ isActive }) => subSubItemClass(isActive)}
|
|
>
|
|
<Folder className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="whitespace-nowrap truncate">{cat.name}</span>
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Settings */}
|
|
<NavLink
|
|
to="/settings"
|
|
className={({ isActive }) => navItemClass(isActive)}
|
|
>
|
|
<Settings className="h-5 w-5 shrink-0" />
|
|
{sidebarExpanded && (
|
|
<span className="text-sm font-medium whitespace-nowrap">Settings</span>
|
|
)}
|
|
</NavLink>
|
|
|
|
{/* Admin — expandable */}
|
|
{user?.is_admin && (
|
|
<div>
|
|
{sidebarExpanded ? (
|
|
<div
|
|
className={cn(
|
|
"flex items-center rounded-lg transition-colors",
|
|
isAdminRoute
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
|
)}
|
|
>
|
|
<NavLink
|
|
to="/admin/users"
|
|
className="flex items-center gap-3 px-3 py-2 flex-1 min-w-0"
|
|
>
|
|
<ShieldCheck className="h-5 w-5 shrink-0" />
|
|
<span className="text-sm font-medium whitespace-nowrap">Admin</span>
|
|
</NavLink>
|
|
<button
|
|
onClick={() => setAdminOpen((o) => !o)}
|
|
className="px-2 py-2 rounded-r-lg"
|
|
aria-label={adminOpen ? "Collapse admin" : "Expand admin"}
|
|
>
|
|
{adminOpen ? (
|
|
<ChevronDown className="h-4 w-4 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 shrink-0" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<NavLink
|
|
to="/admin/users"
|
|
className={({ isActive }) => navItemClass(isActive)}
|
|
>
|
|
<ShieldCheck className="h-5 w-5 shrink-0" />
|
|
</NavLink>
|
|
)}
|
|
|
|
{/* Admin sub-items */}
|
|
{sidebarExpanded && adminOpen && (
|
|
<div className="mt-0.5 space-y-0.5">
|
|
<NavLink
|
|
to="/admin/users"
|
|
className={({ isActive }) => subItemClass(isActive)}
|
|
>
|
|
<Users className="h-4 w-4 shrink-0" />
|
|
<span className="whitespace-nowrap">Users</span>
|
|
</NavLink>
|
|
<NavLink
|
|
to="/admin/groups"
|
|
className={({ isActive }) => subItemClass(isActive)}
|
|
>
|
|
<UsersRound className="h-4 w-4 shrink-0" />
|
|
<span className="whitespace-nowrap">Groups</span>
|
|
</NavLink>
|
|
<NavLink
|
|
to="/admin/appearance"
|
|
className={({ isActive }) => subItemClass(isActive)}
|
|
>
|
|
<Palette className="h-4 w-4 shrink-0" />
|
|
<span className="whitespace-nowrap">Appearance</span>
|
|
</NavLink>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</nav>
|
|
|
|
{/* Bottom section */}
|
|
<div className="py-4 px-2 space-y-1 border-t border-border">
|
|
<div
|
|
className={cn(
|
|
"flex items-center rounded-lg py-2 text-muted",
|
|
sidebarExpanded ? "px-3 gap-3" : "justify-center"
|
|
)}
|
|
>
|
|
<UserCircle className="h-5 w-5 shrink-0" />
|
|
{sidebarExpanded && user && (
|
|
<span className="text-sm font-medium truncate">{user.email}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className={cn("flex", sidebarExpanded ? "px-1" : "justify-center")}>
|
|
<ThemeToggle />
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
className={cn(
|
|
"w-full text-muted hover:text-foreground",
|
|
sidebarExpanded ? "justify-start px-3 gap-3" : "justify-center px-0"
|
|
)}
|
|
onClick={logout}
|
|
>
|
|
<LogOut className="h-5 w-5 shrink-0" />
|
|
{sidebarExpanded && <span className="text-sm font-medium">Logout</span>}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn("w-full text-muted hover:text-foreground", !sidebarExpanded && "px-0")}
|
|
onClick={() => setSidebarExpanded((e) => !e)}
|
|
aria-label={sidebarExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
|
>
|
|
{sidebarExpanded ? (
|
|
<ChevronLeft className="h-5 w-5" />
|
|
) : (
|
|
<ChevronRight className="h-5 w-5" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|