Add sidebar app sub-nav with categories, category filter, and re-analysis on category creation
- Sidebar: Apps accordion expands to Documents, which expands to list all user categories; clicking a category navigates to /apps/documents?category_id=<id> - DocumentsPage: reads category_id from URL and applies filter; shows active category chip in FilterBar with dismiss; removed TagEditor (deferred) - doc-service GET /documents: new category_id query param filters via subquery - doc-service POST /documents/categories: detects similar category names and triggers background re-analysis of affected documents so the new category surfaces as a pending AI suggestion on relevant docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Home,
|
||||
@@ -8,72 +8,197 @@ import {
|
||||
ShieldCheck,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
UserCircle,
|
||||
FileText,
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { getMe } from "@/api/client";
|
||||
import { getMe, listCategories } from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "Home", icon: Home, exact: true },
|
||||
{ to: "/apps", label: "Apps", icon: Grid2X2, exact: false },
|
||||
{ to: "/settings", label: "Settings", icon: Settings, exact: false },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(true);
|
||||
const { logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
|
||||
const isAppsRoute = location.pathname.startsWith("/apps");
|
||||
const isDocsRoute = location.pathname.startsWith("/apps/documents");
|
||||
|
||||
const [appsOpen, setAppsOpen] = useState(isAppsRoute);
|
||||
const [docsOpen, setDocsOpen] = useState(isDocsRoute);
|
||||
|
||||
// Auto-open sections when navigating to their routes
|
||||
useEffect(() => {
|
||||
if (isAppsRoute) setAppsOpen(true);
|
||||
}, [isAppsRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDocsRoute) setDocsOpen(true);
|
||||
}, [isDocsRoute]);
|
||||
|
||||
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",
|
||||
expanded ? "w-56" : "w-16"
|
||||
sidebarExpanded ? "w-56" : "w-16"
|
||||
)}
|
||||
>
|
||||
{/* Nav items */}
|
||||
<nav className="flex-1 py-4 px-2 space-y-1 overflow-hidden">
|
||||
{NAV_ITEMS.map(({ to, label, icon: Icon, exact }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={exact}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
expanded ? "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"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{expanded && (
|
||||
<span className="text-sm font-medium whitespace-nowrap">{label}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
<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>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!sidebarExpanded) {
|
||||
navigate("/apps");
|
||||
return;
|
||||
}
|
||||
setAppsOpen((o) => !o);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center rounded-lg transition-colors",
|
||||
sidebarExpanded ? "px-3 py-2 gap-3" : "justify-center py-3",
|
||||
isAppsRoute
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Grid2X2 className="h-5 w-5 shrink-0" />
|
||||
{sidebarExpanded && (
|
||||
<>
|
||||
<span className="text-sm font-medium whitespace-nowrap flex-1 text-left">
|
||||
Apps
|
||||
</span>
|
||||
{appsOpen ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Apps sub-items — only when sidebar is expanded and appsOpen */}
|
||||
{sidebarExpanded && appsOpen && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{/* Documents service */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setDocsOpen((o) => !o)}
|
||||
className={cn(
|
||||
"w-full flex items-center rounded-lg transition-colors text-sm",
|
||||
"pl-8 pr-3 py-1.5 gap-2",
|
||||
isDocsRoute
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left whitespace-nowrap">Documents</span>
|
||||
{docsOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Documents open link + categories */}
|
||||
{docsOpen && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
<NavLink
|
||||
to="/apps/documents"
|
||||
end
|
||||
className={({ isActive }) => subSubItemClass(isActive)}
|
||||
>
|
||||
<span className="whitespace-nowrap">All documents</span>
|
||||
</NavLink>
|
||||
|
||||
{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 */}
|
||||
{user?.is_admin && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
expanded ? "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"
|
||||
)
|
||||
}
|
||||
className={({ isActive }) => navItemClass(isActive)}
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5 shrink-0" />
|
||||
{expanded && (
|
||||
{sidebarExpanded && (
|
||||
<span className="text-sm font-medium whitespace-nowrap">Admin</span>
|
||||
)}
|
||||
</NavLink>
|
||||
@@ -82,46 +207,42 @@ export default function Sidebar() {
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="py-4 px-2 space-y-1 border-t border-border">
|
||||
{/* User avatar row */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-lg py-2 text-muted",
|
||||
expanded ? "px-3 gap-3" : "justify-center"
|
||||
sidebarExpanded ? "px-3 gap-3" : "justify-center"
|
||||
)}
|
||||
>
|
||||
<UserCircle className="h-5 w-5 shrink-0" />
|
||||
{expanded && user && (
|
||||
{sidebarExpanded && user && (
|
||||
<span className="text-sm font-medium truncate">{user.email}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<div className={cn("flex", expanded ? "px-1" : "justify-center")}>
|
||||
<div className={cn("flex", sidebarExpanded ? "px-1" : "justify-center")}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full text-muted hover:text-foreground",
|
||||
expanded ? "justify-start px-3 gap-3" : "justify-center px-0"
|
||||
sidebarExpanded ? "justify-start px-3 gap-3" : "justify-center px-0"
|
||||
)}
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-5 w-5 shrink-0" />
|
||||
{expanded && <span className="text-sm font-medium">Logout</span>}
|
||||
{sidebarExpanded && <span className="text-sm font-medium">Logout</span>}
|
||||
</Button>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("w-full text-muted hover:text-foreground", !expanded && "px-0")}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||
className={cn("w-full text-muted hover:text-foreground", !sidebarExpanded && "px-0")}
|
||||
onClick={() => setSidebarExpanded((e) => !e)}
|
||||
aria-label={sidebarExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||
>
|
||||
{expanded ? (
|
||||
{sidebarExpanded ? (
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
|
||||
Reference in New Issue
Block a user