1
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import * as React from "react";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
/** Render prop for full custom fallback */
|
||||
fallback?: (error: Error, reset: () => void) => React.ReactNode;
|
||||
/** Reset boundary when any value in this array changes (like `useEffect` deps) */
|
||||
resetKeys?: unknown[];
|
||||
/** Called once when a render error is caught */
|
||||
onError?: (error: Error, info: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches render-time errors anywhere below it and shows a fallback UI.
|
||||
* Wrap the whole app at the root (catches everything) and optionally
|
||||
* wrap individual routes/sections for finer-grained recovery.
|
||||
*
|
||||
* Note: does NOT catch errors inside event handlers, async code, or
|
||||
* server-side rendering. For those, surface errors via React Query
|
||||
* `onError` or `toast.error()`.
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
state: ErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
this.props.onError?.(error, info);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[ErrorBoundary]", error, info);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ErrorBoundaryProps) {
|
||||
if (!this.state.error) return;
|
||||
const prev = prevProps.resetKeys ?? [];
|
||||
const next = this.props.resetKeys ?? [];
|
||||
if (
|
||||
prev.length !== next.length ||
|
||||
prev.some((v, i) => !Object.is(v, next[i]))
|
||||
) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset = () => this.setState({ error: null });
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
if (this.props.fallback) return this.props.fallback(error, this.reset);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<ErrorState
|
||||
severity="error"
|
||||
title="Something went wrong"
|
||||
description="An unexpected error broke this view. You can try reloading just this section, or refresh the page."
|
||||
detail={error.stack ?? error.message}
|
||||
onRetry={this.reset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
matchPath,
|
||||
NavLink,
|
||||
@@ -7,69 +7,39 @@ import {
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
UploadCloud,
|
||||
Settings2,
|
||||
Activity,
|
||||
Brain,
|
||||
Code2,
|
||||
GitBranch,
|
||||
Network,
|
||||
ListChecks,
|
||||
Menu,
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
ClipboardCheck,
|
||||
Layers3,
|
||||
ChevronDown,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Command,
|
||||
HelpCircle,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { RootState } from "@/stores";
|
||||
import { toggleSidebar } from "@/stores/slices/uiSlice";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
|
||||
interface NavItem {
|
||||
to?: string;
|
||||
projectPath?: string;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: "/", labelKey: "nav.dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/onboard", labelKey: "nav.onboard", label: "New Project", icon: UploadCloud },
|
||||
{ projectPath: "sources", labelKey: "nav.sources", label: "Source Explorer", icon: Settings2 },
|
||||
{ projectPath: "crawl", labelKey: "nav.crawl", label: "Seed Crawl", icon: Activity },
|
||||
{ projectPath: "pipeline", labelKey: "nav.pipeline", label: "Build Pipeline", icon: Layers3 },
|
||||
{ projectPath: "analysis", labelKey: "nav.analysis", label: "Page Analysis", icon: SearchCheck },
|
||||
{ projectPath: "schema", labelKey: "nav.schema", label: "Schema Designer", icon: ShieldCheck },
|
||||
{ projectPath: "editor", labelKey: "nav.editor", label: "Entity Manager", icon: Network },
|
||||
{ projectPath: "review", labelKey: "nav.review", label: "Claim Review", icon: ListChecks },
|
||||
{ projectPath: "quality", labelKey: "nav.quality", label: "Quality Inspector", icon: ClipboardCheck },
|
||||
{ projectPath: "graph", labelKey: "nav.graph", label: "Graph View", icon: GitBranch },
|
||||
{ projectPath: "export", labelKey: "nav.export", label: "Export / API", icon: Code2 },
|
||||
{ projectPath: "research", labelKey: "nav.research", label: "Graph Research", icon: Brain },
|
||||
];
|
||||
|
||||
const projectRoutePatterns = [
|
||||
"/sources/:projectId",
|
||||
"/crawl/:projectId",
|
||||
"/pipeline/:projectId",
|
||||
"/analysis/:projectId",
|
||||
"/schema/:projectId",
|
||||
"/research/:projectId",
|
||||
"/editor/:projectId",
|
||||
"/review/:projectId",
|
||||
"/quality/:projectId",
|
||||
"/graph/:projectId",
|
||||
"/export/:projectId",
|
||||
];
|
||||
import { useThemeSync } from "@/hooks/useTheme";
|
||||
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||
import { ThemeToggle } from "@/components/layout/ThemeToggle";
|
||||
import {
|
||||
ShortcutsOverlay,
|
||||
type ShortcutGroup,
|
||||
} from "@/components/ui/shortcuts-overlay";
|
||||
import { Tooltip } from "@/components/ui/tooltip";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
import { NAV_GROUPS, PROJECT_ROUTE_PATTERNS, type NavItem } from "./navigation";
|
||||
import {
|
||||
CommandPalette,
|
||||
useCommandPaletteShortcut,
|
||||
} from "./CommandPalette";
|
||||
|
||||
function projectIdFromPathname(pathname: string): string | undefined {
|
||||
for (const pattern of projectRoutePatterns) {
|
||||
for (const pattern of PROJECT_ROUTE_PATTERNS) {
|
||||
const match = matchPath({ path: pattern, end: false }, pathname);
|
||||
if (match?.params.projectId) return match.params.projectId;
|
||||
}
|
||||
@@ -77,7 +47,7 @@ function projectIdFromPathname(pathname: string): string | undefined {
|
||||
}
|
||||
|
||||
function workspaceSectionFromPathname(pathname: string): string | undefined {
|
||||
for (const pattern of projectRoutePatterns) {
|
||||
for (const pattern of PROJECT_ROUTE_PATTERNS) {
|
||||
const match = matchPath({ path: pattern, end: false }, pathname);
|
||||
if (match?.params.projectId) return pattern.split("/")[1];
|
||||
}
|
||||
@@ -85,11 +55,17 @@ function workspaceSectionFromPathname(pathname: string): string | undefined {
|
||||
}
|
||||
|
||||
export default function AppShell() {
|
||||
useThemeSync();
|
||||
const { t } = useTranslation();
|
||||
const sidebarOpen = useSelector((s: RootState) => s.ui.sidebarOpen);
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||
const [chordLeader, setChordLeader] = useState<string | null>(null);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
|
||||
const routeProjectId = projectIdFromPathname(location.pathname);
|
||||
const workspaceSection = workspaceSectionFromPathname(location.pathname);
|
||||
const { data: projects } = useProjects();
|
||||
@@ -99,9 +75,58 @@ export default function AppShell() {
|
||||
!projects ||
|
||||
projects.some((project) => project.name === routeProjectId);
|
||||
const currentProjectId = routeProjectExists
|
||||
? routeProjectId ?? fallbackProjectId
|
||||
? (routeProjectId ?? fallbackProjectId)
|
||||
: fallbackProjectId;
|
||||
|
||||
useCommandPaletteShortcut(() => setPaletteOpen((v) => !v));
|
||||
|
||||
// Build chord bindings from NAV_GROUPS (e.g. "g d" -> /, "g s" -> /sources/...).
|
||||
const shortcutBindings = useMemo(() => {
|
||||
const bindings: Record<string, () => void> = {};
|
||||
const collectItem = (item: NavItem) => {
|
||||
if (!item.shortcut) return;
|
||||
const href =
|
||||
item.to ??
|
||||
(item.projectPath && currentProjectId
|
||||
? `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`
|
||||
: undefined);
|
||||
if (!href) return;
|
||||
bindings[item.shortcut] = () => navigate(href);
|
||||
};
|
||||
NAV_GROUPS.forEach((g) => g.items.forEach(collectItem));
|
||||
bindings["/"] = () => setPaletteOpen(true);
|
||||
bindings["Shift+?"] = () => setShortcutsOpen(true);
|
||||
return bindings;
|
||||
}, [currentProjectId, navigate]);
|
||||
|
||||
useKeyboardShortcuts(shortcutBindings, {
|
||||
disabled: paletteOpen || shortcutsOpen,
|
||||
onChordStart: (leader) => setChordLeader(leader),
|
||||
onChordEnd: () => setChordLeader(null),
|
||||
});
|
||||
|
||||
// Build groups for the ShortcutsOverlay (memoized)
|
||||
const shortcutGroups = useMemo<ShortcutGroup[]>(() => {
|
||||
const navGroups: ShortcutGroup[] = NAV_GROUPS.map((g) => ({
|
||||
title: g.label,
|
||||
items: g.items
|
||||
.filter((i) => i.shortcut)
|
||||
.map((i) => ({ keys: i.shortcut!, label: i.label })),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
return [
|
||||
...navGroups,
|
||||
{
|
||||
title: "Global",
|
||||
items: [
|
||||
{ keys: "Mod+K", label: "Open command palette" },
|
||||
{ keys: "/", label: "Open command palette" },
|
||||
{ keys: "Shift+?", label: "Show this shortcuts panel" },
|
||||
{ keys: "Escape", label: "Close any open dialog" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (routeProjectId && projects && !routeProjectExists) {
|
||||
const nextPath =
|
||||
@@ -119,98 +144,362 @@ export default function AppShell() {
|
||||
workspaceSection,
|
||||
]);
|
||||
|
||||
const switchProject = (projectName: string) => {
|
||||
setProjectMenuOpen(false);
|
||||
const section = workspaceSection ?? "sources";
|
||||
navigate(`/${section}/${encodeURIComponent(projectName)}`);
|
||||
};
|
||||
|
||||
// Detect macOS to show ⌘ vs Ctrl
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
const modKey = isMac ? "⌘" : "Ctrl";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background text-foreground">
|
||||
<aside
|
||||
className={cn(
|
||||
"border-r bg-card transition-all duration-200 ease-out",
|
||||
sidebarOpen ? "w-60" : "w-16",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between px-4 border-b">
|
||||
{sidebarOpen && (
|
||||
<span className="text-sm font-semibold">
|
||||
{t("app.title", "Ontology Builder")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => dispatch(toggleSidebar())}
|
||||
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
|
||||
<Sidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProjectId={currentProjectId ?? null}
|
||||
onToggle={() => dispatch(toggleSidebar())}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-surface/80 px-4 backdrop-blur-md">
|
||||
<ProjectSwitcher
|
||||
projects={(projects ?? []).map((p) => p.name)}
|
||||
currentProjectId={currentProjectId ?? null}
|
||||
open={projectMenuOpen}
|
||||
onOpenChange={setProjectMenuOpen}
|
||||
onSelect={switchProject}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
className="group flex h-9 max-w-md flex-1 items-center gap-2 rounded-md border border-border bg-background-subtle px-3 text-sm text-muted-foreground transition-colors hover:border-border-strong hover:text-foreground"
|
||||
aria-label={t("nav.openCommandPalette", "Open command palette")}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{t("nav.searchPlaceholder", "Search pages, projects…")}
|
||||
</span>
|
||||
<kbd className="hidden gap-0.5 rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-2xs text-muted-foreground sm:inline-flex">
|
||||
{modKey} K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("nav.currentProject", "Current project")}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-medium">
|
||||
{currentProjectId ??
|
||||
t("nav.noProjectSelected", "Select a project")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
{navItems.map(({ to, projectPath, labelKey, label, icon: Icon }) => {
|
||||
const href =
|
||||
to ??
|
||||
(currentProjectId
|
||||
? `/${projectPath}/${encodeURIComponent(currentProjectId)}`
|
||||
: undefined);
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<div
|
||||
key={labelKey}
|
||||
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground/50"
|
||||
title={t(
|
||||
"nav.selectProjectFirst",
|
||||
"Select a project from the dashboard first",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{sidebarOpen && <span>{t(labelKey, label)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={labelKey}
|
||||
to={href}
|
||||
end={href === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)
|
||||
}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Tooltip content={t("nav.help", "Keyboard shortcuts")} shortcut="?">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("nav.help", "Keyboard shortcuts")}
|
||||
onClick={() => setShortcutsOpen(true)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{sidebarOpen && <span>{t(labelKey, label)}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
|
||||
<h1 className="text-sm font-medium text-muted-foreground">
|
||||
{t("app.subtitle", "AI-powered ontology construction")}
|
||||
</h1>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<ThemeToggle />
|
||||
<div className="ml-2 h-7 w-px bg-border" aria-hidden />
|
||||
<span
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700 dark:bg-brand-900 dark:text-brand-200"
|
||||
title={t("nav.account", "Account")}
|
||||
>
|
||||
ON
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-auto">
|
||||
<ErrorBoundary resetKeys={[location.pathname]}>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
currentProjectId={currentProjectId ?? null}
|
||||
/>
|
||||
|
||||
<ShortcutsOverlay
|
||||
open={shortcutsOpen}
|
||||
onOpenChange={setShortcutsOpen}
|
||||
groups={shortcutGroups}
|
||||
/>
|
||||
|
||||
<ChordHint leader={chordLeader} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChordHint({ leader }: { leader: string | null }) {
|
||||
if (!leader) return null;
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2 animate-fade-in-up"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg">
|
||||
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-2xs">
|
||||
{leader}
|
||||
</kbd>
|
||||
<span className="text-muted-foreground">…</span>
|
||||
<span className="text-muted-foreground">awaiting follower key</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar
|
||||
* ========================================================== */
|
||||
|
||||
function Sidebar({
|
||||
sidebarOpen,
|
||||
currentProjectId,
|
||||
onToggle,
|
||||
t,
|
||||
}: {
|
||||
sidebarOpen: boolean;
|
||||
currentProjectId: string | null;
|
||||
onToggle: () => void;
|
||||
t: TFunction;
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 flex h-screen flex-col border-r border-border bg-surface transition-[width] duration-fast ease-out-expo",
|
||||
sidebarOpen ? "w-64" : "w-[60px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center gap-2 border-b border-border px-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-brand-600 text-white shadow-sm">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{t("app.title", "Ontology Builder")}
|
||||
</p>
|
||||
<p className="truncate text-2xs text-muted-foreground">
|
||||
{t("app.subtitle", "AI-powered ontology construction")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.id} className="px-2 pb-2">
|
||||
{sidebarOpen && (
|
||||
<p className="px-3 pb-1 pt-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(group.labelKey, group.label)}
|
||||
</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => {
|
||||
const href =
|
||||
item.to ??
|
||||
(item.projectPath && currentProjectId
|
||||
? `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`
|
||||
: undefined);
|
||||
const Icon = item.icon;
|
||||
if (!href) {
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-not-allowed items-center gap-2.5 rounded-md px-3 py-2 text-sm text-muted-foreground/40",
|
||||
!sidebarOpen && "justify-center px-2",
|
||||
)}
|
||||
title={t(
|
||||
"nav.selectProjectFirst",
|
||||
"Select a project from the dashboard first",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{sidebarOpen && (
|
||||
<span className="truncate">
|
||||
{t(item.labelKey, item.label)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
to={href}
|
||||
end={href === "/"}
|
||||
title={
|
||||
!sidebarOpen ? t(item.labelKey, item.label) : undefined
|
||||
}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-2.5 rounded-md px-3 py-2 text-sm transition-colors",
|
||||
!sidebarOpen && "justify-center px-2",
|
||||
isActive
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-200"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-1 left-0 w-0.5 rounded-r-full bg-brand-600 dark:bg-brand-400"
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<span className="flex-1 truncate">
|
||||
{t(item.labelKey, item.label)}
|
||||
</span>
|
||||
{item.shortcut && (
|
||||
<kbd className="ml-1 hidden flex-shrink-0 rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-2xs text-muted-foreground group-hover:inline-flex">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="border-t border-border p-3 text-2xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>v0.2.0</span>
|
||||
<span className="pill-info">beta</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Project Switcher (header dropdown)
|
||||
* ========================================================== */
|
||||
|
||||
function ProjectSwitcher({
|
||||
projects,
|
||||
currentProjectId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
t,
|
||||
}: {
|
||||
projects: string[];
|
||||
currentProjectId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (name: string) => void;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const ref = useMemo(() => ({ current: null as HTMLDivElement | null }), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open, onOpenChange, ref]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={(node) => (ref.current = node)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(!open)}
|
||||
className="flex h-9 items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded bg-brand-100 text-2xs font-bold text-brand-700 dark:bg-brand-900 dark:text-brand-200">
|
||||
{(currentProjectId ?? "—").slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="max-w-[160px] truncate">
|
||||
{currentProjectId ?? t("nav.noProjectSelected", "Select project")}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute left-0 top-full z-40 mt-1.5 w-72 overflow-hidden rounded-lg border border-border bg-popover shadow-lg animate-fade-in-up"
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t("nav.projects", "Projects")}
|
||||
</div>
|
||||
<ul className="max-h-72 overflow-y-auto py-1">
|
||||
{projects.length === 0 && (
|
||||
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{t("nav.noProjects", "No projects yet.")}
|
||||
</li>
|
||||
)}
|
||||
{projects.map((name) => (
|
||||
<li key={name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(name)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
||||
name === currentProjectId
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground hover:bg-accent/60",
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={name === currentProjectId}
|
||||
>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded bg-brand-100 text-2xs font-bold text-brand-700 dark:bg-brand-900 dark:text-brand-200">
|
||||
{name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center justify-between border-t border-border bg-background-subtle px-3 py-2 text-2xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Command className="h-3 w-3" />
|
||||
Press <kbd className="rounded border border-border bg-surface px-1 py-0.5">K</kbd> for palette
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import * as React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Search, FolderKanban, Sun, Moon, Monitor, CornerDownLeft } from "lucide-react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { Dialog, DialogPanel } from "@/components/ui/dialog";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
import { NAV_GROUPS, type NavItem } from "./navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cycleTheme, setTheme } from "@/stores/slices/uiSlice";
|
||||
import type { RootState } from "@/stores";
|
||||
|
||||
type CommandItem = {
|
||||
id: string;
|
||||
group: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
keywords?: string[];
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function score(query: string, item: CommandItem): number {
|
||||
if (!query) return 1;
|
||||
const q = query.toLowerCase();
|
||||
const haystack = [item.label, item.group, ...(item.keywords ?? [])]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (haystack.includes(q)) return 2;
|
||||
// fuzzy: every char in q appears in order
|
||||
let pos = 0;
|
||||
for (const ch of q) {
|
||||
pos = haystack.indexOf(ch, pos);
|
||||
if (pos === -1) return 0;
|
||||
pos++;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Current project to use for project-scoped nav items */
|
||||
currentProjectId?: string | null;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentProjectId,
|
||||
}: CommandPaletteProps) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const theme = useSelector((s: RootState) => s.ui.theme);
|
||||
const { data: projects } = useProjects();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [activeIndex, setActiveIndex] = React.useState(0);
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const navHref = (item: NavItem): string | undefined => {
|
||||
if (item.to) return item.to;
|
||||
if (item.projectPath && currentProjectId) {
|
||||
return `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const commands = React.useMemo<CommandItem[]>(() => {
|
||||
const list: CommandItem[] = [];
|
||||
|
||||
NAV_GROUPS.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
const href = navHref(item);
|
||||
list.push({
|
||||
id: `nav:${item.id}`,
|
||||
group: group.label,
|
||||
label: item.label,
|
||||
hint: item.shortcut,
|
||||
keywords: item.keywords,
|
||||
icon: item.icon,
|
||||
onSelect: () => {
|
||||
if (href) navigate(href);
|
||||
close();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
(projects ?? []).slice(0, 12).forEach((project) => {
|
||||
list.push({
|
||||
id: `project:${project.name}`,
|
||||
group: "Projects",
|
||||
label: project.name,
|
||||
keywords: ["switch", "open", "project"],
|
||||
icon: FolderKanban,
|
||||
onSelect: () => {
|
||||
navigate(`/sources/${encodeURIComponent(project.name)}`);
|
||||
close();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const themeOpts: Array<{
|
||||
mode: "light" | "dark" | "system";
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}> = [
|
||||
{ mode: "light", label: "Switch to Light theme", icon: Sun },
|
||||
{ mode: "dark", label: "Switch to Dark theme", icon: Moon },
|
||||
{ mode: "system", label: "Use System theme", icon: Monitor },
|
||||
];
|
||||
themeOpts.forEach((opt) => {
|
||||
list.push({
|
||||
id: `theme:${opt.mode}`,
|
||||
group: "Settings",
|
||||
label: opt.label,
|
||||
keywords: ["theme", "dark", "light", "mode", opt.mode],
|
||||
icon: opt.icon,
|
||||
onSelect: () => {
|
||||
dispatch(setTheme(opt.mode));
|
||||
close();
|
||||
},
|
||||
});
|
||||
});
|
||||
list.push({
|
||||
id: "theme:cycle",
|
||||
group: "Settings",
|
||||
label: `Cycle theme (current: ${theme})`,
|
||||
keywords: ["theme", "toggle"],
|
||||
icon: Sun,
|
||||
onSelect: () => {
|
||||
dispatch(cycleTheme());
|
||||
close();
|
||||
},
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [projects, navigate, currentProjectId, dispatch, close, theme]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const scored = commands
|
||||
.map((cmd) => ({ cmd, s: score(query, cmd) }))
|
||||
.filter((x) => x.s > 0);
|
||||
scored.sort((a, b) => b.s - a.s);
|
||||
return scored.map((x) => x.cmd);
|
||||
}, [commands, query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = listRef.current?.querySelector<HTMLElement>(
|
||||
`[data-cmd-index="${activeIndex}"]`,
|
||||
);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
filtered[activeIndex]?.onSelect();
|
||||
}
|
||||
};
|
||||
|
||||
// Group by section preserving filtered order
|
||||
const grouped: Array<{ group: string; items: CommandItem[] }> = [];
|
||||
filtered.forEach((cmd) => {
|
||||
const last = grouped[grouped.length - 1];
|
||||
if (last && last.group === cmd.group) last.items.push(cmd);
|
||||
else grouped.push({ group: cmd.group, items: [cmd] });
|
||||
});
|
||||
|
||||
let runningIndex = -1;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} ariaLabel="Command palette">
|
||||
<DialogPanel className="mx-auto mt-20 max-w-xl">
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search pages, projects, settings…"
|
||||
aria-label="Command palette input"
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 text-2xs text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1.5">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No results for “{query}”
|
||||
</p>
|
||||
)}
|
||||
{grouped.map((section) => (
|
||||
<div key={section.group} className="px-1.5">
|
||||
<div className="px-3 pb-1 pt-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{section.group}
|
||||
</div>
|
||||
{section.items.map((cmd) => {
|
||||
runningIndex += 1;
|
||||
const isActive = runningIndex === activeIndex;
|
||||
const Icon = cmd.icon;
|
||||
return (
|
||||
<button
|
||||
key={cmd.id}
|
||||
data-cmd-index={runningIndex}
|
||||
type="button"
|
||||
onMouseEnter={() => setActiveIndex(runningIndex)}
|
||||
onClick={cmd.onSelect}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{cmd.label}</span>
|
||||
{cmd.hint && (
|
||||
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 text-2xs text-muted-foreground">
|
||||
{cmd.hint}
|
||||
</kbd>
|
||||
)}
|
||||
{isActive && (
|
||||
<CornerDownLeft className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border bg-background-subtle px-4 py-2 text-2xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5">↑</kbd>
|
||||
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5">↓</kbd>
|
||||
navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5">↵</kbd>
|
||||
select
|
||||
</span>
|
||||
</div>
|
||||
<span>{filtered.length} results</span>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global hook — binds ⌘K / Ctrl+K to toggle the palette.
|
||||
*/
|
||||
export function useCommandPaletteShortcut(onToggle: () => void) {
|
||||
React.useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
if (isMod && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onToggle]);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: React.ReactNode;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
/** Optional eyebrow / breadcrumb trail. The last item is rendered as plain text. */
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
title: React.ReactNode;
|
||||
/** Optional icon shown left of the title */
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
description?: React.ReactNode;
|
||||
/** Pills/badges rendered next to the title (e.g. status, environment) */
|
||||
meta?: React.ReactNode;
|
||||
/** Buttons / dropdowns on the right */
|
||||
actions?: React.ReactNode;
|
||||
/** Secondary nav (tabs) row */
|
||||
tabs?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
breadcrumbs,
|
||||
title,
|
||||
icon: Icon,
|
||||
description,
|
||||
meta,
|
||||
actions,
|
||||
tabs,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-b border-border bg-surface",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-6 pb-4 pt-5">
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<Breadcrumbs items={breadcrumbs} className="mb-2" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{Icon && (
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{meta && <div className="flex items-center gap-2">{meta}</div>}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tabs && (
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="flex items-center gap-1 overflow-x-auto">{tabs}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Breadcrumbs({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className={cn("flex items-center text-xs text-muted-foreground", className)}
|
||||
>
|
||||
<ol className="flex flex-wrap items-center gap-1">
|
||||
{items.map((item, idx) => {
|
||||
const isLast = idx === items.length - 1;
|
||||
return (
|
||||
<li key={idx} className="flex items-center gap-1">
|
||||
{item.to && !isLast ? (
|
||||
<Link
|
||||
to={item.to}
|
||||
className="rounded px-1 hover:text-foreground hover:underline underline-offset-2"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"px-1",
|
||||
isLast && "font-medium text-foreground",
|
||||
)}
|
||||
aria-current={isLast ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{!isLast && (
|
||||
<ChevronRight className="h-3 w-3 flex-shrink-0 opacity-60" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cycleTheme, type ThemeMode } from "@/stores/slices/uiSlice";
|
||||
import type { RootState } from "@/stores";
|
||||
|
||||
const ICONS: Record<ThemeMode, React.ComponentType<{ className?: string }>> = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
};
|
||||
|
||||
const LABELS: Record<ThemeMode, string> = {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
system: "System",
|
||||
};
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const mode = useSelector((s: RootState) => s.ui.theme);
|
||||
const Icon = ICONS[mode];
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => dispatch(cycleTheme())}
|
||||
aria-label={t("theme.toggle", "Switch theme") + ` (${LABELS[mode]})`}
|
||||
title={`${t("theme.label", "Theme")}: ${LABELS[mode]}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { ComponentType } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
ClipboardCheck,
|
||||
Code2,
|
||||
GitBranch,
|
||||
Layers3,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Network,
|
||||
SearchCheck,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
|
||||
export type NavGroupId = "workspace" | "build" | "review" | "publish";
|
||||
|
||||
export interface NavItem {
|
||||
/** Stable identifier — used as React key and palette command id */
|
||||
id: string;
|
||||
/** i18n key */
|
||||
labelKey: string;
|
||||
/** Default label (English) */
|
||||
label: string;
|
||||
/** Static route — mutually exclusive with projectPath */
|
||||
to?: string;
|
||||
/** Path segment under /:projectId — joined as `/${projectPath}/${projectId}` */
|
||||
projectPath?: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
/** Search keywords for the command palette (in addition to label) */
|
||||
keywords?: string[];
|
||||
/** Optional shortcut hint shown next to the item */
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
id: NavGroupId;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: "workspace",
|
||||
labelKey: "navGroup.workspace",
|
||||
label: "Workspace",
|
||||
items: [
|
||||
{
|
||||
id: "dashboard",
|
||||
to: "/",
|
||||
labelKey: "nav.dashboard",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
keywords: ["home", "overview"],
|
||||
shortcut: "g d",
|
||||
},
|
||||
{
|
||||
id: "onboard",
|
||||
to: "/onboard",
|
||||
labelKey: "nav.onboard",
|
||||
label: "New Project",
|
||||
icon: UploadCloud,
|
||||
keywords: ["create", "start", "wizard"],
|
||||
shortcut: "g n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "build",
|
||||
labelKey: "navGroup.build",
|
||||
label: "Build",
|
||||
items: [
|
||||
{
|
||||
id: "sources",
|
||||
projectPath: "sources",
|
||||
labelKey: "nav.sources",
|
||||
label: "Source Explorer",
|
||||
icon: Settings2,
|
||||
keywords: ["urls", "domains", "seed"],
|
||||
shortcut: "g s",
|
||||
},
|
||||
{
|
||||
id: "crawl",
|
||||
projectPath: "crawl",
|
||||
labelKey: "nav.crawl",
|
||||
label: "Seed Crawl",
|
||||
icon: Activity,
|
||||
keywords: ["scrape", "fetch"],
|
||||
shortcut: "g c",
|
||||
},
|
||||
{
|
||||
id: "pipeline",
|
||||
projectPath: "pipeline",
|
||||
labelKey: "nav.pipeline",
|
||||
label: "Build Pipeline",
|
||||
icon: Layers3,
|
||||
keywords: ["jobs", "run", "extract"],
|
||||
shortcut: "g p",
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
projectPath: "analysis",
|
||||
labelKey: "nav.analysis",
|
||||
label: "Page Analysis",
|
||||
icon: SearchCheck,
|
||||
keywords: ["html", "structure"],
|
||||
shortcut: "g a",
|
||||
},
|
||||
{
|
||||
id: "schema",
|
||||
projectPath: "schema",
|
||||
labelKey: "nav.schema",
|
||||
label: "Schema Designer",
|
||||
icon: ShieldCheck,
|
||||
keywords: ["types", "predicate", "model"],
|
||||
shortcut: "g t",
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
projectPath: "research",
|
||||
labelKey: "nav.research",
|
||||
label: "Graph Research",
|
||||
icon: Brain,
|
||||
keywords: ["graphrag", "query", "ai", "autonomous"],
|
||||
shortcut: "g r",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
labelKey: "navGroup.review",
|
||||
label: "Review",
|
||||
items: [
|
||||
{
|
||||
id: "editor",
|
||||
projectPath: "editor",
|
||||
labelKey: "nav.editor",
|
||||
label: "Entity Manager",
|
||||
icon: Network,
|
||||
keywords: ["entities", "nodes", "merge"],
|
||||
shortcut: "g e",
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
projectPath: "review",
|
||||
labelKey: "nav.review",
|
||||
label: "Claim Review",
|
||||
icon: ListChecks,
|
||||
keywords: ["validate", "approve", "queue"],
|
||||
shortcut: "g v",
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
projectPath: "quality",
|
||||
labelKey: "nav.quality",
|
||||
label: "Quality Inspector",
|
||||
icon: ClipboardCheck,
|
||||
keywords: ["metrics", "qa", "audit"],
|
||||
shortcut: "g q",
|
||||
},
|
||||
{
|
||||
id: "graph",
|
||||
projectPath: "graph",
|
||||
labelKey: "nav.graph",
|
||||
label: "Graph View",
|
||||
icon: GitBranch,
|
||||
keywords: ["visualization", "network"],
|
||||
shortcut: "g g",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "publish",
|
||||
labelKey: "navGroup.publish",
|
||||
label: "Publish",
|
||||
items: [
|
||||
{
|
||||
id: "export",
|
||||
projectPath: "export",
|
||||
labelKey: "nav.export",
|
||||
label: "Export / API",
|
||||
icon: Code2,
|
||||
keywords: ["download", "rest", "json"],
|
||||
shortcut: "g x",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const PROJECT_ROUTE_PATTERNS = NAV_GROUPS.flatMap((g) =>
|
||||
g.items.filter((i) => i.projectPath).map((i) => `/${i.projectPath}/:projectId`),
|
||||
);
|
||||
1054
ontology_platform/web/frontend/src/components/ui/data-table.tsx
Normal file
1054
ontology_platform/web/frontend/src/components/ui/data-table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
214
ontology_platform/web/frontend/src/components/ui/dialog.tsx
Normal file
214
ontology_platform/web/frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import * as React from "react";
|
||||
import * as RadixDialog from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Dialog primitive — thin styled wrapper over @radix-ui/react-dialog.
|
||||
* Radix handles focus trap, scroll lock, Escape, focus restoration, and ARIA.
|
||||
*
|
||||
* The exported API mirrors the previous custom implementation so consumers
|
||||
* (Drawer, CommandPalette, ShortcutsOverlay) didn't need restructuring:
|
||||
*
|
||||
* <Dialog open onOpenChange>
|
||||
* <DialogPanel ariaLabel="…">
|
||||
* <DialogHeader onClose>...</DialogHeader>
|
||||
* <DialogBody>...</DialogBody>
|
||||
* <DialogFooter>...</DialogFooter>
|
||||
* </DialogPanel>
|
||||
* </Dialog>
|
||||
*/
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
/** Compatibility shim — ariaLabel is now supplied on DialogPanel directly. */
|
||||
ariaLabel?: string;
|
||||
/** Block close from overlay click */
|
||||
closeOnOverlayClick?: boolean;
|
||||
/** Block close from Escape */
|
||||
closeOnEscape?: boolean;
|
||||
}
|
||||
|
||||
const DialogCtx = React.createContext<{
|
||||
ariaLabel?: string;
|
||||
closeOnOverlayClick: boolean;
|
||||
closeOnEscape: boolean;
|
||||
}>({ closeOnOverlayClick: true, closeOnEscape: true });
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
ariaLabel,
|
||||
closeOnOverlayClick = true,
|
||||
closeOnEscape = true,
|
||||
}: DialogProps) {
|
||||
return (
|
||||
<DialogCtx.Provider
|
||||
value={{ ariaLabel, closeOnOverlayClick, closeOnEscape }}
|
||||
>
|
||||
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
{children}
|
||||
</RadixDialog.Root>
|
||||
</DialogCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const DialogTrigger = RadixDialog.Trigger;
|
||||
export const DialogClose = RadixDialog.Close;
|
||||
|
||||
interface DialogPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Accessible label when the panel has no DialogTitle */
|
||||
ariaLabel?: string;
|
||||
/** Accessible description id reference */
|
||||
ariaDescribedBy?: string;
|
||||
}
|
||||
|
||||
export const DialogPanel = React.forwardRef<HTMLDivElement, DialogPanelProps>(
|
||||
({ className, children, ariaLabel, ariaDescribedBy, ...props }, ref) => {
|
||||
const ctx = React.useContext(DialogCtx);
|
||||
const label = ariaLabel ?? ctx.ariaLabel;
|
||||
return (
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
|
||||
"data-[state=open]:animate-fade-in",
|
||||
)}
|
||||
/>
|
||||
<RadixDialog.Content
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
onInteractOutside={(e) => {
|
||||
if (!ctx.closeOnOverlayClick) e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!ctx.closeOnEscape) e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
|
||||
"flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden",
|
||||
"rounded-xl border border-border bg-surface-raised shadow-xl outline-none",
|
||||
"data-[state=open]:animate-fade-in-up",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Radix warns if no Title is present. If consumer didn't provide one,
|
||||
expose ariaLabel via VisuallyHidden Title as a fallback. */}
|
||||
{label && !hasDialogTitle(children) && (
|
||||
<RadixDialog.Title className="sr-only">{label}</RadixDialog.Title>
|
||||
)}
|
||||
{children}
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogPanel.displayName = "DialogPanel";
|
||||
|
||||
/** Best-effort detection: does the children tree include a <DialogTitle>? */
|
||||
function hasDialogTitle(node: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(node, (child) => {
|
||||
if (found) return;
|
||||
if (!React.isValidElement(child)) return;
|
||||
if (child.type === DialogTitle) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const subtree = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (subtree && hasDialogTitle(subtree)) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
export function DialogHeader({
|
||||
className,
|
||||
children,
|
||||
onClose,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { onClose?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-4 border-b border-border px-5 py-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{onClose && (
|
||||
<RadixDialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="-mr-2 -mt-1 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</RadixDialog.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const DialogTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDialog.Title asChild>
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn("text-base font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
</RadixDialog.Title>
|
||||
));
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
export const DialogDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDialog.Description asChild>
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("mt-1 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
</RadixDialog.Description>
|
||||
));
|
||||
DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
export function DialogBody({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex-1 overflow-y-auto px-5 py-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row-reverse items-center gap-2 border-t border-border px-5 py-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
143
ontology_platform/web/frontend/src/components/ui/drawer.tsx
Normal file
143
ontology_platform/web/frontend/src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from "react";
|
||||
import * as RadixDialog from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Side = "right" | "left";
|
||||
type Size = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
const SIZE_CLASS: Record<Size, string> = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-2xl",
|
||||
xl: "max-w-4xl",
|
||||
};
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
side?: Side;
|
||||
size?: Size;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/** Slot rendered on the header right (e.g. tabs, badges, secondary actions) */
|
||||
headerExtra?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Block close when clicking the overlay */
|
||||
closeOnOverlayClick?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-in drawer using Radix Dialog. Comes with focus trap, scroll lock,
|
||||
* Escape close, and ARIA wiring. Use for entity editors, claim reviews,
|
||||
* detail inspectors — anything that benefits from preserving page context.
|
||||
*/
|
||||
export function Drawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
side = "right",
|
||||
size = "lg",
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
headerExtra,
|
||||
className,
|
||||
closeOnOverlayClick = true,
|
||||
}: DrawerProps) {
|
||||
const slideOpen =
|
||||
side === "right"
|
||||
? "data-[state=open]:animate-slide-in-right"
|
||||
: "data-[state=open]:animate-fade-in-up";
|
||||
const sidePos = side === "right" ? "right-0" : "left-0";
|
||||
const borderSide = side === "right" ? "border-l" : "border-r";
|
||||
|
||||
const titleId = React.useId();
|
||||
const descId = React.useId();
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
|
||||
"data-[state=open]:animate-fade-in",
|
||||
)}
|
||||
/>
|
||||
<RadixDialog.Content
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
aria-describedby={description ? descId : undefined}
|
||||
onInteractOutside={(e) => {
|
||||
if (!closeOnOverlayClick) e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
"fixed top-0 bottom-0 z-50 flex w-full flex-col",
|
||||
"bg-surface-raised border-border shadow-2xl outline-none",
|
||||
sidePos,
|
||||
borderSide,
|
||||
SIZE_CLASS[size],
|
||||
slideOpen,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{(title || description || headerExtra) && (
|
||||
<header className="flex items-start justify-between gap-4 border-b border-border px-6 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<RadixDialog.Title asChild>
|
||||
<h2
|
||||
id={titleId}
|
||||
className="truncate text-base font-semibold text-foreground"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</RadixDialog.Title>
|
||||
)}
|
||||
{!title && (
|
||||
// Radix requires a Title for accessibility; provide a hidden one
|
||||
<RadixDialog.Title className="sr-only">
|
||||
Panel
|
||||
</RadixDialog.Title>
|
||||
)}
|
||||
{description && (
|
||||
<RadixDialog.Description asChild>
|
||||
<p
|
||||
id={descId}
|
||||
className="mt-1 text-sm text-muted-foreground"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</RadixDialog.Description>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerExtra}
|
||||
<RadixDialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</RadixDialog.Close>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-border bg-background-subtle px-6 py-3">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "default" | "muted" | "danger" | "info";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
/** Primary call-to-action (usually a <Button>) */
|
||||
primaryAction?: React.ReactNode;
|
||||
/** Optional secondary action */
|
||||
secondaryAction?: React.ReactNode;
|
||||
/** Style variant — danger for errors, info for empty-by-design */
|
||||
variant?: Variant;
|
||||
/** Compact vertical padding for inline / panel usage */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ICON_BG: Record<Variant, string> = {
|
||||
default: "bg-muted text-muted-foreground",
|
||||
muted: "bg-muted text-muted-foreground",
|
||||
danger: "bg-danger-subtle text-danger",
|
||||
info: "bg-info-subtle text-info",
|
||||
};
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
variant = "default",
|
||||
compact = false,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-4 text-center",
|
||||
compact ? "py-8" : "py-16",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-full",
|
||||
ICON_BG[variant],
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
|
||||
{primaryAction}
|
||||
{secondaryAction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
ontology_platform/web/frontend/src/components/ui/error-state.tsx
Normal file
150
ontology_platform/web/frontend/src/components/ui/error-state.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from "react";
|
||||
import { AlertCircle, AlertTriangle, RefreshCw, WifiOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
type Severity = "error" | "warning" | "offline";
|
||||
|
||||
interface ErrorStateProps {
|
||||
/** Visual severity */
|
||||
severity?: Severity;
|
||||
/** Short headline, e.g. "Could not load entities" */
|
||||
title: React.ReactNode;
|
||||
/** Longer explanation. Pass an `Error` and the message will be extracted. */
|
||||
description?: React.ReactNode | Error;
|
||||
/** Retry handler — adds a Retry button when provided */
|
||||
onRetry?: () => void;
|
||||
/** Disable the retry button (e.g. during a refetch) */
|
||||
retrying?: boolean;
|
||||
/** Inline vs full block layout */
|
||||
inline?: boolean;
|
||||
/** Compact vertical padding */
|
||||
compact?: boolean;
|
||||
/** Show technical details (stack) in a collapsible block */
|
||||
detail?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ICON: Record<Severity, React.ComponentType<{ className?: string }>> = {
|
||||
error: AlertCircle,
|
||||
warning: AlertTriangle,
|
||||
offline: WifiOff,
|
||||
};
|
||||
|
||||
const TONE: Record<
|
||||
Severity,
|
||||
{ icon: string; ring: string; bg: string; text: string }
|
||||
> = {
|
||||
error: {
|
||||
icon: "text-danger",
|
||||
ring: "border-danger-border",
|
||||
bg: "bg-danger-subtle",
|
||||
text: "text-danger",
|
||||
},
|
||||
warning: {
|
||||
icon: "text-warning",
|
||||
ring: "border-warning-border",
|
||||
bg: "bg-warning-subtle",
|
||||
text: "text-warning",
|
||||
},
|
||||
offline: {
|
||||
icon: "text-muted-foreground",
|
||||
ring: "border-border",
|
||||
bg: "bg-muted",
|
||||
text: "text-muted-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export function ErrorState({
|
||||
severity = "error",
|
||||
title,
|
||||
description,
|
||||
onRetry,
|
||||
retrying,
|
||||
inline = false,
|
||||
compact = false,
|
||||
detail,
|
||||
className,
|
||||
}: ErrorStateProps) {
|
||||
const Icon = ICON[severity];
|
||||
const tone = TONE[severity];
|
||||
|
||||
const desc =
|
||||
description instanceof Error ? description.message : description;
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-md border px-4 py-3",
|
||||
tone.ring,
|
||||
tone.bg,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("mt-0.5 h-4 w-4 flex-shrink-0", tone.icon)} />
|
||||
<div className="min-w-0 flex-1 text-sm">
|
||||
<div className={cn("font-medium", tone.text)}>{title}</div>
|
||||
{desc && (
|
||||
<div className="mt-0.5 text-muted-foreground">{desc}</div>
|
||||
)}
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-3.5 w-3.5", retrying && "animate-spin")}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-4 text-center",
|
||||
compact ? "py-8" : "py-16",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-full",
|
||||
tone.bg,
|
||||
tone.icon,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
{desc && <p className="text-sm text-muted-foreground">{desc}</p>}
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} disabled={retrying} variant="outline">
|
||||
<RefreshCw className={cn("h-4 w-4", retrying && "animate-spin")} />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{detail && (
|
||||
<details className="mt-2 w-full max-w-2xl text-left">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
|
||||
Show technical details
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded-md border border-border bg-background-subtle p-3 text-2xs text-muted-foreground">
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import * as React from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Check, ChevronDown, Filter, Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
|
||||
/**
|
||||
* Generic FilterPanel — composes a search box, several MultiSelect/Single popovers,
|
||||
* range inputs, and active-filter chips into a unified toolbar.
|
||||
*
|
||||
* Build by composing the named sub-components. Each filter is self-contained so the
|
||||
* parent only stores values and supplies onChange callbacks. Use `FilterChips` to
|
||||
* render the active filter summary below the toolbar.
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
* Layout primitives
|
||||
* ========================================================== */
|
||||
|
||||
export function FilterPanel({
|
||||
children,
|
||||
className,
|
||||
toolbarClassName,
|
||||
chips,
|
||||
onClearAll,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Active filter chips, e.g. from <FilterChips /> */
|
||||
chips?: React.ReactNode;
|
||||
onClearAll?: () => void;
|
||||
className?: string;
|
||||
toolbarClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-surface",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 px-3 py-2",
|
||||
toolbarClassName,
|
||||
)}
|
||||
>
|
||||
<Filter className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{children}
|
||||
</div>
|
||||
{chips && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-t border-border bg-background-subtle px-3 py-2">
|
||||
{chips}
|
||||
{onClearAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Search box
|
||||
* ========================================================== */
|
||||
|
||||
export function FilterSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search…",
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("relative min-w-[200px] flex-1", className)}>
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Select filter (single value) — popover with options + counts
|
||||
* ========================================================== */
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Optional badge / count */
|
||||
hint?: React.ReactNode;
|
||||
/** Optional swatch color for visual cue */
|
||||
swatch?: string;
|
||||
}
|
||||
|
||||
export function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "All",
|
||||
allowEmpty = true,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: FilterOption[];
|
||||
placeholder?: string;
|
||||
allowEmpty?: boolean;
|
||||
}) {
|
||||
const selectedLabel =
|
||||
options.find((o) => o.value === value)?.label ?? placeholder;
|
||||
const hasValue = !!value;
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
|
||||
hasValue && "border-brand-500 text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{label}:</span>
|
||||
<span className={cn(hasValue ? "font-medium" : "text-muted-foreground")}>
|
||||
{selectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="z-40 w-56 overflow-hidden rounded-md border border-border bg-popover shadow-md animate-fade-in-up"
|
||||
>
|
||||
<ul className="max-h-72 overflow-y-auto py-1">
|
||||
{allowEmpty && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent/60",
|
||||
!hasValue && "bg-accent/40 font-medium",
|
||||
)}
|
||||
>
|
||||
<span>{placeholder}</span>
|
||||
{!hasValue && <Check className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{options.map((opt) => {
|
||||
const isSelected = opt.value === value;
|
||||
return (
|
||||
<li key={opt.value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent/60",
|
||||
isSelected && "bg-accent/40 font-medium",
|
||||
)}
|
||||
>
|
||||
{opt.swatch && (
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: opt.swatch }}
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1 truncate">{opt.label}</span>
|
||||
{opt.hint != null && (
|
||||
<span className="text-2xs text-muted-foreground tabular-nums">
|
||||
{opt.hint}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Check className="h-3.5 w-3.5 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Range filter (slider) — for numeric thresholds like confidence
|
||||
* ========================================================== */
|
||||
|
||||
export function FilterRange({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.05,
|
||||
format = (v) => `${Math.round(v * 100)}%`,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
format?: (v: number) => string;
|
||||
}) {
|
||||
const active = value > min;
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
|
||||
active && "border-brand-500 text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{label}:</span>
|
||||
<span className={cn(active ? "font-medium" : "text-muted-foreground")}>
|
||||
≥ {format(value)}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="z-40 w-64 rounded-md border border-border bg-popover p-3 shadow-md animate-fade-in-up"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium tabular-nums">{format(value)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-2xs text-muted-foreground">
|
||||
<span>{format(min)}</span>
|
||||
<span>{format(max)}</span>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Toggle (checkbox-style chip)
|
||||
* ========================================================== */
|
||||
|
||||
export function FilterToggle({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
|
||||
checked && "border-brand-500",
|
||||
disabled && "cursor-not-allowed opacity-50 hover:bg-surface",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="text-foreground">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Active filter chips
|
||||
* ========================================================== */
|
||||
|
||||
export interface ActiveChip {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function FilterChips({ chips }: { chips: ActiveChip[] }) {
|
||||
if (chips.length === 0) {
|
||||
return (
|
||||
<span className="text-2xs text-muted-foreground">No active filters</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-200"
|
||||
>
|
||||
<span className="text-brand-600/70 dark:text-brand-300/70">
|
||||
{chip.label}:
|
||||
</span>
|
||||
<span className="font-medium">{chip.value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onClear}
|
||||
className="ml-0.5 -mr-0.5 rounded-full p-0.5 hover:bg-brand-200/60 dark:hover:bg-brand-800/60"
|
||||
aria-label={`Remove ${chip.label} filter`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Refresh / view actions slot (placed on the right of toolbar)
|
||||
* ========================================================== */
|
||||
|
||||
export function FilterActions({ children }: { children: React.ReactNode }) {
|
||||
return <div className="ml-auto flex items-center gap-1">{children}</div>;
|
||||
}
|
||||
|
||||
// Re-export Button for convenience when composing inline
|
||||
export { Button };
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "./skeleton";
|
||||
|
||||
type Variant = "spinner" | "skeleton" | "list" | "table";
|
||||
|
||||
interface LoadingStateProps {
|
||||
variant?: Variant;
|
||||
/** Optional message under the indicator */
|
||||
message?: React.ReactNode;
|
||||
/** For skeleton variants: row count */
|
||||
rows?: number;
|
||||
/** Compact vertical padding */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized loading indicator. Pick a variant that matches what's coming:
|
||||
* - `spinner`: indeterminate work (mutations, single resources)
|
||||
* - `skeleton`: rectangular placeholder (single block)
|
||||
* - `list`: vertical list of rows
|
||||
* - `table`: dense grid of rows × 4 cols
|
||||
*/
|
||||
export function LoadingState({
|
||||
variant = "spinner",
|
||||
message,
|
||||
rows = 4,
|
||||
compact = false,
|
||||
className,
|
||||
}: LoadingStateProps) {
|
||||
if (variant === "spinner") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 text-center text-muted-foreground",
|
||||
compact ? "py-6" : "py-16",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-brand-500" />
|
||||
{message && <span className="text-sm">{message}</span>}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "skeleton") {
|
||||
return <Skeleton className={cn("h-24 w-full", className)} />;
|
||||
}
|
||||
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} aria-busy="true">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// table
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border border-border",
|
||||
className,
|
||||
)}
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="border-b border-border bg-background-subtle px-3 py-2">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-4 gap-3 border-b border-border px-3 py-3 last:border-0"
|
||||
>
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
<Skeleton className="h-3.5 w-2/3" />
|
||||
<Skeleton className="h-3.5 w-1/2" />
|
||||
<Skeleton className="h-3.5 w-3/5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import * as React from "react";
|
||||
import { Keyboard } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogBody,
|
||||
} from "./dialog";
|
||||
|
||||
export interface ShortcutEntry {
|
||||
keys: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ShortcutGroup {
|
||||
title: string;
|
||||
items: ShortcutEntry[];
|
||||
}
|
||||
|
||||
interface ShortcutsOverlayProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
groups: ShortcutGroup[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal listing keyboard shortcuts. Pair with the `?` binding to open it.
|
||||
*
|
||||
* Each `keys` entry uses tokens separated by spaces for chord (e.g. "g d")
|
||||
* or `+` for modifier combos (e.g. "Mod+K", "Shift+/"). Tokens are rendered
|
||||
* as individual <kbd> chips.
|
||||
*/
|
||||
export function ShortcutsOverlay({
|
||||
open,
|
||||
onOpenChange,
|
||||
groups,
|
||||
title = "Keyboard shortcuts",
|
||||
description = "Press the key combination to trigger the action.",
|
||||
}: ShortcutsOverlayProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} ariaLabel={title}>
|
||||
<DialogPanel className="mx-auto mt-20 max-w-2xl">
|
||||
<DialogHeader onClose={() => onOpenChange(false)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
|
||||
<Keyboard className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="mb-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.title}
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item.keys + item.label}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border bg-background-subtle px-3 py-1.5"
|
||||
>
|
||||
<span className="text-sm text-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<ShortcutChips keys={item.keys} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutChips({ keys }: { keys: string }) {
|
||||
// chord (space) → sequence; modifier (+) → group
|
||||
const trimmed = keys.trim();
|
||||
if (trimmed.includes(" ")) {
|
||||
const parts = trimmed.split(/\s+/);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{parts.map((p, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<Kbd>{p}</Kbd>
|
||||
{i < parts.length - 1 && (
|
||||
<span className="text-2xs text-muted-foreground">then</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (trimmed.includes("+")) {
|
||||
const parts = trimmed.split("+").map((p) => p.trim());
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{parts.map((p, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<Kbd>{prettifyKey(p)}</Kbd>
|
||||
{i < parts.length - 1 && (
|
||||
<span className="text-2xs text-muted-foreground">+</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <Kbd>{prettifyKey(trimmed)}</Kbd>;
|
||||
}
|
||||
|
||||
function prettifyKey(k: string): string {
|
||||
const lower = k.toLowerCase();
|
||||
if (typeof navigator === "undefined") return k;
|
||||
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
if (lower === "mod") return isMac ? "⌘" : "Ctrl";
|
||||
if (lower === "cmd" || lower === "meta") return isMac ? "⌘" : "Win";
|
||||
if (lower === "ctrl") return "Ctrl";
|
||||
if (lower === "shift") return "⇧";
|
||||
if (lower === "alt" || lower === "option") return isMac ? "⌥" : "Alt";
|
||||
if (lower === "enter") return "↵";
|
||||
if (lower === "escape" || lower === "esc") return "Esc";
|
||||
if (lower === "arrowup") return "↑";
|
||||
if (lower === "arrowdown") return "↓";
|
||||
if (lower === "arrowleft") return "←";
|
||||
if (lower === "arrowright") return "→";
|
||||
return k.length === 1 ? k.toUpperCase() : k;
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="inline-flex min-w-[1.5rem] items-center justify-center rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-2xs font-medium text-foreground shadow-xs">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
59
ontology_platform/web/frontend/src/components/ui/tooltip.tsx
Normal file
59
ontology_platform/web/frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Thin wrapper over Radix Tooltip with project styling.
|
||||
* Wrap your tree in <TooltipProvider> once (in main.tsx).
|
||||
*/
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
export interface TooltipProps {
|
||||
children: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
/** Side relative to the trigger */
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
/** Alignment along the side axis */
|
||||
align?: "start" | "center" | "end";
|
||||
/** Open delay in ms. Default 200. */
|
||||
delayMs?: number;
|
||||
/** Show keyboard shortcut chip after content */
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
children,
|
||||
content,
|
||||
side = "top",
|
||||
align = "center",
|
||||
delayMs = 200,
|
||||
shortcut,
|
||||
}: TooltipProps) {
|
||||
return (
|
||||
<TooltipPrimitive.Root delayDuration={delayMs}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"z-50 inline-flex items-center gap-2 rounded-md border border-border bg-popover px-2.5 py-1.5",
|
||||
"text-2xs text-popover-foreground shadow-md",
|
||||
"animate-fade-in",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
)}
|
||||
>
|
||||
<span>{content}</span>
|
||||
{shortcut && (
|
||||
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
<TooltipPrimitive.Arrow className="fill-popover stroke-border" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user