This commit is contained in:
LASTA_DEV01\lasta
2026-05-20 18:43:08 +09:00
parent 33584dd631
commit 651c47d698
30 changed files with 6222 additions and 793 deletions

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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 &ldquo;{query}&rdquo;
</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]);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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`),
);

File diff suppressed because it is too large Load Diff

View 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}
/>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}