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

View File

@@ -0,0 +1,74 @@
import * as React from "react";
import { ErrorState } from "@/components/ui/error-state";
interface ErrorBoundaryState {
error: Error | null;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
/** Render prop for full custom fallback */
fallback?: (error: Error, reset: () => void) => React.ReactNode;
/** Reset boundary when any value in this array changes (like `useEffect` deps) */
resetKeys?: unknown[];
/** Called once when a render error is caught */
onError?: (error: Error, info: React.ErrorInfo) => void;
}
/**
* Catches render-time errors anywhere below it and shows a fallback UI.
* Wrap the whole app at the root (catches everything) and optionally
* wrap individual routes/sections for finer-grained recovery.
*
* Note: does NOT catch errors inside event handlers, async code, or
* server-side rendering. For those, surface errors via React Query
* `onError` or `toast.error()`.
*/
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.props.onError?.(error, info);
// eslint-disable-next-line no-console
console.error("[ErrorBoundary]", error, info);
}
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (!this.state.error) return;
const prev = prevProps.resetKeys ?? [];
const next = this.props.resetKeys ?? [];
if (
prev.length !== next.length ||
prev.some((v, i) => !Object.is(v, next[i]))
) {
this.reset();
}
}
reset = () => this.setState({ error: null });
render() {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.fallback) return this.props.fallback(error, this.reset);
return (
<div className="mx-auto max-w-3xl px-6 py-16">
<ErrorState
severity="error"
title="Something went wrong"
description="An unexpected error broke this view. You can try reloading just this section, or refresh the page."
detail={error.stack ?? error.message}
onRetry={this.reset}
/>
</div>
);
}
}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo, useState } from "react";
import {
matchPath,
NavLink,
@@ -7,69 +7,39 @@ import {
useNavigate,
} from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useSelector, useDispatch } from "react-redux";
import {
LayoutDashboard,
UploadCloud,
Settings2,
Activity,
Brain,
Code2,
GitBranch,
Network,
ListChecks,
Menu,
SearchCheck,
ShieldCheck,
ClipboardCheck,
Layers3,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Command,
HelpCircle,
Search,
Sparkles,
} from "lucide-react";
import { RootState } from "@/stores";
import { toggleSidebar } from "@/stores/slices/uiSlice";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useProjects } from "@/hooks/useProjects";
interface NavItem {
to?: string;
projectPath?: string;
labelKey: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{ to: "/", labelKey: "nav.dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/onboard", labelKey: "nav.onboard", label: "New Project", icon: UploadCloud },
{ projectPath: "sources", labelKey: "nav.sources", label: "Source Explorer", icon: Settings2 },
{ projectPath: "crawl", labelKey: "nav.crawl", label: "Seed Crawl", icon: Activity },
{ projectPath: "pipeline", labelKey: "nav.pipeline", label: "Build Pipeline", icon: Layers3 },
{ projectPath: "analysis", labelKey: "nav.analysis", label: "Page Analysis", icon: SearchCheck },
{ projectPath: "schema", labelKey: "nav.schema", label: "Schema Designer", icon: ShieldCheck },
{ projectPath: "editor", labelKey: "nav.editor", label: "Entity Manager", icon: Network },
{ projectPath: "review", labelKey: "nav.review", label: "Claim Review", icon: ListChecks },
{ projectPath: "quality", labelKey: "nav.quality", label: "Quality Inspector", icon: ClipboardCheck },
{ projectPath: "graph", labelKey: "nav.graph", label: "Graph View", icon: GitBranch },
{ projectPath: "export", labelKey: "nav.export", label: "Export / API", icon: Code2 },
{ projectPath: "research", labelKey: "nav.research", label: "Graph Research", icon: Brain },
];
const projectRoutePatterns = [
"/sources/:projectId",
"/crawl/:projectId",
"/pipeline/:projectId",
"/analysis/:projectId",
"/schema/:projectId",
"/research/:projectId",
"/editor/:projectId",
"/review/:projectId",
"/quality/:projectId",
"/graph/:projectId",
"/export/:projectId",
];
import { useThemeSync } from "@/hooks/useTheme";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { ThemeToggle } from "@/components/layout/ThemeToggle";
import {
ShortcutsOverlay,
type ShortcutGroup,
} from "@/components/ui/shortcuts-overlay";
import { Tooltip } from "@/components/ui/tooltip";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { NAV_GROUPS, PROJECT_ROUTE_PATTERNS, type NavItem } from "./navigation";
import {
CommandPalette,
useCommandPaletteShortcut,
} from "./CommandPalette";
function projectIdFromPathname(pathname: string): string | undefined {
for (const pattern of projectRoutePatterns) {
for (const pattern of PROJECT_ROUTE_PATTERNS) {
const match = matchPath({ path: pattern, end: false }, pathname);
if (match?.params.projectId) return match.params.projectId;
}
@@ -77,7 +47,7 @@ function projectIdFromPathname(pathname: string): string | undefined {
}
function workspaceSectionFromPathname(pathname: string): string | undefined {
for (const pattern of projectRoutePatterns) {
for (const pattern of PROJECT_ROUTE_PATTERNS) {
const match = matchPath({ path: pattern, end: false }, pathname);
if (match?.params.projectId) return pattern.split("/")[1];
}
@@ -85,11 +55,17 @@ function workspaceSectionFromPathname(pathname: string): string | undefined {
}
export default function AppShell() {
useThemeSync();
const { t } = useTranslation();
const sidebarOpen = useSelector((s: RootState) => s.ui.sidebarOpen);
const dispatch = useDispatch();
const location = useLocation();
const navigate = useNavigate();
const [paletteOpen, setPaletteOpen] = useState(false);
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
const [chordLeader, setChordLeader] = useState<string | null>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const routeProjectId = projectIdFromPathname(location.pathname);
const workspaceSection = workspaceSectionFromPathname(location.pathname);
const { data: projects } = useProjects();
@@ -99,9 +75,58 @@ export default function AppShell() {
!projects ||
projects.some((project) => project.name === routeProjectId);
const currentProjectId = routeProjectExists
? routeProjectId ?? fallbackProjectId
? (routeProjectId ?? fallbackProjectId)
: fallbackProjectId;
useCommandPaletteShortcut(() => setPaletteOpen((v) => !v));
// Build chord bindings from NAV_GROUPS (e.g. "g d" -> /, "g s" -> /sources/...).
const shortcutBindings = useMemo(() => {
const bindings: Record<string, () => void> = {};
const collectItem = (item: NavItem) => {
if (!item.shortcut) return;
const href =
item.to ??
(item.projectPath && currentProjectId
? `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`
: undefined);
if (!href) return;
bindings[item.shortcut] = () => navigate(href);
};
NAV_GROUPS.forEach((g) => g.items.forEach(collectItem));
bindings["/"] = () => setPaletteOpen(true);
bindings["Shift+?"] = () => setShortcutsOpen(true);
return bindings;
}, [currentProjectId, navigate]);
useKeyboardShortcuts(shortcutBindings, {
disabled: paletteOpen || shortcutsOpen,
onChordStart: (leader) => setChordLeader(leader),
onChordEnd: () => setChordLeader(null),
});
// Build groups for the ShortcutsOverlay (memoized)
const shortcutGroups = useMemo<ShortcutGroup[]>(() => {
const navGroups: ShortcutGroup[] = NAV_GROUPS.map((g) => ({
title: g.label,
items: g.items
.filter((i) => i.shortcut)
.map((i) => ({ keys: i.shortcut!, label: i.label })),
})).filter((g) => g.items.length > 0);
return [
...navGroups,
{
title: "Global",
items: [
{ keys: "Mod+K", label: "Open command palette" },
{ keys: "/", label: "Open command palette" },
{ keys: "Shift+?", label: "Show this shortcuts panel" },
{ keys: "Escape", label: "Close any open dialog" },
],
},
];
}, []);
useEffect(() => {
if (routeProjectId && projects && !routeProjectExists) {
const nextPath =
@@ -119,98 +144,362 @@ export default function AppShell() {
workspaceSection,
]);
const switchProject = (projectName: string) => {
setProjectMenuOpen(false);
const section = workspaceSection ?? "sources";
navigate(`/${section}/${encodeURIComponent(projectName)}`);
};
// Detect macOS to show ⌘ vs Ctrl
const isMac =
typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const modKey = isMac ? "⌘" : "Ctrl";
return (
<div className="flex min-h-screen bg-background text-foreground">
<aside
className={cn(
"border-r bg-card transition-all duration-200 ease-out",
sidebarOpen ? "w-60" : "w-16",
)}
>
<div className="flex h-14 items-center justify-between px-4 border-b">
{sidebarOpen && (
<span className="text-sm font-semibold">
{t("app.title", "Ontology Builder")}
</span>
)}
<Button
variant="ghost"
size="icon"
onClick={() => dispatch(toggleSidebar())}
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
<Sidebar
sidebarOpen={sidebarOpen}
currentProjectId={currentProjectId ?? null}
onToggle={() => dispatch(toggleSidebar())}
t={t}
/>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-surface/80 px-4 backdrop-blur-md">
<ProjectSwitcher
projects={(projects ?? []).map((p) => p.name)}
currentProjectId={currentProjectId ?? null}
open={projectMenuOpen}
onOpenChange={setProjectMenuOpen}
onSelect={switchProject}
t={t}
/>
<button
type="button"
onClick={() => setPaletteOpen(true)}
className="group flex h-9 max-w-md flex-1 items-center gap-2 rounded-md border border-border bg-background-subtle px-3 text-sm text-muted-foreground transition-colors hover:border-border-strong hover:text-foreground"
aria-label={t("nav.openCommandPalette", "Open command palette")}
>
<Menu className="h-4 w-4" />
</Button>
</div>
<Search className="h-4 w-4" />
<span className="flex-1 text-left">
{t("nav.searchPlaceholder", "Search pages, projects…")}
</span>
<kbd className="hidden gap-0.5 rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-2xs text-muted-foreground sm:inline-flex">
{modKey} K
</kbd>
</button>
{sidebarOpen && (
<div className="border-b px-4 py-3">
<p className="text-xs text-muted-foreground">
{t("nav.currentProject", "Current project")}
</p>
<p className="mt-1 truncate text-sm font-medium">
{currentProjectId ??
t("nav.noProjectSelected", "Select a project")}
</p>
</div>
)}
<nav className="flex flex-col gap-1 p-2">
{navItems.map(({ to, projectPath, labelKey, label, icon: Icon }) => {
const href =
to ??
(currentProjectId
? `/${projectPath}/${encodeURIComponent(currentProjectId)}`
: undefined);
if (!href) {
return (
<div
key={labelKey}
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground/50"
title={t(
"nav.selectProjectFirst",
"Select a project from the dashboard first",
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && <span>{t(labelKey, label)}</span>}
</div>
);
}
return (
<NavLink
key={labelKey}
to={href}
end={href === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)
}
<div className="ml-auto flex items-center gap-1">
<Tooltip content={t("nav.help", "Keyboard shortcuts")} shortcut="?">
<Button
variant="ghost"
size="icon"
aria-label={t("nav.help", "Keyboard shortcuts")}
onClick={() => setShortcutsOpen(true)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && <span>{t(labelKey, label)}</span>}
</NavLink>
);
})}
</nav>
</aside>
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
<h1 className="text-sm font-medium text-muted-foreground">
{t("app.subtitle", "AI-powered ontology construction")}
</h1>
<HelpCircle className="h-4 w-4" />
</Button>
</Tooltip>
<ThemeToggle />
<div className="ml-2 h-7 w-px bg-border" aria-hidden />
<span
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700 dark:bg-brand-900 dark:text-brand-200"
title={t("nav.account", "Account")}
>
ON
</span>
</div>
</header>
<main className="flex-1 overflow-auto">
<Outlet />
<main className="min-w-0 flex-1 overflow-auto">
<ErrorBoundary resetKeys={[location.pathname]}>
<Outlet />
</ErrorBoundary>
</main>
</div>
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
currentProjectId={currentProjectId ?? null}
/>
<ShortcutsOverlay
open={shortcutsOpen}
onOpenChange={setShortcutsOpen}
groups={shortcutGroups}
/>
<ChordHint leader={chordLeader} />
</div>
);
}
function ChordHint({ leader }: { leader: string | null }) {
if (!leader) return null;
return (
<div
className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2 animate-fade-in-up"
role="status"
aria-live="polite"
>
<div className="flex items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg">
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-2xs">
{leader}
</kbd>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">awaiting follower key</span>
</div>
</div>
);
}
/* ============================================================
* Sidebar
* ========================================================== */
function Sidebar({
sidebarOpen,
currentProjectId,
onToggle,
t,
}: {
sidebarOpen: boolean;
currentProjectId: string | null;
onToggle: () => void;
t: TFunction;
}) {
return (
<aside
className={cn(
"sticky top-0 flex h-screen flex-col border-r border-border bg-surface transition-[width] duration-fast ease-out-expo",
sidebarOpen ? "w-64" : "w-[60px]",
)}
>
<div className="flex h-14 items-center gap-2 border-b border-border px-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-brand-600 text-white shadow-sm">
<Sparkles className="h-4 w-4" />
</div>
{sidebarOpen && (
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">
{t("app.title", "Ontology Builder")}
</p>
<p className="truncate text-2xs text-muted-foreground">
{t("app.subtitle", "AI-powered ontology construction")}
</p>
</div>
)}
<Button
variant="ghost"
size="icon"
onClick={onToggle}
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
className="h-7 w-7"
>
{sidebarOpen ? (
<ChevronsLeft className="h-4 w-4" />
) : (
<ChevronsRight className="h-4 w-4" />
)}
</Button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{NAV_GROUPS.map((group) => (
<div key={group.id} className="px-2 pb-2">
{sidebarOpen && (
<p className="px-3 pb-1 pt-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(group.labelKey, group.label)}
</p>
)}
<ul className="flex flex-col gap-0.5">
{group.items.map((item) => {
const href =
item.to ??
(item.projectPath && currentProjectId
? `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`
: undefined);
const Icon = item.icon;
if (!href) {
return (
<li key={item.id}>
<div
className={cn(
"flex cursor-not-allowed items-center gap-2.5 rounded-md px-3 py-2 text-sm text-muted-foreground/40",
!sidebarOpen && "justify-center px-2",
)}
title={t(
"nav.selectProjectFirst",
"Select a project from the dashboard first",
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && (
<span className="truncate">
{t(item.labelKey, item.label)}
</span>
)}
</div>
</li>
);
}
return (
<li key={item.id}>
<NavLink
to={href}
end={href === "/"}
title={
!sidebarOpen ? t(item.labelKey, item.label) : undefined
}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-2.5 rounded-md px-3 py-2 text-sm transition-colors",
!sidebarOpen && "justify-center px-2",
isActive
? "bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-200"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)
}
>
{({ isActive }) => (
<>
{isActive && (
<span
aria-hidden
className="absolute inset-y-1 left-0 w-0.5 rounded-r-full bg-brand-600 dark:bg-brand-400"
/>
)}
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && (
<>
<span className="flex-1 truncate">
{t(item.labelKey, item.label)}
</span>
{item.shortcut && (
<kbd className="ml-1 hidden flex-shrink-0 rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-2xs text-muted-foreground group-hover:inline-flex">
{item.shortcut}
</kbd>
)}
</>
)}
</>
)}
</NavLink>
</li>
);
})}
</ul>
</div>
))}
</nav>
{sidebarOpen && (
<div className="border-t border-border p-3 text-2xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>v0.2.0</span>
<span className="pill-info">beta</span>
</div>
</div>
)}
</aside>
);
}
/* ============================================================
* Project Switcher (header dropdown)
* ========================================================== */
function ProjectSwitcher({
projects,
currentProjectId,
open,
onOpenChange,
onSelect,
t,
}: {
projects: string[];
currentProjectId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (name: string) => void;
t: TFunction;
}) {
const ref = useMemo(() => ({ current: null as HTMLDivElement | null }), []);
useEffect(() => {
if (!open) return;
const onClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
onOpenChange(false);
}
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [open, onOpenChange, ref]);
return (
<div className="relative" ref={(node) => (ref.current = node)}>
<button
type="button"
onClick={() => onOpenChange(!open)}
className="flex h-9 items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent"
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="flex h-5 w-5 items-center justify-center rounded bg-brand-100 text-2xs font-bold text-brand-700 dark:bg-brand-900 dark:text-brand-200">
{(currentProjectId ?? "—").slice(0, 1).toUpperCase()}
</span>
<span className="max-w-[160px] truncate">
{currentProjectId ?? t("nav.noProjectSelected", "Select project")}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
{open && (
<div
role="listbox"
className="absolute left-0 top-full z-40 mt-1.5 w-72 overflow-hidden rounded-lg border border-border bg-popover shadow-lg animate-fade-in-up"
>
<div className="border-b border-border px-3 py-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
{t("nav.projects", "Projects")}
</div>
<ul className="max-h-72 overflow-y-auto py-1">
{projects.length === 0 && (
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
{t("nav.noProjects", "No projects yet.")}
</li>
)}
{projects.map((name) => (
<li key={name}>
<button
type="button"
onClick={() => onSelect(name)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
name === currentProjectId
? "bg-accent text-foreground"
: "text-foreground hover:bg-accent/60",
)}
role="option"
aria-selected={name === currentProjectId}
>
<span className="flex h-5 w-5 items-center justify-center rounded bg-brand-100 text-2xs font-bold text-brand-700 dark:bg-brand-900 dark:text-brand-200">
{name.slice(0, 1).toUpperCase()}
</span>
<span className="truncate">{name}</span>
</button>
</li>
))}
</ul>
<div className="flex items-center justify-between border-t border-border bg-background-subtle px-3 py-2 text-2xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Command className="h-3 w-3" />
Press <kbd className="rounded border border-border bg-surface px-1 py-0.5">K</kbd> for palette
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,291 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { Search, FolderKanban, Sun, Moon, Monitor, CornerDownLeft } from "lucide-react";
import { useDispatch, useSelector } from "react-redux";
import { Dialog, DialogPanel } from "@/components/ui/dialog";
import { useProjects } from "@/hooks/useProjects";
import { NAV_GROUPS, type NavItem } from "./navigation";
import { cn } from "@/lib/utils";
import { cycleTheme, setTheme } from "@/stores/slices/uiSlice";
import type { RootState } from "@/stores";
type CommandItem = {
id: string;
group: string;
label: string;
hint?: string;
keywords?: string[];
icon: React.ComponentType<{ className?: string }>;
onSelect: () => void;
};
function score(query: string, item: CommandItem): number {
if (!query) return 1;
const q = query.toLowerCase();
const haystack = [item.label, item.group, ...(item.keywords ?? [])]
.join(" ")
.toLowerCase();
if (haystack.includes(q)) return 2;
// fuzzy: every char in q appears in order
let pos = 0;
for (const ch of q) {
pos = haystack.indexOf(ch, pos);
if (pos === -1) return 0;
pos++;
}
return 1;
}
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Current project to use for project-scoped nav items */
currentProjectId?: string | null;
}
export function CommandPalette({
open,
onOpenChange,
currentProjectId,
}: CommandPaletteProps) {
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useSelector((s: RootState) => s.ui.theme);
const { data: projects } = useProjects();
const [query, setQuery] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const listRef = React.useRef<HTMLDivElement>(null);
const navHref = (item: NavItem): string | undefined => {
if (item.to) return item.to;
if (item.projectPath && currentProjectId) {
return `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`;
}
return undefined;
};
const close = React.useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const commands = React.useMemo<CommandItem[]>(() => {
const list: CommandItem[] = [];
NAV_GROUPS.forEach((group) => {
group.items.forEach((item) => {
const href = navHref(item);
list.push({
id: `nav:${item.id}`,
group: group.label,
label: item.label,
hint: item.shortcut,
keywords: item.keywords,
icon: item.icon,
onSelect: () => {
if (href) navigate(href);
close();
},
});
});
});
(projects ?? []).slice(0, 12).forEach((project) => {
list.push({
id: `project:${project.name}`,
group: "Projects",
label: project.name,
keywords: ["switch", "open", "project"],
icon: FolderKanban,
onSelect: () => {
navigate(`/sources/${encodeURIComponent(project.name)}`);
close();
},
});
});
const themeOpts: Array<{
mode: "light" | "dark" | "system";
label: string;
icon: React.ComponentType<{ className?: string }>;
}> = [
{ mode: "light", label: "Switch to Light theme", icon: Sun },
{ mode: "dark", label: "Switch to Dark theme", icon: Moon },
{ mode: "system", label: "Use System theme", icon: Monitor },
];
themeOpts.forEach((opt) => {
list.push({
id: `theme:${opt.mode}`,
group: "Settings",
label: opt.label,
keywords: ["theme", "dark", "light", "mode", opt.mode],
icon: opt.icon,
onSelect: () => {
dispatch(setTheme(opt.mode));
close();
},
});
});
list.push({
id: "theme:cycle",
group: "Settings",
label: `Cycle theme (current: ${theme})`,
keywords: ["theme", "toggle"],
icon: Sun,
onSelect: () => {
dispatch(cycleTheme());
close();
},
});
return list;
}, [projects, navigate, currentProjectId, dispatch, close, theme]);
const filtered = React.useMemo(() => {
const scored = commands
.map((cmd) => ({ cmd, s: score(query, cmd) }))
.filter((x) => x.s > 0);
scored.sort((a, b) => b.s - a.s);
return scored.map((x) => x.cmd);
}, [commands, query]);
React.useEffect(() => {
if (open) {
setQuery("");
setActiveIndex(0);
}
}, [open]);
React.useEffect(() => {
setActiveIndex(0);
}, [query]);
React.useEffect(() => {
const el = listRef.current?.querySelector<HTMLElement>(
`[data-cmd-index="${activeIndex}"]`,
);
el?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (event.key === "Enter") {
event.preventDefault();
filtered[activeIndex]?.onSelect();
}
};
// Group by section preserving filtered order
const grouped: Array<{ group: string; items: CommandItem[] }> = [];
filtered.forEach((cmd) => {
const last = grouped[grouped.length - 1];
if (last && last.group === cmd.group) last.items.push(cmd);
else grouped.push({ group: cmd.group, items: [cmd] });
});
let runningIndex = -1;
return (
<Dialog open={open} onOpenChange={onOpenChange} ariaLabel="Command palette">
<DialogPanel className="mx-auto mt-20 max-w-xl">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Search className="h-4 w-4 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search pages, projects, settings…"
aria-label="Command palette input"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 text-2xs text-muted-foreground">
ESC
</kbd>
</div>
<div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1.5">
{filtered.length === 0 && (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</p>
)}
{grouped.map((section) => (
<div key={section.group} className="px-1.5">
<div className="px-3 pb-1 pt-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
{section.group}
</div>
{section.items.map((cmd) => {
runningIndex += 1;
const isActive = runningIndex === activeIndex;
const Icon = cmd.icon;
return (
<button
key={cmd.id}
data-cmd-index={runningIndex}
type="button"
onMouseEnter={() => setActiveIndex(runningIndex)}
onClick={cmd.onSelect}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm",
isActive
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-accent/50",
)}
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{cmd.label}</span>
{cmd.hint && (
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 text-2xs text-muted-foreground">
{cmd.hint}
</kbd>
)}
{isActive && (
<CornerDownLeft className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
);
})}
</div>
))}
</div>
<div className="flex items-center justify-between border-t border-border bg-background-subtle px-4 py-2 text-2xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5"></kbd>
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5"></kbd>
navigate
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5"></kbd>
select
</span>
</div>
<span>{filtered.length} results</span>
</div>
</DialogPanel>
</Dialog>
);
}
/**
* Global hook — binds ⌘K / Ctrl+K to toggle the palette.
*/
export function useCommandPaletteShortcut(onToggle: () => void) {
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
const isMod = event.metaKey || event.ctrlKey;
if (isMod && event.key.toLowerCase() === "k") {
event.preventDefault();
onToggle();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onToggle]);
}

View File

@@ -0,0 +1,129 @@
import * as React from "react";
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface BreadcrumbItem {
label: React.ReactNode;
to?: string;
}
interface PageHeaderProps {
/** Optional eyebrow / breadcrumb trail. The last item is rendered as plain text. */
breadcrumbs?: BreadcrumbItem[];
title: React.ReactNode;
/** Optional icon shown left of the title */
icon?: React.ComponentType<{ className?: string }>;
description?: React.ReactNode;
/** Pills/badges rendered next to the title (e.g. status, environment) */
meta?: React.ReactNode;
/** Buttons / dropdowns on the right */
actions?: React.ReactNode;
/** Secondary nav (tabs) row */
tabs?: React.ReactNode;
className?: string;
}
export function PageHeader({
breadcrumbs,
title,
icon: Icon,
description,
meta,
actions,
tabs,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
"border-b border-border bg-surface",
className,
)}
>
<div className="mx-auto max-w-7xl px-6 pb-4 pt-5">
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs items={breadcrumbs} className="mb-2" />
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2.5">
{Icon && (
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
<Icon className="h-5 w-5" />
</span>
)}
<h1 className="truncate text-2xl font-semibold tracking-tight text-foreground">
{title}
</h1>
{meta && <div className="flex items-center gap-2">{meta}</div>}
</div>
{description && (
<p className="mt-1.5 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{actions && (
<div className="flex flex-shrink-0 items-center gap-2">
{actions}
</div>
)}
</div>
</div>
{tabs && (
<div className="mx-auto max-w-7xl px-6">
<div className="flex items-center gap-1 overflow-x-auto">{tabs}</div>
</div>
)}
</div>
);
}
export function Breadcrumbs({
items,
className,
}: {
items: BreadcrumbItem[];
className?: string;
}) {
return (
<nav
aria-label="Breadcrumb"
className={cn("flex items-center text-xs text-muted-foreground", className)}
>
<ol className="flex flex-wrap items-center gap-1">
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<li key={idx} className="flex items-center gap-1">
{item.to && !isLast ? (
<Link
to={item.to}
className="rounded px-1 hover:text-foreground hover:underline underline-offset-2"
>
{item.label}
</Link>
) : (
<span
className={cn(
"px-1",
isLast && "font-medium text-foreground",
)}
aria-current={isLast ? "page" : undefined}
>
{item.label}
</span>
)}
{!isLast && (
<ChevronRight className="h-3 w-3 flex-shrink-0 opacity-60" />
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -0,0 +1,37 @@
import { useDispatch, useSelector } from "react-redux";
import { Moon, Sun, Monitor } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cycleTheme, type ThemeMode } from "@/stores/slices/uiSlice";
import type { RootState } from "@/stores";
const ICONS: Record<ThemeMode, React.ComponentType<{ className?: string }>> = {
light: Sun,
dark: Moon,
system: Monitor,
};
const LABELS: Record<ThemeMode, string> = {
light: "Light",
dark: "Dark",
system: "System",
};
export function ThemeToggle() {
const { t } = useTranslation();
const dispatch = useDispatch();
const mode = useSelector((s: RootState) => s.ui.theme);
const Icon = ICONS[mode];
return (
<Button
variant="ghost"
size="icon"
onClick={() => dispatch(cycleTheme())}
aria-label={t("theme.toggle", "Switch theme") + ` (${LABELS[mode]})`}
title={`${t("theme.label", "Theme")}: ${LABELS[mode]}`}
>
<Icon className="h-4 w-4" />
</Button>
);
}

View File

@@ -0,0 +1,195 @@
import type { ComponentType } from "react";
import {
Activity,
Brain,
ClipboardCheck,
Code2,
GitBranch,
Layers3,
LayoutDashboard,
ListChecks,
Network,
SearchCheck,
Settings2,
ShieldCheck,
UploadCloud,
} from "lucide-react";
export type NavGroupId = "workspace" | "build" | "review" | "publish";
export interface NavItem {
/** Stable identifier — used as React key and palette command id */
id: string;
/** i18n key */
labelKey: string;
/** Default label (English) */
label: string;
/** Static route — mutually exclusive with projectPath */
to?: string;
/** Path segment under /:projectId — joined as `/${projectPath}/${projectId}` */
projectPath?: string;
icon: ComponentType<{ className?: string }>;
/** Search keywords for the command palette (in addition to label) */
keywords?: string[];
/** Optional shortcut hint shown next to the item */
shortcut?: string;
}
export interface NavGroup {
id: NavGroupId;
labelKey: string;
label: string;
items: NavItem[];
}
export const NAV_GROUPS: NavGroup[] = [
{
id: "workspace",
labelKey: "navGroup.workspace",
label: "Workspace",
items: [
{
id: "dashboard",
to: "/",
labelKey: "nav.dashboard",
label: "Dashboard",
icon: LayoutDashboard,
keywords: ["home", "overview"],
shortcut: "g d",
},
{
id: "onboard",
to: "/onboard",
labelKey: "nav.onboard",
label: "New Project",
icon: UploadCloud,
keywords: ["create", "start", "wizard"],
shortcut: "g n",
},
],
},
{
id: "build",
labelKey: "navGroup.build",
label: "Build",
items: [
{
id: "sources",
projectPath: "sources",
labelKey: "nav.sources",
label: "Source Explorer",
icon: Settings2,
keywords: ["urls", "domains", "seed"],
shortcut: "g s",
},
{
id: "crawl",
projectPath: "crawl",
labelKey: "nav.crawl",
label: "Seed Crawl",
icon: Activity,
keywords: ["scrape", "fetch"],
shortcut: "g c",
},
{
id: "pipeline",
projectPath: "pipeline",
labelKey: "nav.pipeline",
label: "Build Pipeline",
icon: Layers3,
keywords: ["jobs", "run", "extract"],
shortcut: "g p",
},
{
id: "analysis",
projectPath: "analysis",
labelKey: "nav.analysis",
label: "Page Analysis",
icon: SearchCheck,
keywords: ["html", "structure"],
shortcut: "g a",
},
{
id: "schema",
projectPath: "schema",
labelKey: "nav.schema",
label: "Schema Designer",
icon: ShieldCheck,
keywords: ["types", "predicate", "model"],
shortcut: "g t",
},
{
id: "research",
projectPath: "research",
labelKey: "nav.research",
label: "Graph Research",
icon: Brain,
keywords: ["graphrag", "query", "ai", "autonomous"],
shortcut: "g r",
},
],
},
{
id: "review",
labelKey: "navGroup.review",
label: "Review",
items: [
{
id: "editor",
projectPath: "editor",
labelKey: "nav.editor",
label: "Entity Manager",
icon: Network,
keywords: ["entities", "nodes", "merge"],
shortcut: "g e",
},
{
id: "review",
projectPath: "review",
labelKey: "nav.review",
label: "Claim Review",
icon: ListChecks,
keywords: ["validate", "approve", "queue"],
shortcut: "g v",
},
{
id: "quality",
projectPath: "quality",
labelKey: "nav.quality",
label: "Quality Inspector",
icon: ClipboardCheck,
keywords: ["metrics", "qa", "audit"],
shortcut: "g q",
},
{
id: "graph",
projectPath: "graph",
labelKey: "nav.graph",
label: "Graph View",
icon: GitBranch,
keywords: ["visualization", "network"],
shortcut: "g g",
},
],
},
{
id: "publish",
labelKey: "navGroup.publish",
label: "Publish",
items: [
{
id: "export",
projectPath: "export",
labelKey: "nav.export",
label: "Export / API",
icon: Code2,
keywords: ["download", "rest", "json"],
shortcut: "g x",
},
],
},
];
export const PROJECT_ROUTE_PATTERNS = NAV_GROUPS.flatMap((g) =>
g.items.filter((i) => i.projectPath).map((i) => `/${i.projectPath}/:projectId`),
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
import * as React from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Dialog primitive — thin styled wrapper over @radix-ui/react-dialog.
* Radix handles focus trap, scroll lock, Escape, focus restoration, and ARIA.
*
* The exported API mirrors the previous custom implementation so consumers
* (Drawer, CommandPalette, ShortcutsOverlay) didn't need restructuring:
*
* <Dialog open onOpenChange>
* <DialogPanel ariaLabel="…">
* <DialogHeader onClose>...</DialogHeader>
* <DialogBody>...</DialogBody>
* <DialogFooter>...</DialogFooter>
* </DialogPanel>
* </Dialog>
*/
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
/** Compatibility shim — ariaLabel is now supplied on DialogPanel directly. */
ariaLabel?: string;
/** Block close from overlay click */
closeOnOverlayClick?: boolean;
/** Block close from Escape */
closeOnEscape?: boolean;
}
const DialogCtx = React.createContext<{
ariaLabel?: string;
closeOnOverlayClick: boolean;
closeOnEscape: boolean;
}>({ closeOnOverlayClick: true, closeOnEscape: true });
export function Dialog({
open,
onOpenChange,
children,
ariaLabel,
closeOnOverlayClick = true,
closeOnEscape = true,
}: DialogProps) {
return (
<DialogCtx.Provider
value={{ ariaLabel, closeOnOverlayClick, closeOnEscape }}
>
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
{children}
</RadixDialog.Root>
</DialogCtx.Provider>
);
}
export const DialogTrigger = RadixDialog.Trigger;
export const DialogClose = RadixDialog.Close;
interface DialogPanelProps extends React.HTMLAttributes<HTMLDivElement> {
/** Accessible label when the panel has no DialogTitle */
ariaLabel?: string;
/** Accessible description id reference */
ariaDescribedBy?: string;
}
export const DialogPanel = React.forwardRef<HTMLDivElement, DialogPanelProps>(
({ className, children, ariaLabel, ariaDescribedBy, ...props }, ref) => {
const ctx = React.useContext(DialogCtx);
const label = ariaLabel ?? ctx.ariaLabel;
return (
<RadixDialog.Portal>
<RadixDialog.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-fade-in",
)}
/>
<RadixDialog.Content
ref={ref}
aria-label={label}
aria-describedby={ariaDescribedBy}
onInteractOutside={(e) => {
if (!ctx.closeOnOverlayClick) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (!ctx.closeOnEscape) e.preventDefault();
}}
className={cn(
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
"flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden",
"rounded-xl border border-border bg-surface-raised shadow-xl outline-none",
"data-[state=open]:animate-fade-in-up",
className,
)}
{...props}
>
{/* Radix warns if no Title is present. If consumer didn't provide one,
expose ariaLabel via VisuallyHidden Title as a fallback. */}
{label && !hasDialogTitle(children) && (
<RadixDialog.Title className="sr-only">{label}</RadixDialog.Title>
)}
{children}
</RadixDialog.Content>
</RadixDialog.Portal>
);
},
);
DialogPanel.displayName = "DialogPanel";
/** Best-effort detection: does the children tree include a <DialogTitle>? */
function hasDialogTitle(node: React.ReactNode): boolean {
let found = false;
React.Children.forEach(node, (child) => {
if (found) return;
if (!React.isValidElement(child)) return;
if (child.type === DialogTitle) {
found = true;
return;
}
const subtree = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (subtree && hasDialogTitle(subtree)) found = true;
});
return found;
}
export function DialogHeader({
className,
children,
onClose,
...props
}: React.HTMLAttributes<HTMLDivElement> & { onClose?: () => void }) {
return (
<div
className={cn(
"flex items-start justify-between gap-4 border-b border-border px-5 py-4",
className,
)}
{...props}
>
<div className="min-w-0 flex-1">{children}</div>
{onClose && (
<RadixDialog.Close asChild>
<button
type="button"
onClick={onClose}
className="-mr-2 -mt-1 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</RadixDialog.Close>
)}
</div>
);
}
export const DialogTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<RadixDialog.Title asChild>
<h2
ref={ref}
className={cn("text-base font-semibold text-foreground", className)}
{...props}
/>
</RadixDialog.Title>
));
DialogTitle.displayName = "DialogTitle";
export const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<RadixDialog.Description asChild>
<p
ref={ref}
className={cn("mt-1 text-sm text-muted-foreground", className)}
{...props}
/>
</RadixDialog.Description>
));
DialogDescription.displayName = "DialogDescription";
export function DialogBody({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex-1 overflow-y-auto px-5 py-4", className)}
{...props}
/>
);
}
export function DialogFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-row-reverse items-center gap-2 border-t border-border px-5 py-3",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,143 @@
import * as React from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
type Side = "right" | "left";
type Size = "sm" | "md" | "lg" | "xl";
const SIZE_CLASS: Record<Size, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-2xl",
xl: "max-w-4xl",
};
interface DrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: Side;
size?: Size;
title?: React.ReactNode;
description?: React.ReactNode;
footer?: React.ReactNode;
children?: React.ReactNode;
/** Slot rendered on the header right (e.g. tabs, badges, secondary actions) */
headerExtra?: React.ReactNode;
className?: string;
/** Block close when clicking the overlay */
closeOnOverlayClick?: boolean;
}
/**
* Slide-in drawer using Radix Dialog. Comes with focus trap, scroll lock,
* Escape close, and ARIA wiring. Use for entity editors, claim reviews,
* detail inspectors — anything that benefits from preserving page context.
*/
export function Drawer({
open,
onOpenChange,
side = "right",
size = "lg",
title,
description,
footer,
children,
headerExtra,
className,
closeOnOverlayClick = true,
}: DrawerProps) {
const slideOpen =
side === "right"
? "data-[state=open]:animate-slide-in-right"
: "data-[state=open]:animate-fade-in-up";
const sidePos = side === "right" ? "right-0" : "left-0";
const borderSide = side === "right" ? "border-l" : "border-r";
const titleId = React.useId();
const descId = React.useId();
return (
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
<RadixDialog.Portal>
<RadixDialog.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-fade-in",
)}
/>
<RadixDialog.Content
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descId : undefined}
onInteractOutside={(e) => {
if (!closeOnOverlayClick) e.preventDefault();
}}
className={cn(
"fixed top-0 bottom-0 z-50 flex w-full flex-col",
"bg-surface-raised border-border shadow-2xl outline-none",
sidePos,
borderSide,
SIZE_CLASS[size],
slideOpen,
className,
)}
>
{(title || description || headerExtra) && (
<header className="flex items-start justify-between gap-4 border-b border-border px-6 py-4">
<div className="min-w-0 flex-1">
{title && (
<RadixDialog.Title asChild>
<h2
id={titleId}
className="truncate text-base font-semibold text-foreground"
>
{title}
</h2>
</RadixDialog.Title>
)}
{!title && (
// Radix requires a Title for accessibility; provide a hidden one
<RadixDialog.Title className="sr-only">
Panel
</RadixDialog.Title>
)}
{description && (
<RadixDialog.Description asChild>
<p
id={descId}
className="mt-1 text-sm text-muted-foreground"
>
{description}
</p>
</RadixDialog.Description>
)}
</div>
<div className="flex items-center gap-2">
{headerExtra}
<RadixDialog.Close asChild>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close drawer"
>
<X className="h-4 w-4" />
</button>
</RadixDialog.Close>
</div>
</header>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{children}
</div>
{footer && (
<footer className="flex items-center justify-end gap-2 border-t border-border bg-background-subtle px-6 py-3">
{footer}
</footer>
)}
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}

View File

@@ -0,0 +1,71 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type Variant = "default" | "muted" | "danger" | "info";
interface EmptyStateProps {
icon?: React.ComponentType<{ className?: string }>;
title: React.ReactNode;
description?: React.ReactNode;
/** Primary call-to-action (usually a <Button>) */
primaryAction?: React.ReactNode;
/** Optional secondary action */
secondaryAction?: React.ReactNode;
/** Style variant — danger for errors, info for empty-by-design */
variant?: Variant;
/** Compact vertical padding for inline / panel usage */
compact?: boolean;
className?: string;
}
const ICON_BG: Record<Variant, string> = {
default: "bg-muted text-muted-foreground",
muted: "bg-muted text-muted-foreground",
danger: "bg-danger-subtle text-danger",
info: "bg-info-subtle text-info",
};
export function EmptyState({
icon: Icon,
title,
description,
primaryAction,
secondaryAction,
variant = "default",
compact = false,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-4 text-center",
compact ? "py-8" : "py-16",
className,
)}
role="status"
>
{Icon && (
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full",
ICON_BG[variant],
)}
>
<Icon className="h-6 w-6" />
</div>
)}
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{(primaryAction || secondaryAction) && (
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
{primaryAction}
{secondaryAction}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,150 @@
import * as React from "react";
import { AlertCircle, AlertTriangle, RefreshCw, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
type Severity = "error" | "warning" | "offline";
interface ErrorStateProps {
/** Visual severity */
severity?: Severity;
/** Short headline, e.g. "Could not load entities" */
title: React.ReactNode;
/** Longer explanation. Pass an `Error` and the message will be extracted. */
description?: React.ReactNode | Error;
/** Retry handler — adds a Retry button when provided */
onRetry?: () => void;
/** Disable the retry button (e.g. during a refetch) */
retrying?: boolean;
/** Inline vs full block layout */
inline?: boolean;
/** Compact vertical padding */
compact?: boolean;
/** Show technical details (stack) in a collapsible block */
detail?: string;
className?: string;
}
const ICON: Record<Severity, React.ComponentType<{ className?: string }>> = {
error: AlertCircle,
warning: AlertTriangle,
offline: WifiOff,
};
const TONE: Record<
Severity,
{ icon: string; ring: string; bg: string; text: string }
> = {
error: {
icon: "text-danger",
ring: "border-danger-border",
bg: "bg-danger-subtle",
text: "text-danger",
},
warning: {
icon: "text-warning",
ring: "border-warning-border",
bg: "bg-warning-subtle",
text: "text-warning",
},
offline: {
icon: "text-muted-foreground",
ring: "border-border",
bg: "bg-muted",
text: "text-muted-foreground",
},
};
export function ErrorState({
severity = "error",
title,
description,
onRetry,
retrying,
inline = false,
compact = false,
detail,
className,
}: ErrorStateProps) {
const Icon = ICON[severity];
const tone = TONE[severity];
const desc =
description instanceof Error ? description.message : description;
if (inline) {
return (
<div
role="alert"
className={cn(
"flex items-start gap-3 rounded-md border px-4 py-3",
tone.ring,
tone.bg,
className,
)}
>
<Icon className={cn("mt-0.5 h-4 w-4 flex-shrink-0", tone.icon)} />
<div className="min-w-0 flex-1 text-sm">
<div className={cn("font-medium", tone.text)}>{title}</div>
{desc && (
<div className="mt-0.5 text-muted-foreground">{desc}</div>
)}
</div>
{onRetry && (
<Button
variant="outline"
size="sm"
onClick={onRetry}
disabled={retrying}
>
<RefreshCw
className={cn("h-3.5 w-3.5", retrying && "animate-spin")}
/>
Retry
</Button>
)}
</div>
);
}
return (
<div
role="alert"
className={cn(
"flex flex-col items-center justify-center gap-4 text-center",
compact ? "py-8" : "py-16",
className,
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full",
tone.bg,
tone.icon,
)}
>
<Icon className="h-6 w-6" />
</div>
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{desc && <p className="text-sm text-muted-foreground">{desc}</p>}
</div>
{onRetry && (
<Button onClick={onRetry} disabled={retrying} variant="outline">
<RefreshCw className={cn("h-4 w-4", retrying && "animate-spin")} />
Retry
</Button>
)}
{detail && (
<details className="mt-2 w-full max-w-2xl text-left">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
Show technical details
</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded-md border border-border bg-background-subtle p-3 text-2xs text-muted-foreground">
{detail}
</pre>
</details>
)}
</div>
);
}

View File

@@ -0,0 +1,370 @@
import * as React from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, ChevronDown, Filter, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { Input } from "./input";
/**
* Generic FilterPanel — composes a search box, several MultiSelect/Single popovers,
* range inputs, and active-filter chips into a unified toolbar.
*
* Build by composing the named sub-components. Each filter is self-contained so the
* parent only stores values and supplies onChange callbacks. Use `FilterChips` to
* render the active filter summary below the toolbar.
*/
/* ============================================================
* Layout primitives
* ========================================================== */
export function FilterPanel({
children,
className,
toolbarClassName,
chips,
onClearAll,
}: {
children: React.ReactNode;
/** Active filter chips, e.g. from <FilterChips /> */
chips?: React.ReactNode;
onClearAll?: () => void;
className?: string;
toolbarClassName?: string;
}) {
return (
<div
className={cn(
"rounded-lg border border-border bg-surface",
className,
)}
>
<div
className={cn(
"flex flex-wrap items-center gap-2 px-3 py-2",
toolbarClassName,
)}
>
<Filter className="h-4 w-4 text-muted-foreground" aria-hidden />
{children}
</div>
{chips && (
<div className="flex flex-wrap items-center gap-1.5 border-t border-border bg-background-subtle px-3 py-2">
{chips}
{onClearAll && (
<button
type="button"
onClick={onClearAll}
className="ml-auto text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Clear all
</button>
)}
</div>
)}
</div>
);
}
/* ============================================================
* Search box
* ========================================================== */
export function FilterSearch({
value,
onChange,
placeholder = "Search…",
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<div className={cn("relative min-w-[200px] flex-1", className)}>
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-9 pl-8"
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
);
}
/* ============================================================
* Select filter (single value) — popover with options + counts
* ========================================================== */
export interface FilterOption {
value: string;
label: string;
/** Optional badge / count */
hint?: React.ReactNode;
/** Optional swatch color for visual cue */
swatch?: string;
}
export function FilterSelect({
label,
value,
onChange,
options,
placeholder = "All",
allowEmpty = true,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: FilterOption[];
placeholder?: string;
allowEmpty?: boolean;
}) {
const selectedLabel =
options.find((o) => o.value === value)?.label ?? placeholder;
const hasValue = !!value;
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
hasValue && "border-brand-500 text-foreground",
)}
>
<span className="text-muted-foreground">{label}:</span>
<span className={cn(hasValue ? "font-medium" : "text-muted-foreground")}>
{selectedLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={4}
className="z-40 w-56 overflow-hidden rounded-md border border-border bg-popover shadow-md animate-fade-in-up"
>
<ul className="max-h-72 overflow-y-auto py-1">
{allowEmpty && (
<li>
<button
type="button"
onClick={() => onChange("")}
className={cn(
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent/60",
!hasValue && "bg-accent/40 font-medium",
)}
>
<span>{placeholder}</span>
{!hasValue && <Check className="h-3.5 w-3.5" />}
</button>
</li>
)}
{options.map((opt) => {
const isSelected = opt.value === value;
return (
<li key={opt.value}>
<button
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent/60",
isSelected && "bg-accent/40 font-medium",
)}
>
{opt.swatch && (
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: opt.swatch }}
/>
)}
<span className="flex-1 truncate">{opt.label}</span>
{opt.hint != null && (
<span className="text-2xs text-muted-foreground tabular-nums">
{opt.hint}
</span>
)}
{isSelected && (
<Check className="h-3.5 w-3.5 text-foreground" />
)}
</button>
</li>
);
})}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
/* ============================================================
* Range filter (slider) — for numeric thresholds like confidence
* ========================================================== */
export function FilterRange({
label,
value,
onChange,
min = 0,
max = 1,
step = 0.05,
format = (v) => `${Math.round(v * 100)}%`,
}: {
label: string;
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
format?: (v: number) => string;
}) {
const active = value > min;
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
active && "border-brand-500 text-foreground",
)}
>
<span className="text-muted-foreground">{label}:</span>
<span className={cn(active ? "font-medium" : "text-muted-foreground")}>
{format(value)}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={4}
className="z-40 w-64 rounded-md border border-border bg-popover p-3 shadow-md animate-fade-in-up"
>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{format(value)}</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full"
/>
<div className="mt-1 flex justify-between text-2xs text-muted-foreground">
<span>{format(min)}</span>
<span>{format(max)}</span>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
/* ============================================================
* Toggle (checkbox-style chip)
* ========================================================== */
export function FilterToggle({
label,
checked,
onChange,
disabled,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<label
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
checked && "border-brand-500",
disabled && "cursor-not-allowed opacity-50 hover:bg-surface",
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="text-foreground">{label}</span>
</label>
);
}
/* ============================================================
* Active filter chips
* ========================================================== */
export interface ActiveChip {
id: string;
label: string;
value: string;
onClear: () => void;
}
export function FilterChips({ chips }: { chips: ActiveChip[] }) {
if (chips.length === 0) {
return (
<span className="text-2xs text-muted-foreground">No active filters</span>
);
}
return (
<>
{chips.map((chip) => (
<span
key={chip.id}
className="inline-flex items-center gap-1 rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-200"
>
<span className="text-brand-600/70 dark:text-brand-300/70">
{chip.label}:
</span>
<span className="font-medium">{chip.value}</span>
<button
type="button"
onClick={chip.onClear}
className="ml-0.5 -mr-0.5 rounded-full p-0.5 hover:bg-brand-200/60 dark:hover:bg-brand-800/60"
aria-label={`Remove ${chip.label} filter`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</>
);
}
/* ============================================================
* Refresh / view actions slot (placed on the right of toolbar)
* ========================================================== */
export function FilterActions({ children }: { children: React.ReactNode }) {
return <div className="ml-auto flex items-center gap-1">{children}</div>;
}
// Re-export Button for convenience when composing inline
export { Button };

View File

@@ -0,0 +1,90 @@
import * as React from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Skeleton } from "./skeleton";
type Variant = "spinner" | "skeleton" | "list" | "table";
interface LoadingStateProps {
variant?: Variant;
/** Optional message under the indicator */
message?: React.ReactNode;
/** For skeleton variants: row count */
rows?: number;
/** Compact vertical padding */
compact?: boolean;
className?: string;
}
/**
* Standardized loading indicator. Pick a variant that matches what's coming:
* - `spinner`: indeterminate work (mutations, single resources)
* - `skeleton`: rectangular placeholder (single block)
* - `list`: vertical list of rows
* - `table`: dense grid of rows × 4 cols
*/
export function LoadingState({
variant = "spinner",
message,
rows = 4,
compact = false,
className,
}: LoadingStateProps) {
if (variant === "spinner") {
return (
<div
role="status"
aria-live="polite"
className={cn(
"flex flex-col items-center justify-center gap-3 text-center text-muted-foreground",
compact ? "py-6" : "py-16",
className,
)}
>
<Loader2 className="h-5 w-5 animate-spin text-brand-500" />
{message && <span className="text-sm">{message}</span>}
<span className="sr-only">Loading</span>
</div>
);
}
if (variant === "skeleton") {
return <Skeleton className={cn("h-24 w-full", className)} />;
}
if (variant === "list") {
return (
<div className={cn("space-y-2", className)} aria-busy="true">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
// table
return (
<div
className={cn(
"overflow-hidden rounded-lg border border-border",
className,
)}
aria-busy="true"
>
<div className="border-b border-border bg-background-subtle px-3 py-2">
<Skeleton className="h-3.5 w-32" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
className="grid grid-cols-4 gap-3 border-b border-border px-3 py-3 last:border-0"
>
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3.5 w-1/2" />
<Skeleton className="h-3.5 w-3/5" />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,147 @@
import * as React from "react";
import { Keyboard } from "lucide-react";
import {
Dialog,
DialogPanel,
DialogHeader,
DialogTitle,
DialogDescription,
DialogBody,
} from "./dialog";
export interface ShortcutEntry {
keys: string;
label: string;
}
export interface ShortcutGroup {
title: string;
items: ShortcutEntry[];
}
interface ShortcutsOverlayProps {
open: boolean;
onOpenChange: (open: boolean) => void;
groups: ShortcutGroup[];
title?: string;
description?: string;
}
/**
* Modal listing keyboard shortcuts. Pair with the `?` binding to open it.
*
* Each `keys` entry uses tokens separated by spaces for chord (e.g. "g d")
* or `+` for modifier combos (e.g. "Mod+K", "Shift+/"). Tokens are rendered
* as individual <kbd> chips.
*/
export function ShortcutsOverlay({
open,
onOpenChange,
groups,
title = "Keyboard shortcuts",
description = "Press the key combination to trigger the action.",
}: ShortcutsOverlayProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange} ariaLabel={title}>
<DialogPanel className="mx-auto mt-20 max-w-2xl">
<DialogHeader onClose={() => onOpenChange(false)}>
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
<Keyboard className="h-4 w-4" />
</span>
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogBody>
<div className="grid gap-6 sm:grid-cols-2">
{groups.map((group) => (
<div key={group.title}>
<h3 className="mb-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</h3>
<ul className="space-y-1.5">
{group.items.map((item) => (
<li
key={item.keys + item.label}
className="flex items-center justify-between gap-3 rounded-md border border-border bg-background-subtle px-3 py-1.5"
>
<span className="text-sm text-foreground">
{item.label}
</span>
<ShortcutChips keys={item.keys} />
</li>
))}
</ul>
</div>
))}
</div>
</DialogBody>
</DialogPanel>
</Dialog>
);
}
function ShortcutChips({ keys }: { keys: string }) {
// chord (space) → sequence; modifier (+) → group
const trimmed = keys.trim();
if (trimmed.includes(" ")) {
const parts = trimmed.split(/\s+/);
return (
<span className="inline-flex items-center gap-1">
{parts.map((p, i) => (
<React.Fragment key={i}>
<Kbd>{p}</Kbd>
{i < parts.length - 1 && (
<span className="text-2xs text-muted-foreground">then</span>
)}
</React.Fragment>
))}
</span>
);
}
if (trimmed.includes("+")) {
const parts = trimmed.split("+").map((p) => p.trim());
return (
<span className="inline-flex items-center gap-1">
{parts.map((p, i) => (
<React.Fragment key={i}>
<Kbd>{prettifyKey(p)}</Kbd>
{i < parts.length - 1 && (
<span className="text-2xs text-muted-foreground">+</span>
)}
</React.Fragment>
))}
</span>
);
}
return <Kbd>{prettifyKey(trimmed)}</Kbd>;
}
function prettifyKey(k: string): string {
const lower = k.toLowerCase();
if (typeof navigator === "undefined") return k;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
if (lower === "mod") return isMac ? "⌘" : "Ctrl";
if (lower === "cmd" || lower === "meta") return isMac ? "⌘" : "Win";
if (lower === "ctrl") return "Ctrl";
if (lower === "shift") return "⇧";
if (lower === "alt" || lower === "option") return isMac ? "⌥" : "Alt";
if (lower === "enter") return "↵";
if (lower === "escape" || lower === "esc") return "Esc";
if (lower === "arrowup") return "↑";
if (lower === "arrowdown") return "↓";
if (lower === "arrowleft") return "←";
if (lower === "arrowright") return "→";
return k.length === 1 ? k.toUpperCase() : k;
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex min-w-[1.5rem] items-center justify-center rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-2xs font-medium text-foreground shadow-xs">
{children}
</kbd>
);
}

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
/**
* Thin wrapper over Radix Tooltip with project styling.
* Wrap your tree in <TooltipProvider> once (in main.tsx).
*/
export const TooltipProvider = TooltipPrimitive.Provider;
export interface TooltipProps {
children: React.ReactNode;
content: React.ReactNode;
/** Side relative to the trigger */
side?: "top" | "right" | "bottom" | "left";
/** Alignment along the side axis */
align?: "start" | "center" | "end";
/** Open delay in ms. Default 200. */
delayMs?: number;
/** Show keyboard shortcut chip after content */
shortcut?: string;
}
export function Tooltip({
children,
content,
side = "top",
align = "center",
delayMs = 200,
shortcut,
}: TooltipProps) {
return (
<TooltipPrimitive.Root delayDuration={delayMs}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
align={align}
sideOffset={6}
className={cn(
"z-50 inline-flex items-center gap-2 rounded-md border border-border bg-popover px-2.5 py-1.5",
"text-2xs text-popover-foreground shadow-md",
"animate-fade-in",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
)}
>
<span>{content}</span>
{shortcut && (
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{shortcut}
</kbd>
)}
<TooltipPrimitive.Arrow className="fill-popover stroke-border" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}

View 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();
};
}, []);
}

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

View File

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

View File

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

View File

@@ -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">-&gt;</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>

View File

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

View File

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