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>
|
||||
);
|
||||
}
|
||||
173
ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
173
ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Generic keyboard shortcut hook supporting two forms:
|
||||
* - Single keys / modifier combos: "?", "/", "Mod+K", "Shift+/"
|
||||
* - Chord (leader-follower) sequences: "g d", "g n" — leader pressed then
|
||||
* follower within `chordWindowMs`. Inspired by Linear / GitHub.
|
||||
*
|
||||
* Bindings is a record of `pattern -> handler`. Patterns are case-insensitive.
|
||||
* Use "Mod" for Ctrl on Windows/Linux and Cmd on macOS.
|
||||
*
|
||||
* The hook ignores key events whose target is an editable element (input,
|
||||
* textarea, contenteditable) UNLESS the pattern uses a modifier (Mod/Shift/Alt).
|
||||
*/
|
||||
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
/** Disable all bindings (e.g. while a modal is open) */
|
||||
disabled?: boolean;
|
||||
/** Time window in ms for a chord follower after leader. Default 1500. */
|
||||
chordWindowMs?: number;
|
||||
/** Fired when a leader key starts a chord, useful for UI hints. */
|
||||
onChordStart?: (leader: string) => void;
|
||||
/** Fired when a chord times out or is cancelled. */
|
||||
onChordEnd?: () => void;
|
||||
}
|
||||
|
||||
type Handler = (event: KeyboardEvent) => void;
|
||||
|
||||
function isEditable(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMac(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
}
|
||||
|
||||
interface ParsedSingle {
|
||||
kind: "single";
|
||||
key: string; // lowercase
|
||||
mod: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
interface ParsedChord {
|
||||
kind: "chord";
|
||||
leader: string; // lowercase
|
||||
follower: string; // lowercase
|
||||
}
|
||||
|
||||
function parsePattern(pattern: string): ParsedSingle | ParsedChord {
|
||||
const trimmed = pattern.trim();
|
||||
if (trimmed.includes(" ")) {
|
||||
const [leader, follower] = trimmed.toLowerCase().split(/\s+/);
|
||||
return { kind: "chord", leader, follower };
|
||||
}
|
||||
const parts = trimmed.split("+").map((p) => p.trim().toLowerCase());
|
||||
const key = parts.pop() ?? "";
|
||||
const set = new Set(parts);
|
||||
return {
|
||||
kind: "single",
|
||||
key,
|
||||
mod: set.has("mod") || set.has("ctrl") || set.has("cmd") || set.has("meta"),
|
||||
shift: set.has("shift"),
|
||||
alt: set.has("alt") || set.has("option"),
|
||||
};
|
||||
}
|
||||
|
||||
function keyMatches(event: KeyboardEvent, parsed: ParsedSingle): boolean {
|
||||
const evKey = event.key.toLowerCase();
|
||||
if (evKey !== parsed.key) return false;
|
||||
const mod = isMac() ? event.metaKey : event.ctrlKey;
|
||||
if (parsed.mod !== mod) return false;
|
||||
if (parsed.shift !== event.shiftKey) return false;
|
||||
if (parsed.alt !== event.altKey) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
bindings: Record<string, Handler>,
|
||||
opts: UseKeyboardShortcutsOptions = {},
|
||||
) {
|
||||
const bindingsRef = useRef(bindings);
|
||||
bindingsRef.current = bindings;
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const pending = { leader: null as string | null, timer: 0 };
|
||||
|
||||
const clearChord = () => {
|
||||
if (pending.timer) {
|
||||
window.clearTimeout(pending.timer);
|
||||
pending.timer = 0;
|
||||
}
|
||||
if (pending.leader) {
|
||||
pending.leader = null;
|
||||
optsRef.current.onChordEnd?.();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (optsRef.current.disabled) return;
|
||||
const editable = isEditable(event.target);
|
||||
const evKey = event.key.toLowerCase();
|
||||
|
||||
// Parse all bindings on each press (cheap; ~15 entries)
|
||||
const entries = Object.entries(bindingsRef.current).map(
|
||||
([pattern, handler]) => ({ pattern, parsed: parsePattern(pattern), handler }),
|
||||
);
|
||||
|
||||
// 1) Follower phase — if a leader is pending, look for chord match.
|
||||
if (pending.leader) {
|
||||
const match = entries.find(
|
||||
(e) =>
|
||||
e.parsed.kind === "chord" &&
|
||||
e.parsed.leader === pending.leader &&
|
||||
e.parsed.follower === evKey,
|
||||
);
|
||||
if (match) {
|
||||
event.preventDefault();
|
||||
clearChord();
|
||||
match.handler(event);
|
||||
return;
|
||||
}
|
||||
// Any other key cancels the chord
|
||||
clearChord();
|
||||
// ... and falls through to normal handling below.
|
||||
}
|
||||
|
||||
// 2) Single key / modifier combos
|
||||
for (const e of entries) {
|
||||
if (e.parsed.kind !== "single") continue;
|
||||
const hasMod = e.parsed.mod || e.parsed.shift || e.parsed.alt;
|
||||
if (editable && !hasMod) continue;
|
||||
if (keyMatches(event, e.parsed)) {
|
||||
event.preventDefault();
|
||||
e.handler(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Leader phase — start a chord if this key is a leader and no
|
||||
// modifier was pressed (chords are bare keys).
|
||||
if (editable) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const leaders = new Set(
|
||||
entries
|
||||
.filter((e) => e.parsed.kind === "chord")
|
||||
.map((e) => (e.parsed as ParsedChord).leader),
|
||||
);
|
||||
if (leaders.has(evKey)) {
|
||||
pending.leader = evKey;
|
||||
optsRef.current.onChordStart?.(evKey);
|
||||
pending.timer = window.setTimeout(
|
||||
clearChord,
|
||||
optsRef.current.chordWindowMs ?? 1500,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
clearChord();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
30
ontology_platform/web/frontend/src/hooks/useTheme.ts
Normal file
30
ontology_platform/web/frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/stores";
|
||||
|
||||
/**
|
||||
* Syncs the Redux theme state to the `<html>` element's `dark` class.
|
||||
* Honors system preference when mode === "system" and reacts to OS changes.
|
||||
*/
|
||||
export function useThemeSync() {
|
||||
const mode = useSelector((s: RootState) => s.ui.theme);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const apply = () => {
|
||||
const resolved =
|
||||
mode === "system" ? (media.matches ? "dark" : "light") : mode;
|
||||
root.classList.toggle("dark", resolved === "dark");
|
||||
root.dataset.theme = resolved;
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
if (mode === "system") {
|
||||
media.addEventListener("change", apply);
|
||||
return () => media.removeEventListener("change", apply);
|
||||
}
|
||||
}, [mode]);
|
||||
}
|
||||
@@ -8,17 +8,23 @@ import "./i18n";
|
||||
import App from "./App";
|
||||
import store from "./stores";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
import "./styles/globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter basename="/static/">
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
<ErrorBoundary>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
|
||||
<BrowserRouter basename="/static/">
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -5,28 +6,38 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Plus,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DataTable, type ColumnDef } from "@/components/ui/data-table";
|
||||
import { Drawer } from "@/components/ui/drawer";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { useCreateSource, useDeleteSource } from "@/hooks/useSources";
|
||||
|
||||
type SourceRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
base_url?: string | null;
|
||||
trust_level?: string | number | null;
|
||||
respect_robots_txt?: boolean | null;
|
||||
rate_limit_per_minute?: number | null;
|
||||
};
|
||||
|
||||
const toNumber = (v: string | number | null | undefined): number | null =>
|
||||
v == null ? null : typeof v === "number" ? v : Number(v);
|
||||
|
||||
const SOURCE_TYPES = [
|
||||
"official",
|
||||
"review",
|
||||
@@ -62,6 +73,15 @@ const sourceSchema = z.object({
|
||||
|
||||
type SourceFormValues = z.infer<typeof sourceSchema>;
|
||||
|
||||
const TYPE_TONE: Record<string, string> = {
|
||||
official: "pill-info",
|
||||
review: "pill-warning",
|
||||
blog: "pill-muted",
|
||||
news: "pill-info",
|
||||
community: "pill-muted",
|
||||
unknown: "pill-muted",
|
||||
};
|
||||
|
||||
export default function ConfigureSourcesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
@@ -79,6 +99,8 @@ export default function ConfigureSourcesPage() {
|
||||
const createSource = useCreateSource(projectName);
|
||||
const deleteSource = useDeleteSource(projectName);
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -96,6 +118,11 @@ export default function ConfigureSourcesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const openAdd = () => {
|
||||
reset();
|
||||
setAddOpen(true);
|
||||
};
|
||||
|
||||
const onAddSource = async (values: SourceFormValues) => {
|
||||
try {
|
||||
await createSource.mutateAsync({
|
||||
@@ -108,6 +135,7 @@ export default function ConfigureSourcesPage() {
|
||||
}),
|
||||
);
|
||||
reset();
|
||||
setAddOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("sources.addFailed", "추가 실패: {{msg}}", {
|
||||
@@ -143,269 +171,327 @@ export default function ConfigureSourcesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/")}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("sources.title", "참고 소스 설정")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}{" "}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
const sources = project?.sources ?? [];
|
||||
|
||||
const columns: ColumnDef<SourceRow, any>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: t("sources.name", "이름"),
|
||||
accessorFn: (row) => row.name,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
|
||||
<Globe2 className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate font-medium">{row.original.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(`/crawl/${projectName}`)}
|
||||
disabled={!project || (project.sources?.length ?? 0) === 0}
|
||||
>
|
||||
{t("sources.next", "크롤 진행")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
size: 240,
|
||||
enablePinning: true,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: t("sources.type", "타입"),
|
||||
accessorFn: (row) => row.type,
|
||||
cell: ({ row }) => (
|
||||
<span className={TYPE_TONE[row.original.type] ?? "pill-muted"}>
|
||||
{row.original.type}
|
||||
</span>
|
||||
),
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: "base_url",
|
||||
header: "Base URL",
|
||||
accessorFn: (row) => row.base_url ?? "",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.base_url ? (
|
||||
<a
|
||||
href={row.original.base_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span className="max-w-[260px] truncate">
|
||||
{row.original.base_url}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/60">—</span>
|
||||
),
|
||||
size: 280,
|
||||
},
|
||||
{
|
||||
id: "trust",
|
||||
header: t("sources.trust", "신뢰도"),
|
||||
accessorFn: (row) => toNumber(row.trust_level) ?? 0,
|
||||
cell: ({ row }) => {
|
||||
const n = toNumber(row.original.trust_level);
|
||||
return n == null ? (
|
||||
<span className="text-xs text-muted-foreground/60">—</span>
|
||||
) : (
|
||||
<TrustBar value={n} />
|
||||
);
|
||||
},
|
||||
size: 140,
|
||||
meta: { align: "right" },
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
header: t("sources.rateLimit", "rate/분"),
|
||||
accessorFn: (row) => row.rate_limit_per_minute ?? 0,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">
|
||||
{row.original.rate_limit_per_minute ?? "—"}
|
||||
</span>
|
||||
),
|
||||
size: 110,
|
||||
meta: { align: "right" },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(row.original.name);
|
||||
}}
|
||||
disabled={deleteSource.isPending}
|
||||
aria-label={t("sources.delete", "삭제")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-danger" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
size: 60,
|
||||
meta: { align: "right" },
|
||||
},
|
||||
];
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center justify-between gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{(error as Error).message}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{t("common.retry", "다시 시도")}
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={Settings2}
|
||||
breadcrumbs={[
|
||||
{ label: t("nav.dashboard", "Dashboard"), to: "/" },
|
||||
{ label: projectName, to: `/sources/${projectName}` },
|
||||
{ label: t("sources.title", "Source Explorer") },
|
||||
]}
|
||||
title={t("sources.title", "참고 소스 설정")}
|
||||
description={
|
||||
project &&
|
||||
`${project.name} · ${project.domain} · ${sources.length} ${t("sources.count", "sources")}`
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={openAdd}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("sources.add", "소스 추가")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => navigate(`/crawl/${projectName}`)}
|
||||
disabled={!project || sources.length === 0}
|
||||
>
|
||||
{t("sources.next", "크롤 진행")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sources.listTitle", "등록된 소스")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"sources.listDesc",
|
||||
"프로젝트 온톨로지 구축에 사용할 참고 사이트 목록",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
{isError && (
|
||||
<ErrorState
|
||||
className="mb-4"
|
||||
inline
|
||||
severity="error"
|
||||
title={t("sources.loadFailedTitle", "소스를 불러오지 못했습니다")}
|
||||
description={error}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataTable<SourceRow>
|
||||
tableId="sources"
|
||||
columns={columns}
|
||||
data={sources}
|
||||
getRowId={(row) => row.id}
|
||||
loading={isLoading}
|
||||
loadingRows={4}
|
||||
height={560}
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={Globe2}
|
||||
title={t("sources.emptyTitle", "등록된 소스가 없습니다")}
|
||||
description={t(
|
||||
"sources.empty",
|
||||
"첫 참고 사이트를 추가해 크롤을 시작하세요.",
|
||||
)}
|
||||
|
||||
{project && project.sources.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sources.empty",
|
||||
"아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{project && project.sources.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{project.sources.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
|
||||
{s.type}
|
||||
</span>
|
||||
{typeof s.trust_level === "number" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("sources.trust", "신뢰도")}{" "}
|
||||
{s.trust_level.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{s.base_url && (
|
||||
<a
|
||||
href={s.base_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{s.base_url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(s.name)}
|
||||
disabled={deleteSource.isPending}
|
||||
aria-label={t("sources.delete", "삭제")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<aside>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sources.addTitle", "소스 추가")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("sources.addDesc", "참고할 사이트 정보를 입력하세요")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onAddSource)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_name">
|
||||
{t("sources.name", "이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_name"
|
||||
placeholder="official_brand_site"
|
||||
{...register("name")}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_type">
|
||||
{t("sources.type", "타입")}
|
||||
</Label>
|
||||
<select
|
||||
id="src_type"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
{...register("type")}
|
||||
>
|
||||
{SOURCE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_url">
|
||||
{t("sources.baseUrl", "Base URL")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_url"
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
{...register("base_url")}
|
||||
/>
|
||||
{errors.base_url && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.base_url.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_trust">
|
||||
{t("sources.trust", "신뢰도")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_trust"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0}
|
||||
max={1}
|
||||
{...register("trust_level", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.trust_level && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.trust_level.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_rate">
|
||||
{t("sources.rateLimit", "rate/분")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_rate"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
{...register("rate_limit_per_minute", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.rate_limit_per_minute && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.rate_limit_per_minute.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("respect_robots_txt")}
|
||||
/>
|
||||
<span>
|
||||
{t("sources.respectRobots", "robots.txt 준수")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || createSource.isPending}
|
||||
>
|
||||
{createSource.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
primaryAction={
|
||||
<Button onClick={openAdd}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("sources.add", "소스 추가")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
size="md"
|
||||
title={t("sources.addTitle", "소스 추가")}
|
||||
description={t("sources.addDesc", "참고할 사이트 정보를 입력하세요")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setAddOpen(false)}
|
||||
>
|
||||
{t("common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="add-source-form"
|
||||
disabled={isSubmitting || createSource.isPending}
|
||||
>
|
||||
{createSource.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("sources.add", "소스 추가")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="add-source-form"
|
||||
onSubmit={handleSubmit(onAddSource)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_name">{t("sources.name", "이름")}</Label>
|
||||
<Input
|
||||
id="src_name"
|
||||
placeholder="official_brand_site"
|
||||
{...register("name")}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-danger">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_type">{t("sources.type", "타입")}</Label>
|
||||
<select
|
||||
id="src_type"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
{...register("type")}
|
||||
>
|
||||
{SOURCE_TYPES.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_url">
|
||||
{t("sources.baseUrl", "Base URL")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_url"
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
{...register("base_url")}
|
||||
/>
|
||||
{errors.base_url && (
|
||||
<p className="text-xs text-danger">{errors.base_url.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_trust">
|
||||
{t("sources.trust", "신뢰도")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_trust"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0}
|
||||
max={1}
|
||||
{...register("trust_level", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.trust_level && (
|
||||
<p className="text-xs text-danger">
|
||||
{errors.trust_level.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_rate">
|
||||
{t("sources.rateLimit", "rate/분")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_rate"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
{...register("rate_limit_per_minute", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.rate_limit_per_minute && (
|
||||
<p className="text-xs text-danger">
|
||||
{errors.rate_limit_per_minute.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("respect_robots_txt")}
|
||||
/>
|
||||
<span>{t("sources.respectRobots", "robots.txt 준수")}</span>
|
||||
</label>
|
||||
</form>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TrustBar({ value }: { value: number }) {
|
||||
const pct = Math.round(value * 100);
|
||||
const tone =
|
||||
value >= 0.75 ? "bg-success" : value >= 0.4 ? "bg-warning" : "bg-danger";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
||||
<div className={`h-full ${tone}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Eye,
|
||||
Filter,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -168,23 +170,23 @@ export default function ReviewPage() {
|
||||
<Metric label="Rejected" value={counts.rejected} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_420px]">
|
||||
<Card>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Review Queue</CardTitle>
|
||||
<CardDescription>
|
||||
신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-56 pl-9"
|
||||
className="w-48 pl-9 sm:w-56"
|
||||
placeholder="Search claims"
|
||||
/>
|
||||
</div>
|
||||
@@ -193,7 +195,7 @@ export default function ReviewPage() {
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="w-40 pl-9"
|
||||
className="w-32 pl-9 sm:w-40"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="candidate">Candidate</option>
|
||||
@@ -218,51 +220,81 @@ export default function ReviewPage() {
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{filteredClaims.map((claim) => (
|
||||
<article
|
||||
key={claim.id}
|
||||
className="rounded-md border bg-background p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
{filteredClaims.map((claim) => {
|
||||
const isSelected = selectedClaim?.id === claim.id;
|
||||
return (
|
||||
<article
|
||||
key={claim.id}
|
||||
className={cn(
|
||||
"rounded-md border bg-surface transition-colors",
|
||||
isSelected
|
||||
? "border-brand-500 ring-1 ring-brand-500/30"
|
||||
: "border-border hover:border-border-strong",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(claim.id)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
className="block w-full px-4 pt-4 pb-3 text-left"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant={statusVariant(claim.status)}>
|
||||
{reviewLabel(claim.status)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{claim.predicate}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-2xs text-muted-foreground">
|
||||
{confidenceBucket(claim)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
<div className="flex flex-wrap items-baseline gap-1.5 text-sm">
|
||||
<span className="break-all font-medium text-foreground">
|
||||
{humanizeValue(claim.subject)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">-></span>
|
||||
<span className="font-medium">{claimObject(claim)}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="break-all font-medium text-foreground">
|
||||
{claimObject(claim)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>Confidence {formatPercent(claim.confidence)}</span>
|
||||
<span>Source {humanizeValue(claim.source)}</span>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-2xs text-muted-foreground">
|
||||
<span>
|
||||
Evidence {claim.evidence_text ? "yes" : "missing"}
|
||||
Confidence{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatPercent(claim.confidence)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Method {humanizeValue(claim.extraction_method)}
|
||||
Source{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{humanizeValue(claim.source)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Evidence{" "}
|
||||
<span
|
||||
className={
|
||||
claim.evidence_text
|
||||
? "font-medium text-success"
|
||||
: "font-medium text-warning"
|
||||
}
|
||||
>
|
||||
{claim.evidence_text ? "yes" : "missing"}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Method{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{humanizeValue(claim.extraction_method)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border bg-background-subtle px-3 py-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedId(claim.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Detail
|
||||
</Button>
|
||||
<Button
|
||||
@@ -271,7 +303,7 @@ export default function ReviewPage() {
|
||||
onClick={() => applyStatus(claim, "validated_claim")}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
@@ -280,18 +312,18 @@ export default function ReviewPage() {
|
||||
onClick={() => applyStatus(claim, "rejected")}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<aside className="lg:sticky lg:top-4 lg:self-start">
|
||||
<aside className="xl:sticky xl:top-4 xl:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Claim Detail</CardTitle>
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
const THEME_STORAGE_KEY = "op.theme";
|
||||
|
||||
function readStoredTheme(): ThemeMode {
|
||||
if (typeof window === "undefined") return "system";
|
||||
try {
|
||||
const v = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (v === "light" || v === "dark" || v === "system") return v;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function persistTheme(mode: ThemeMode) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
sidebarOpen: boolean;
|
||||
currentStep: number; // 0: dashboard, 1: onboard, 2: sources, 3: crawl, 4: review
|
||||
selectedProjectId: string | null;
|
||||
isLoading: boolean;
|
||||
theme: ThemeMode;
|
||||
notification: {
|
||||
type: "success" | "error" | "info" | "warning" | null;
|
||||
message: string;
|
||||
@@ -16,6 +41,7 @@ const initialState: UIState = {
|
||||
currentStep: 0,
|
||||
selectedProjectId: null,
|
||||
isLoading: false,
|
||||
theme: readStoredTheme(),
|
||||
notification: {
|
||||
type: null,
|
||||
message: "",
|
||||
@@ -50,6 +76,20 @@ const uiSlice = createSlice({
|
||||
clearNotification: (state) => {
|
||||
state.notification = { type: null, message: "" };
|
||||
},
|
||||
setTheme: (state, action: PayloadAction<ThemeMode>) => {
|
||||
state.theme = action.payload;
|
||||
persistTheme(action.payload);
|
||||
},
|
||||
cycleTheme: (state) => {
|
||||
const next: ThemeMode =
|
||||
state.theme === "light"
|
||||
? "dark"
|
||||
: state.theme === "dark"
|
||||
? "system"
|
||||
: "light";
|
||||
state.theme = next;
|
||||
persistTheme(next);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,6 +100,8 @@ export const {
|
||||
setLoading,
|
||||
showNotification,
|
||||
clearNotification,
|
||||
setTheme,
|
||||
cycleTheme,
|
||||
} = uiSlice.actions;
|
||||
|
||||
export default uiSlice.reducer;
|
||||
|
||||
@@ -2,50 +2,180 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================================================
|
||||
* Design Tokens — Ontology Platform
|
||||
* Two layers:
|
||||
* 1. Primitive tokens (brand-*, chart-*) — raw palette
|
||||
* 2. Semantic tokens (background, surface, primary, ring, success, ...)
|
||||
* reference primitives; only semantic tokens should be used in components.
|
||||
* Values are HSL triplets (no `hsl()` wrapper) for Tailwind opacity modifiers.
|
||||
* ========================================================================== */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--muted: 221.2 63.6% 97%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 221.2 83.2% 53.3%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
--primary: 222.2 47.6% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 47.6% 11.2%;
|
||||
/* — Brand scale (deep indigo, tuned for data-heavy surfaces) — */
|
||||
--brand-50: 220 100% 97%;
|
||||
--brand-100: 220 95% 93%;
|
||||
--brand-200: 221 92% 86%;
|
||||
--brand-300: 222 88% 76%;
|
||||
--brand-400: 224 84% 64%;
|
||||
--brand-500: 226 78% 55%;
|
||||
--brand-600: 228 74% 47%;
|
||||
--brand-700: 229 70% 39%;
|
||||
--brand-800: 230 64% 32%;
|
||||
--brand-900: 231 58% 24%;
|
||||
--brand-950: 232 60% 14%;
|
||||
|
||||
/* — Chart palette (qualitative, colorblind-safer ordering) — */
|
||||
--chart-1: 226 78% 55%;
|
||||
--chart-2: 175 70% 41%;
|
||||
--chart-3: 32 95% 56%;
|
||||
--chart-4: 280 68% 60%;
|
||||
--chart-5: 340 80% 58%;
|
||||
--chart-6: 142 64% 42%;
|
||||
--chart-7: 200 90% 48%;
|
||||
--chart-8: 12 78% 56%;
|
||||
--chart-9: 55 88% 52%;
|
||||
--chart-10: 260 70% 62%;
|
||||
--chart-11: 165 60% 38%;
|
||||
--chart-12: 300 60% 52%;
|
||||
|
||||
/* — Semantic surfaces (light) — */
|
||||
--background: 210 25% 99%;
|
||||
--background-subtle: 220 24% 96%;
|
||||
--surface: 0 0% 100%;
|
||||
--surface-raised: 0 0% 100%;
|
||||
--foreground: 222 47% 11%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
|
||||
--muted: 220 20% 96%;
|
||||
--muted-foreground: 220 12% 44%;
|
||||
|
||||
--primary: 226 78% 55%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 220 20% 94%;
|
||||
--secondary-foreground: 222 47% 16%;
|
||||
|
||||
--accent: 220 20% 94%;
|
||||
--accent-foreground: 222 47% 16%;
|
||||
|
||||
--border: 220 16% 90%;
|
||||
--border-strong: 220 14% 82%;
|
||||
--input: 220 16% 90%;
|
||||
--ring: 226 78% 55%;
|
||||
|
||||
/* — Semantic states (light) — */
|
||||
--success: 142 62% 40%;
|
||||
--success-foreground: 140 80% 98%;
|
||||
--success-subtle: 142 60% 95%;
|
||||
--success-border: 142 50% 78%;
|
||||
|
||||
--warning: 32 92% 48%;
|
||||
--warning-foreground: 30 90% 98%;
|
||||
--warning-subtle: 40 95% 94%;
|
||||
--warning-border: 36 88% 76%;
|
||||
|
||||
--danger: 0 75% 52%;
|
||||
--danger-foreground: 0 80% 98%;
|
||||
--danger-subtle: 0 80% 96%;
|
||||
--danger-border: 0 70% 82%;
|
||||
|
||||
--info: 210 85% 50%;
|
||||
--info-foreground: 210 90% 98%;
|
||||
--info-subtle: 210 90% 95%;
|
||||
--info-border: 210 80% 80%;
|
||||
|
||||
/* — Geometry — */
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* — Elevation (light) — uses cool-tinted shadows for crispness — */
|
||||
--shadow-xs: 0 1px 2px 0 hsl(220 30% 12% / 0.04);
|
||||
--shadow-sm: 0 1px 2px 0 hsl(220 30% 12% / 0.05),
|
||||
0 1px 3px 0 hsl(220 30% 12% / 0.06);
|
||||
--shadow-md: 0 2px 4px -1px hsl(220 30% 12% / 0.06),
|
||||
0 4px 8px -2px hsl(220 30% 12% / 0.08);
|
||||
--shadow-lg: 0 4px 8px -2px hsl(220 30% 12% / 0.08),
|
||||
0 12px 24px -6px hsl(220 30% 12% / 0.12);
|
||||
--shadow-xl: 0 8px 16px -4px hsl(220 30% 12% / 0.10),
|
||||
0 24px 48px -12px hsl(220 30% 12% / 0.18);
|
||||
--shadow-2xl: 0 32px 64px -16px hsl(220 30% 12% / 0.24);
|
||||
|
||||
/* — Typography (font families) — */
|
||||
--font-sans: "Pretendard Variable", "Pretendard", "Inter",
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI",
|
||||
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||
--font-display: "Inter", "Pretendard Variable", "Pretendard",
|
||||
ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace,
|
||||
SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 91.2% 59.8%;
|
||||
--accent-foreground: 222.2 47.6% 11.2%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.6% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
/* — Brand scale stays primitive; semantic tokens re-point — */
|
||||
--background: 224 30% 7%;
|
||||
--background-subtle: 224 28% 10%;
|
||||
--surface: 224 28% 10%;
|
||||
--surface-raised: 224 26% 13%;
|
||||
--foreground: 210 30% 96%;
|
||||
|
||||
--card: 224 26% 11%;
|
||||
--card-foreground: 210 30% 96%;
|
||||
--popover: 224 26% 13%;
|
||||
--popover-foreground: 210 30% 96%;
|
||||
|
||||
--muted: 224 22% 16%;
|
||||
--muted-foreground: 220 14% 64%;
|
||||
|
||||
--primary: 224 84% 64%;
|
||||
--primary-foreground: 226 80% 10%;
|
||||
|
||||
--secondary: 224 22% 18%;
|
||||
--secondary-foreground: 210 30% 96%;
|
||||
|
||||
--accent: 224 22% 20%;
|
||||
--accent-foreground: 210 30% 96%;
|
||||
|
||||
--border: 224 20% 20%;
|
||||
--border-strong: 224 18% 28%;
|
||||
--input: 224 20% 20%;
|
||||
--ring: 224 84% 64%;
|
||||
|
||||
--success: 142 55% 50%;
|
||||
--success-foreground: 142 80% 8%;
|
||||
--success-subtle: 142 40% 16%;
|
||||
--success-border: 142 40% 28%;
|
||||
|
||||
--warning: 36 92% 58%;
|
||||
--warning-foreground: 30 90% 10%;
|
||||
--warning-subtle: 36 50% 18%;
|
||||
--warning-border: 36 50% 30%;
|
||||
|
||||
--danger: 0 72% 60%;
|
||||
--danger-foreground: 0 80% 98%;
|
||||
--danger-subtle: 0 50% 20%;
|
||||
--danger-border: 0 50% 32%;
|
||||
|
||||
--info: 210 90% 62%;
|
||||
--info-foreground: 210 90% 10%;
|
||||
--info-subtle: 210 50% 18%;
|
||||
--info-border: 210 50% 30%;
|
||||
|
||||
/* shadows on dark are softer and rely on borders + glow */
|
||||
--shadow-xs: 0 1px 2px 0 hsl(0 0% 0% / 0.30);
|
||||
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.32),
|
||||
0 1px 3px 0 hsl(0 0% 0% / 0.30);
|
||||
--shadow-md: 0 2px 4px -1px hsl(0 0% 0% / 0.36),
|
||||
0 4px 8px -2px hsl(0 0% 0% / 0.40);
|
||||
--shadow-lg: 0 4px 8px -2px hsl(0 0% 0% / 0.44),
|
||||
0 12px 24px -6px hsl(0 0% 0% / 0.48);
|
||||
--shadow-xl: 0 8px 16px -4px hsl(0 0% 0% / 0.50),
|
||||
0 24px 48px -12px hsl(0 0% 0% / 0.55);
|
||||
--shadow-2xl: 0 32px 64px -16px hsl(0 0% 0% / 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +184,92 @@
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: "cv11", "ss01", "ss03";
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground font-sans text-base;
|
||||
font-synthesis-weight: none;
|
||||
}
|
||||
|
||||
/* Focus ring — consistent across all interactive elements */
|
||||
:where(button, a, [role="button"], input, select, textarea, [tabindex]):not(
|
||||
[tabindex="-1"]
|
||||
):focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: hsl(var(--brand-500) / 0.25);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Scrollbars (Webkit) */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--border-strong));
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.6);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Headings default to display font with tight tracking */
|
||||
h1, h2, h3, h4 {
|
||||
@apply font-heading tracking-tight text-foreground;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Numeric tabular figures for tables/metrics */
|
||||
.tabular-nums,
|
||||
table,
|
||||
[data-tabular] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Surface helpers — use instead of bg-card/bg-background ad-hoc */
|
||||
.surface-1 {
|
||||
@apply bg-surface border border-border;
|
||||
}
|
||||
.surface-2 {
|
||||
@apply bg-surface-raised border border-border shadow-sm;
|
||||
}
|
||||
|
||||
/* Status pill — semantic colored chip */
|
||||
.pill {
|
||||
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-2xs font-medium;
|
||||
}
|
||||
.pill-success { @apply pill bg-success-subtle text-success border border-success-border; }
|
||||
.pill-warning { @apply pill bg-warning-subtle text-warning border border-warning-border; }
|
||||
.pill-danger { @apply pill bg-danger-subtle text-danger border border-danger-border; }
|
||||
.pill-info { @apply pill bg-info-subtle text-info border border-info-border; }
|
||||
.pill-muted { @apply pill bg-muted text-muted-foreground border border-border; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user