diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 4646340..6158fc2 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,24 +1,38 @@ -import { NavLink, Outlet } from "react-router-dom"; +import { useCallback, useEffect, useState } from "react"; +import { Outlet } from "react-router-dom"; import { useAuth } from "../hooks/useAuth"; import { useDensity } from "../hooks/useDensity"; - -const navLinks = [ - { to: "/", label: "Items" }, - { to: "/projects", label: "Projects" }, - { to: "/schemas", label: "Schemas" }, - { to: "/audit", label: "Audit" }, - { to: "/settings", label: "Settings" }, -]; - -const roleBadgeStyle: Record = { - admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" }, - editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" }, - viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" }, -}; +import { useModules } from "../hooks/useModules"; +import { Sidebar } from "./Sidebar"; export function AppShell() { const { user, loading, logout } = useAuth(); const [density, toggleDensity] = useDensity(); + const { modules } = useModules(); + + const [sidebarOpen, setSidebarOpen] = useState(() => { + return localStorage.getItem("silo-sidebar") !== "closed"; + }); + + const toggleSidebar = useCallback(() => { + setSidebarOpen((prev) => { + const next = !prev; + localStorage.setItem("silo-sidebar", next ? "open" : "closed"); + return next; + }); + }, []); + + // Ctrl+J to toggle sidebar + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === "j") { + e.preventDefault(); + toggleSidebar(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [toggleSidebar]); if (loading) { return ( @@ -36,115 +50,17 @@ export function AppShell() { } return ( -
-
-

- Silo -

- - - - {user && ( -
- - {user.display_name} - - - {user.role} - - - -
- )} -
- -
+
+ +
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..33e1086 --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -0,0 +1,335 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { NavLink, useNavigate } from "react-router-dom"; +import { + Package, + FolderKanban, + FileCode2, + ClipboardCheck, + Settings2, + ChevronLeft, + ChevronRight, + LogOut, +} from "lucide-react"; +import type { ModuleInfo } from "../api/types"; + +interface NavItem { + moduleId: string | null; + path: string; + label: string; + icon: React.ComponentType<{ size?: number }>; +} + +const allNavItems: NavItem[] = [ + { moduleId: "core", path: "/", label: "Items", icon: Package }, + { + moduleId: "projects", + path: "/projects", + label: "Projects", + icon: FolderKanban, + }, + { moduleId: "schemas", path: "/schemas", label: "Schemas", icon: FileCode2 }, + { moduleId: "audit", path: "/audit", label: "Audit", icon: ClipboardCheck }, + { moduleId: null, path: "/settings", label: "Settings", icon: Settings2 }, +]; + +interface SidebarProps { + open: boolean; + onToggle: () => void; + modules: Record; + user: { display_name: string; role: string } | null; + density: string; + onToggleDensity: () => void; + onLogout: () => void; +} + +const roleBadgeStyle: Record = { + admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" }, + editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" }, + viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" }, +}; + +export function Sidebar({ + open, + onToggle, + modules, + user, + density, + onToggleDensity, + onLogout, +}: SidebarProps) { + const navigate = useNavigate(); + const [focusIndex, setFocusIndex] = useState(-1); + const navRefs = useRef<(HTMLAnchorElement | null)[]>([]); + + const visibleItems = allNavItems.filter( + (item) => item.moduleId === null || modules[item.moduleId]?.enabled, + ); + + // Focus the item at focusIndex when it changes + useEffect(() => { + if (focusIndex >= 0 && focusIndex < navRefs.current.length) { + navRefs.current[focusIndex]?.focus(); + } + }, [focusIndex]); + + // Reset focus when sidebar closes + useEffect(() => { + if (!open) setFocusIndex(-1); + }, [open]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!open) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setFocusIndex((i) => (i + 1) % visibleItems.length); + break; + case "ArrowUp": + e.preventDefault(); + setFocusIndex( + (i) => (i - 1 + visibleItems.length) % visibleItems.length, + ); + break; + case "Enter": { + const target = visibleItems[focusIndex]; + if (focusIndex >= 0 && target) { + e.preventDefault(); + navigate(target.path); + } + break; + } + case "Escape": + e.preventDefault(); + onToggle(); + break; + } + }, + [open, focusIndex, visibleItems, navigate, onToggle], + ); + + return ( + + ); +} + +const btnStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "0.75rem", + padding: "var(--d-nav-py) var(--d-nav-px)", + borderRadius: "var(--d-nav-radius)", + border: "none", + background: "transparent", + color: "var(--ctp-subtext1)", + fontSize: "var(--font-body)", + fontWeight: 500, + cursor: "pointer", + whiteSpace: "nowrap", + width: "100%", + textAlign: "left", +}; diff --git a/web/src/hooks/useModules.ts b/web/src/hooks/useModules.ts new file mode 100644 index 0000000..87a4e87 --- /dev/null +++ b/web/src/hooks/useModules.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; +import { get } from "../api/client"; +import type { ModuleInfo, ModulesResponse } from "../api/types"; + +export function useModules() { + const [modules, setModules] = useState>({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + get("/api/modules") + .then((res) => setModules(res.modules)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + return { modules, loading }; +} diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css index 7a915d6..8b4179d 100644 --- a/web/src/styles/theme.css +++ b/web/src/styles/theme.css @@ -67,6 +67,9 @@ --d-footer-h: 28px; --d-footer-font: var(--font-table); --d-footer-px: 2rem; + + --d-sidebar-w: 220px; + --d-sidebar-collapsed: 48px; } /* ── Density: compact ── */ @@ -98,4 +101,7 @@ --d-footer-h: 24px; --d-footer-font: var(--font-sm); --d-footer-px: 1.25rem; + + --d-sidebar-w: 180px; + --d-sidebar-collapsed: 40px; }