From a0894fe9469272578a201fd906b641e4d870e98e Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 10:11:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(nav):=20global=20=E2=8C=98K=20command=20pa?= =?UTF-8?q?lette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a global command launcher reachable from anywhere via ⌘K (Mac) / Ctrl+K (Win), or "/" when no input is focused. Three sections: - **Navigation** — every primary route from the top-bar nav, plus the full Admin sub-menu, role-gated against the session. - **Recent activity** — last 5 audits and last 5 device observations, lazy-loaded once on first open from /api/dashboard/overview. - **Companies** — fuzzy search against the active customer list from /api/companies, kicks in once the user types 2+ characters. Selecting a company deep-links to /configuration-items?company=. Top-bar exposes a small "Search · ⌘K" pill on md+ for discoverability, sized to fit between the Status indicator and the Theme toggle. Implementation: - shadcn `command` primitive (uses cmdk under the hood); declined the bundled dialog overwrite to keep our existing dialog.tsx. - CommandPalette mounted once in app/layout.tsx so it lives outside the AppNavigation re-renders. Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN.md | 9 + app/layout.tsx | 2 + components/navigation/app-navigation.tsx | 10 + components/navigation/command-palette.tsx | 298 ++++++++++++++++++++++ components/ui/command.tsx | 184 +++++++++++++ package-lock.json | 17 ++ package.json | 1 + 7 files changed, 521 insertions(+) create mode 100644 components/navigation/command-palette.tsx create mode 100644 components/ui/command.tsx diff --git a/DESIGN.md b/DESIGN.md index 5476fb0..ecb137f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -349,6 +349,15 @@ below is the working backlog; expand as we go. expandable-row patterns (useful for `/veeam-analysis`-style drill-downs). +### Command palette (2026-05-03) +- [x] **Cmd+K / Ctrl+K** opens a global launcher + (`components/navigation/command-palette.tsx`). Three sections: + Navigation (every primary route, role-gated), Recent activity + (last 5 audits + 5 observations from `/api/dashboard/overview`), + Companies (filtered client-side from `/api/companies`). The `/` + key also opens it when no input is focused. Top-bar shows a small + "Search · ⌘K" button (md+) for discoverability. + ### Status & dashboard split (2026-05-03) - [x] Move integration health + sync health off `/dashboard` onto a dedicated `/status` route. Top-bar `` links there. diff --git a/app/layout.tsx b/app/layout.tsx index eaf1cd8..9f6c937 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { AppNavigation } from "@/components/navigation/app-navigation"; +import { CommandPalette } from "@/components/navigation/command-palette"; import { TaglineFooter } from "@/components/branding/tagline-footer"; import { Toaster } from "sonner"; import { AuthProvider } from "@/components/auth/auth-provider"; @@ -57,6 +58,7 @@ export default function RootLayout({
{children}
+ diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index a93b898..a4a6b59 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -315,6 +315,16 @@ export function AppNavigation() { {/* Right Side Actions */}
+ diff --git a/components/navigation/command-palette.tsx b/components/navigation/command-palette.tsx new file mode 100644 index 0000000..7dc2080 --- /dev/null +++ b/components/navigation/command-palette.tsx @@ -0,0 +1,298 @@ +/* CommandPalette — global ⌘K / Ctrl+K launcher. + * + * Three sections: + * • Navigation — every primary route from the top-bar nav, + * plus admin shortcuts. + * • Recent activity — last 5 analyses + last 5 device observations, + * pulled lazily from /api/dashboard/overview. + * • Companies — fuzzy search against /api/companies (kicks in + * once the user types a query). + * + * Mounted in the root layout so it's available on every page. */ + +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { useSession } from '@/lib/auth-client'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from '@/components/ui/command'; +import { + LayoutDashboard, + Server, + Activity, + HardDrive, + Sparkles, + Brain, + Search, + AlertTriangle, + Database, + GitCompare, + Plug, + Power, + Users, + Workflow, + Settings, + ShieldCheck, + TrendingUp, + Building2, + Clock, +} from 'lucide-react'; + +// ── Static navigation entries ──────────────────────────────────────── + +interface CommandRoute { + href: string; + label: string; + hint?: string; + icon: React.ElementType; + /** Comma-separated extra keywords to match. */ + keywords?: string; + superAdminOnly?: boolean; +} + +const ROUTES: CommandRoute[] = [ + // Primary + { href: '/', label: 'Dashboard', icon: LayoutDashboard, keywords: 'home ops operations today kpi' }, + { href: '/status', label: 'System status', icon: Activity, keywords: 'health integrations workers sync uptime' }, + { href: '/configuration-items', label: 'Configuration items', icon: Server, keywords: 'ci devices assets' }, + { href: '/backup-status', label: 'Backup status', icon: HardDrive, keywords: 'veeam rpo' }, + { href: '/veeam-comparison', label: 'RPO comparison', icon: GitCompare, keywords: 'pulse veeam datto rmm' }, + { href: '/veeam-analysis', label: 'Backup ticket analysis', icon: Brain, keywords: 'veeam category resolution' }, + // Engagement (super-admin) + { href: '/engagement', label: 'Engagement overview', icon: TrendingUp, superAdminOnly: true, keywords: 'hours teams email' }, + { href: '/engagement/profile', label: 'Engagement · employee profile', icon: TrendingUp, superAdminOnly: true, keywords: 'employee resource' }, + // Analyzer + { href: '/analyzer/tickets', label: 'Analyzer · browse tickets', icon: Search, keywords: 'tickets analyzer browse' }, + { href: '/analyzer/reports', label: 'Analyzer · aggregate reports', icon: Sparkles, keywords: 'aggregate report' }, + { href: '/analyzer/queue', label: 'Analyzer · needs review', icon: Sparkles, keywords: 'human review queue' }, + { href: '/analyzer/itglue/applications', label: 'IT Glue · applications', icon: Database, keywords: 'flexible asset' }, + { href: '/analyzer/itglue/configurations', label: 'IT Glue · configurations', icon: Database, keywords: 'configuration' }, + // Admin (super-admin) + { href: '/admin', label: 'Admin home', icon: Activity, superAdminOnly: true, keywords: 'admin dashboard' }, + { href: '/admin/integrations', label: 'Admin · integrations', icon: Power, superAdminOnly: true, keywords: 'toggle disable enable s1 sentinelone datto itglue' }, + { href: '/admin/sync', label: 'Admin · sync schedules', icon: Clock, superAdminOnly: true, keywords: 'cron schedules autotask veeam' }, + { href: '/admin/workflow', label: 'Admin · workflow rules', icon: Workflow, superAdminOnly: true, keywords: 'classifier ai prompt' }, + { href: '/admin/rmm-overshell', label: 'Admin · RMM Overshell', icon: Activity, superAdminOnly: true, keywords: 'datto execute script' }, + { href: '/admin/itglue-writes', label: 'Admin · IT Glue write log', icon: Database, superAdminOnly: true, keywords: 'audit revert' }, + { href: '/admin/device-link-conflicts', label: 'Admin · device-link conflicts', icon: AlertTriangle, superAdminOnly: true, keywords: 'mapping conflicts xref' }, + { href: '/admin/users', label: 'Admin · users & roles', icon: Users, superAdminOnly: true, keywords: 'invite role permission' }, + // Account + { href: '/settings', label: 'Settings', icon: Settings, keywords: 'profile account' }, + { href: '/settings/security', label: 'Security · 2FA & sessions', icon: ShieldCheck, keywords: 'two factor totp logout' }, +]; + +interface OverviewResponse { + observations: Array<{ id: string; kind: string; hostname: string | null; companyName: string | null }>; + audits: Array<{ id: string; hostname: string | null; companyName: string | null }>; +} + +interface CompanyHit { + id: number; + name: string; +} + +interface CompaniesApiRow { + id: number; + companyName: string; +} + +// ── Component ──────────────────────────────────────────────────────── + +export function CommandPalette() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [recents, setRecents] = useState(null); + const [allCompanies, setAllCompanies] = useState(null); + const [companiesLoading, setCompaniesLoading] = useState(false); + const router = useRouter(); + const { data: session } = useSession(); + const isSuperAdmin = (session?.user as { role?: string } | undefined)?.role === 'super-admin'; + + // Bind ⌘K / Ctrl+K (and / when nothing else is focused). + useEffect(() => { + function onKey(e: KeyboardEvent) { + const isCmdK = (e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey); + const isSlash = + e.key === '/' && + !(document.activeElement instanceof HTMLInputElement) && + !(document.activeElement instanceof HTMLTextAreaElement) && + !(document.activeElement as HTMLElement | null)?.isContentEditable; + if (isCmdK || isSlash) { + e.preventDefault(); + setOpen((v) => !v); + } + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, []); + + // Lazy-load recent activity once on first open. + useEffect(() => { + if (!open || recents) return; + void fetch('/api/dashboard/overview', { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((j: OverviewResponse | null) => { + if (j) setRecents(j); + }) + .catch(() => { + /* ignore */ + }); + }, [open, recents]); + + // Companies are loaded once on first open (the endpoint returns the + // full active customer list — small enough to filter client-side). + useEffect(() => { + if (!open || allCompanies !== null) return; + setCompaniesLoading(true); + void fetch('/api/companies', { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : { companies: [] })) + .then((j: { companies?: CompaniesApiRow[] }) => { + const items: CompanyHit[] = (j.companies ?? []).map((c) => ({ + id: c.id, + name: c.companyName, + })); + setAllCompanies(items); + }) + .catch(() => setAllCompanies([])) + .finally(() => setCompaniesLoading(false)); + }, [open, allCompanies]); + + const companyMatches = (() => { + const q = query.trim().toLowerCase(); + if (q.length < 2 || !allCompanies) return []; + return allCompanies + .filter((c) => c.name.toLowerCase().includes(q)) + .slice(0, 8); + })(); + + const go = useCallback( + (href: string) => { + setOpen(false); + router.push(href); + }, + [router], + ); + + const visibleRoutes = ROUTES.filter((r) => !r.superAdminOnly || isSuperAdmin); + + return ( + + + + No results. + + + {visibleRoutes.map((r) => ( + go(r.href)} + > + + {r.label} + {r.hint && {r.hint}} + + ))} + + + {recents && recents.audits.length > 0 && ( + <> + + + {recents.audits.slice(0, 5).map((a) => ( + go(`/analyzer/itglue/configurations`)} + > + + + {a.hostname ?? '(unanchored)'} + {a.companyName && ( + · {a.companyName} + )} + + + ))} + + + )} + + {recents && recents.observations.length > 0 && ( + <> + + + {recents.observations.slice(0, 5).map((o) => ( + go('/configuration-items')} + > + + + {o.hostname ?? '(unanchored)'} + {o.companyName && ( + · {o.companyName} + )} + + + ))} + + + )} + + {companyMatches.length > 0 && ( + <> + + + {companyMatches.map((c) => ( + + go( + `/configuration-items?company=${encodeURIComponent(String(c.id))}`, + ) + } + > + + {c.name} + + ))} + + + )} + + + + + + Press + ⌘ K + or + / + to open this panel anywhere. + + + + + ); +} diff --git a/components/ui/command.tsx b/components/ui/command.tsx new file mode 100644 index 0000000..8fe3ccb --- /dev/null +++ b/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/package-lock.json b/package-lock.json index e133403..1560de8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "better-auth": "^1.4.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dotenv": "^17.2.3", "ioredis": "^5.9.0", @@ -8433,6 +8434,22 @@ "node": ">=0.10.0" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", diff --git a/package.json b/package.json index 71d3fcb..2ca5052 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "better-auth": "^1.4.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dotenv": "^17.2.3", "ioredis": "^5.9.0",