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:
curo1305
2026-04-17 16:57:35 +02:00
parent bc7a74062d
commit 7d0edbd5e7
8 changed files with 384 additions and 193 deletions
+178 -57
View File
@@ -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" />