chore: bootstrap repository with governance docs

Initialize git, add Apache-2.0 LICENSE, .gitattributes (LF line
endings), AGENTS.md (entry points, stack, discovery order, baseline
checks), RUNBOOK.md (dev boot, prod deploy with overlay, ingestion,
failures, rollback, scaling notes), .env.prod.example with rotated
credential placeholders, and dev-only warnings on .env.example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vadim Malanov
2026-05-13 16:41:50 +03:00
commit 7f72171572
157 changed files with 11298 additions and 0 deletions

4
frontend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
# Frontend environment
VITE_API_BASE_URL=/api/v1
VITE_USE_MOCK=true
VITE_APP_NAME=LegacyHUB

7
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.env
.env.local
.vite
.DS_Store
*.log

140
frontend/README.md Normal file
View File

@@ -0,0 +1,140 @@
# LegacyHUB · Frontend
React + TypeScript + Vite frontend for **LegacyHUB**, the legacy-document
indexing and AI search module of the **TeamHUB Suite**.
This package ships:
- the application shell (collapsible sidebar, top toolbar, breadcrumb nav,
global ⌘K command palette, light/dark theme, notification center,
user/profile menu);
- nine pages: Dashboard, Documents, Ingestion Jobs, Search, Document Viewer,
Tables & Figures, Quality Control, System Health, Settings;
- a hybrid AI search workspace with semantic / lexical / hybrid modes, live
suggestions, expandable filters, highlighted matches, reranker score
visualization and side-by-side chunk preview;
- typed service layer (`src/services/*`) with Axios + TanStack Query and a
mock data backend you can toggle off when the backend is reachable.
## Stack
| Concern | Library |
|----------------|-----------------------------------------|
| Bundler | Vite 5 |
| Language | TypeScript 5.6 |
| UI | React 18 |
| Styling | TailwindCSS 3 + custom design tokens |
| Components | shadcn/ui primitives (Radix + cva) |
| Animation | Framer Motion |
| Charts | Recharts |
| Server state | TanStack Query |
| Client state | Zustand |
| Routing | React Router v6 |
| HTTP | Axios |
| Icons | lucide-react |
| Toasts | sonner |
| Virtualization | @tanstack/react-virtual |
## Quick start
```bash
cd frontend
cp .env.example .env # VITE_USE_MOCK=true for offline UI development
npm install
npm run dev # http://localhost:5173
```
When the FastAPI backend is running, set `VITE_USE_MOCK=false` (or simply
`VITE_API_BASE_URL=/api/v1` and let the Vite dev proxy at port 8000 handle
routing). All API calls are isolated through `src/services/*.ts`.
## Architecture
```
frontend/src/
app/ RouterProvider, QueryClient, TooltipProvider, theme bootstrap
pages/ One file per route — composed of widgets + primitives
layouts/ AppShell, Sidebar (collapsible), Topbar, Breadcrumbs, ⌘K palette
widgets/ Domain-specific composite components (KpiCard, Charts, Result cards,
PdfPreviewPane, ChunkPreview, ServiceHealthCard, Timeline)
components/
ui/ shadcn-style primitives — Button, Card, Tabs, Dialog, Select,
Tooltip, Popover, ScrollArea, Command, Skeleton, Progress, …
common/ Domain primitives — Logo, StatusChip, ConfidenceMeter,
QualityFlag, BlockTypeIcon, Highlight, EmptyState, PageHeader,
ThemeToggle
services/ Typed API layer (Axios) + TanStack hooks (one file per resource)
mock/ Deterministic mock data + simulated latency
hooks/ Wrappers around services exposing TanStack Query hooks
stores/ Zustand stores: uiStore (theme, sidebar, palette), searchStore
styles/ Tailwind layer + design tokens (HSL CSS variables)
lib/ cn(), formatBytes/Number/Percent/Duration, relativeTime, etc.
```
### Design system
- **Palette** — white / light-gray surfaces with a single restrained green
accent (`--primary: 158 64% 32%`) matching QMS Hub.
- **Surfaces** — three tiers: sunken (page background), default card, raised
(popovers / dialogs). Glass surfaces via `backdrop-blur` for the topbar.
- **Corners** — `--radius: 14px` produces soft, premium edges across every
component.
- **Shadows** — `shadow-soft` and `shadow-elevated` only. No harsh drop
shadows.
- **Typography** — Inter variable, optical sizes, tabular numbers for data
cells, JetBrains Mono for IDs / paths / hashes.
- **Motion** — Framer Motion `layoutId` for the active sidebar pill,
`fade-in-up` for KPI cards, animated tabs and result expansion.
- **States** — skeleton shimmer instead of spinners wherever possible.
### Key flows
- **Hybrid search (`/search`)** — Debounced query → TanStack hook hits the
backend (or mock). Results are virtualized, scored, optionally reranked.
Picking a result hydrates a side-by-side ChunkPreview with the highlighted
excerpt, a page thumbnail, citation metadata, and quality flags.
- **Documents (`/documents`)** — Virtualized table (TanStack Virtual)
supports thousands of rows. Filters: status, OCR threshold, "needs review",
free-text search. Clicking a row opens the viewer.
- **Document Viewer (`/viewer/:id`)** — Split layout. Left pane: PDF page
thumbnails + synchronized large page preview with highlighted OCR blocks.
Right pane: extracted chunks / tables / figures / metadata, kept in lock-step
with the active page. Below: full pipeline timeline.
- **Ingestion (`/ingestion`)** — Submit a folder path with `recursive` /
`force` toggles → optimistic queue, run history table with live progress
bars.
- **Quality control (`/quality`)** — Three review queues (low confidence,
handwriting, failed extraction) with reviewer actions and an audit log.
### Mock vs real backend
`src/services/apiClient.ts` exports a constant `USE_MOCK`. When `true`, every
service module short-circuits to `src/services/mock/mockData.ts` which
generates deterministic, seeded data: 280 documents, dashboards, ingestion
runs, search results, health and queue snapshots, and per-document detail
(pages, chunks, tables, figures, timeline events).
This lets the frontend be developed and demoed without the Python services
running.
### Accessibility
- All interactive elements use `ring-focus` (visible 2px primary ring).
- Sidebar nav exposes tooltips when collapsed.
- Keyboard: `Ctrl/Cmd + K` opens the global command palette.
### Responsive layout
- ≥ 1280 px (xl, ultrawide) — three-column dashboards, side-by-side search.
- 10241280 px (laptop) — two-column dashboards, stacked search.
- < 1024 px — single column; sidebar collapses to icons only.
## Scripts
```bash
npm run dev # Vite dev server with /api proxy → :8000
npm run build # type-check + production bundle
npm run preview # preview build
npm run lint
npm run format
```

19
frontend/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#059669" />
<title>LegacyHUB · TeamHUB Suite</title>
<link
rel="stylesheet"
href="https://rsms.me/inter/inter.css"
crossorigin="anonymous"
/>
</head>
<body class="bg-background text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

56
frontend/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "legacyhub-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --port 4173",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.10.6",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.5.4",
"lucide-react": "^0.451.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"recharts": "^2.13.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.11.1",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" x2="32" y1="0" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#10b981"/>
<stop offset="1" stop-color="#047857"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<path d="M9 9.5h6.2c2.9 0 4.6 1.5 4.6 4 0 2-1.1 3.3-3 3.8l3.6 5.2h-3l-3.3-5h-2.5v5H9V9.5zm5.9 5.7c1.5 0 2.4-.7 2.4-2 0-1.2-.9-1.9-2.4-1.9h-3.2v3.9h3.2z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

11
frontend/src/app/App.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { RouterProvider } from "react-router-dom";
import { AppProviders } from "@/app/providers";
import { router } from "@/app/router";
export function App() {
return (
<AppProviders>
<RouterProvider router={router} />
</AppProviders>
);
}

View File

@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "sonner";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
export function AppProviders({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={150}>
{children}
<Toaster
position="bottom-right"
richColors
toastOptions={{
classNames: {
toast:
"rounded-xl border border-border/70 bg-card text-foreground shadow-elevated",
},
}}
/>
</TooltipProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,31 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { AppShell } from "@/layouts/AppShell";
import { DashboardPage } from "@/pages/DashboardPage";
import { DocumentsPage } from "@/pages/DocumentsPage";
import { IngestionJobsPage } from "@/pages/IngestionJobsPage";
import { SearchPage } from "@/pages/SearchPage";
import { DocumentViewerPage } from "@/pages/DocumentViewerPage";
import { TablesFiguresPage } from "@/pages/TablesFiguresPage";
import { QualityControlPage } from "@/pages/QualityControlPage";
import { SystemHealthPage } from "@/pages/SystemHealthPage";
import { SettingsPage } from "@/pages/SettingsPage";
export const router = createBrowserRouter([
{
element: <AppShell />,
children: [
{ path: "/", element: <DashboardPage /> },
{ path: "/documents", element: <DocumentsPage /> },
{ path: "/ingestion", element: <IngestionJobsPage /> },
{ path: "/search", element: <SearchPage /> },
{ path: "/viewer", element: <DocumentViewerPage /> },
{ path: "/viewer/:id", element: <DocumentViewerPage /> },
{ path: "/tables-figures", element: <TablesFiguresPage /> },
{ path: "/quality", element: <QualityControlPage /> },
{ path: "/health", element: <SystemHealthPage /> },
{ path: "/settings", element: <SettingsPage /> },
{ path: "*", element: <Navigate to="/" replace /> },
],
},
]);

View File

@@ -0,0 +1,44 @@
import {
AlignLeft,
Heading,
List,
Table as TableIcon,
Image as ImageIcon,
PenLine,
Hash,
HelpCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
const MAP: Record<string, { icon: typeof AlignLeft; tone: string }> = {
title: { icon: Hash, tone: "text-primary" },
heading: { icon: Heading, tone: "text-primary" },
paragraph: { icon: AlignLeft, tone: "text-muted-foreground" },
list: { icon: List, tone: "text-muted-foreground" },
table: { icon: TableIcon, tone: "text-warning" },
figure_caption: { icon: ImageIcon, tone: "text-primary-600" },
figure_description: { icon: ImageIcon, tone: "text-primary-600" },
handwriting: { icon: PenLine, tone: "text-destructive" },
unknown: { icon: HelpCircle, tone: "text-muted-foreground" },
};
export function BlockTypeIcon({
type,
className,
}: {
type: string;
className?: string;
}) {
const m = MAP[type] ?? MAP.unknown;
const Icon = m.icon;
return <Icon className={cn("h-3.5 w-3.5", m.tone, className)} aria-hidden />;
}
export function BlockTypeLabel({ type }: { type: string }) {
return (
<span className="inline-flex items-center gap-1 rounded-md border border-border/70 bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<BlockTypeIcon type={type} />
{type.replace(/_/g, " ")}
</span>
);
}

View File

@@ -0,0 +1,38 @@
import { cn } from "@/lib/utils";
export function ConfidenceMeter({
value,
showLabel = true,
className,
}: {
value: number | null | undefined;
showLabel?: boolean;
className?: string;
}) {
const pct = value == null ? null : Math.round(value * 100);
const tone =
pct == null
? "bg-muted-foreground/30"
: pct >= 85
? "bg-success"
: pct >= 65
? "bg-primary"
: pct >= 45
? "bg-warning"
: "bg-destructive";
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full transition-all", tone)}
style={{ width: pct == null ? "100%" : `${pct}%` }}
/>
</div>
{showLabel && (
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{pct == null ? "—" : `${pct}%`}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function EmptyState({
icon,
title,
description,
action,
className,
}: {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"panel flex flex-col items-center justify-center gap-3 px-8 py-14 text-center",
className
)}
>
{icon && (
<div className="rounded-2xl border border-border/70 bg-accent/40 p-3 text-primary">
{icon}
</div>
)}
<div className="space-y-1">
<div className="text-sm font-semibold">{title}</div>
{description && (
<div className="max-w-sm text-xs text-muted-foreground">{description}</div>
)}
</div>
{action}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { useMemo } from "react";
export function Highlight({
text,
query,
}: {
text: string;
query: string;
}) {
const parts = useMemo(() => splitHighlight(text, query), [text, query]);
return (
<>
{parts.map((p, i) =>
p.match ? (
<mark
key={i}
className="rounded-[3px] bg-primary/20 px-0.5 text-primary-700 dark:text-primary-100"
>
{p.text}
</mark>
) : (
<span key={i}>{p.text}</span>
)
)}
</>
);
}
function splitHighlight(text: string, query: string): { text: string; match: boolean }[] {
const q = query.trim();
if (!q) return [{ text, match: false }];
const tokens = Array.from(new Set(q.split(/\s+/).filter((t) => t.length >= 2)));
if (tokens.length === 0) return [{ text, match: false }];
const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const re = new RegExp(`(${escaped.join("|")})`, "gi");
const out: { text: string; match: boolean }[] = [];
let last = 0;
for (const m of text.matchAll(re)) {
if (m.index! > last) out.push({ text: text.slice(last, m.index), match: false });
out.push({ text: m[0], match: true });
last = m.index! + m[0].length;
}
if (last < text.length) out.push({ text: text.slice(last), match: false });
return out;
}

View File

@@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
export function Logo({ className, compact = false }: { className?: string; compact?: boolean }) {
return (
<div className={cn("flex items-center gap-2.5", className)}>
<div className="relative h-8 w-8 shrink-0 overflow-hidden rounded-lg shadow-soft">
<div className="absolute inset-0 bg-gradient-to-br from-primary-500 to-primary-700" />
<div className="absolute inset-0 grid place-items-center text-[15px] font-semibold tracking-tight text-white">
L
</div>
<div className="pointer-events-none absolute inset-0 ring-1 ring-inset ring-white/15" />
</div>
{!compact && (
<div className="leading-tight">
<div className="text-sm font-semibold tracking-tight text-foreground">LegacyHUB</div>
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
TeamHUB Suite
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function PageHeader({
title,
description,
actions,
className,
}: {
title: string;
description?: string;
actions?: ReactNode;
className?: string;
}) {
return (
<header className={cn("flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between", className)}>
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight text-foreground text-balance">
{title}
</h1>
{description && (
<p className="max-w-2xl text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</header>
);
}

View File

@@ -0,0 +1,74 @@
import { AlertTriangle, CheckCircle2, FileWarning, Hash, Image, PenLine, Table } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const FLAGS: Record<
string,
{ label: string; icon: typeof AlertTriangle; tone: string }
> = {
low_ocr_confidence: { label: "Low OCR confidence", icon: AlertTriangle, tone: "text-warning" },
very_short_text: { label: "Very short text", icon: Hash, tone: "text-muted-foreground" },
possible_garbled_text: { label: "Possible garbled text", icon: FileWarning, tone: "text-destructive" },
table_detected: { label: "Table detected", icon: Table, tone: "text-primary-600" },
figure_detected: { label: "Figure detected", icon: Image, tone: "text-primary-600" },
handwriting_detected: { label: "Handwriting detected", icon: PenLine, tone: "text-destructive" },
needs_manual_review: { label: "Needs manual review", icon: AlertTriangle, tone: "text-warning" },
};
export function QualityFlags({
flags,
compact = false,
className,
}: {
flags: Record<string, boolean | undefined> | null | undefined;
compact?: boolean;
className?: string;
}) {
const active = Object.entries(flags ?? {})
.filter(([k, v]) => v && FLAGS[k])
.map(([k]) => k);
if (active.length === 0) {
return (
<span className={cn("inline-flex items-center gap-1 text-xs text-success", className)}>
<CheckCircle2 className="h-3.5 w-3.5" />
Clean
</span>
);
}
return (
<div className={cn("flex flex-wrap items-center gap-1.5", className)}>
{active.map((key) => {
const f = FLAGS[key];
const Icon = f.icon;
if (compact) {
return (
<Tooltip key={key}>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-full border border-border/60 bg-card",
f.tone
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent>{f.label}</TooltipContent>
</Tooltip>
);
}
return (
<span
key={key}
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-muted/40 px-2 py-0.5 text-[11px] font-medium"
>
<Icon className={cn("h-3 w-3", f.tone)} />
<span className="text-muted-foreground">{f.label}</span>
</span>
);
})}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { cn } from "@/lib/utils";
const TONE: Record<string, { dot: string; text: string; bg: string }> = {
ok: { dot: "bg-success", text: "text-success", bg: "bg-success/10" },
active: { dot: "bg-primary", text: "text-primary-700 dark:text-primary-100", bg: "bg-primary/10" },
warning: { dot: "bg-warning", text: "text-warning", bg: "bg-warning/10" },
error: { dot: "bg-destructive", text: "text-destructive", bg: "bg-destructive/10" },
muted: { dot: "bg-muted-foreground", text: "text-muted-foreground", bg: "bg-muted/60" },
};
export type StatusTone = keyof typeof TONE;
export function StatusChip({
tone = "muted",
label,
className,
}: {
tone?: StatusTone;
label: string;
className?: string;
}) {
const t = TONE[tone];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
t.bg,
t.text,
className
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", t.dot)} />
{label}
</span>
);
}
export function statusToTone(status: string): StatusTone {
const s = status?.toUpperCase();
if (!s) return "muted";
if (s.includes("FAILED") || s === "ERROR") return "error";
if (s === "INDEXING_COMPLETED" || s === "OK") return "ok";
if (s === "DISCOVERED" || s.endsWith("_STARTED") || s === "PENDING") return "active";
if (s === "OCR_COMPLETED" || s === "EXTRACTION_COMPLETED" || s === "CHUNKING_COMPLETED")
return "active";
if (s === "DEGRADED") return "warning";
return "muted";
}

View File

@@ -0,0 +1,28 @@
import { Moon, Sun, MonitorSmartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUiStore } from "@/stores/uiStore";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : MonitorSmartphone;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Toggle theme"
onClick={() => setTheme(next)}
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Theme: {theme}</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary/12 text-primary-700 dark:text-primary-100",
outline: "border-border bg-transparent text-foreground",
muted: "border-transparent bg-muted text-muted-foreground",
success: "border-transparent bg-success/15 text-success",
warning: "border-transparent bg-warning/15 text-warning",
destructive: "border-transparent bg-destructive/15 text-destructive",
accent: "border-transparent bg-accent text-accent-foreground",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-focus transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-soft hover:bg-primary-700 active:translate-y-[0.5px]",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border/70",
outline:
"border border-border bg-transparent hover:bg-muted text-foreground",
ghost:
"hover:bg-muted text-foreground",
subtle:
"bg-accent text-accent-foreground hover:bg-accent/70",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
sm: "h-8 px-3 text-xs",
default: "h-9 px-4",
lg: "h-11 px-6 text-base rounded-xl",
icon: "h-9 w-9",
"icon-sm": "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
);
}
);
Button.displayName = "Button";
export { buttonVariants };

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("panel", className)} {...props} />
)
);
Card.displayName = "Card";
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1 p-5", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-base font-semibold tracking-tight text-foreground", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
export const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("px-5 pb-5", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center justify-between gap-2 border-t border-border/60 px-5 py-3", className)}
{...props}
/>
)
);
CardFooter.displayName = "CardFooter";

View File

@@ -0,0 +1,90 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
export const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn("flex h-full w-full flex-col overflow-hidden rounded-xl bg-popover", className)}
{...props}
/>
));
Command.displayName = "Command";
export const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center gap-2 border-b border-border/70 px-3" cmdk-input-wrapper="">
<Search className="h-4 w-4 text-muted-foreground" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/70",
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = "CommandInput";
export const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[320px] overflow-y-auto overflow-x-hidden p-1 scrollbar-thin", className)}
{...props}
/>
));
CommandList.displayName = "CommandList";
export const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm text-muted-foreground" {...props} />
));
CommandEmpty.displayName = "CommandEmpty";
export const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
));
CommandGroup.displayName = "CommandGroup";
export const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-colors",
"data-[selected=true]:bg-muted data-[selected=true]:text-foreground",
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = "CommandItem";
export const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
);

View File

@@ -0,0 +1,76 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 panel-raised p-6",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground hover:bg-muted ring-focus">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
export const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-1.5", className)} {...props} />
);
export const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-base font-semibold tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;

View File

@@ -0,0 +1,113 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
export const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border/70", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-xl border border-border/70 bg-popover p-1 text-popover-foreground shadow-elevated",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
export const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-colors",
"hover:bg-muted focus:bg-muted",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2.5 py-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
export const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
checked={checked}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-muted",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
export const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
);
export const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-md px-2.5 py-1.5 text-sm outline-none hover:bg-muted",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = "text", ...props }, ref) => (
<input
ref={ref}
type={type}
className={cn(
"flex h-9 w-full rounded-lg border border-input bg-surface px-3 py-1.5 text-sm shadow-sm transition-colors",
"placeholder:text-muted-foreground/70",
"ring-focus disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
);
Input.displayName = "Input";

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverAnchor = PopoverPrimitive.Anchor;
export const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 6, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-xl border border-border/70 bg-popover p-3 text-popover-foreground shadow-elevated",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
export const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-muted", className)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = "Progress";

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
export const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = "ScrollArea";
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" ? "h-full w-2 p-0.5" : "h-2 flex-col p-0.5",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-muted-foreground/30 hover:bg-muted-foreground/50 transition-colors" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = "ScrollBar";

View File

@@ -0,0 +1,89 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export const Select = SelectPrimitive.Root;
export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-lg border border-input bg-surface px-3 text-sm shadow-sm transition-colors",
"ring-focus disabled:cursor-not-allowed disabled:opacity-50",
"[&>span]:truncate",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
export const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={cn(
"relative z-50 max-h-72 min-w-[8rem] overflow-hidden rounded-xl border border-border/70 bg-popover text-popover-foreground shadow-elevated",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" && "translate-y-1",
className
)}
{...props}
>
<SelectPrimitive.Viewport className="p-1 max-h-72">
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
export const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors",
"data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border/70", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
export const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border/70",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
{...props}
/>
));
Separator.displayName = "Separator";

View File

@@ -0,0 +1,5 @@
import { cn } from "@/lib/utils";
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("skeleton-shimmer rounded-lg", className)} {...props} />;
}

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
export const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
ref={ref}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors",
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted",
"ring-focus disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-soft transition-transform",
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = "Switch";

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
export const Tabs = TabsPrimitive.Root;
export const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center gap-1 rounded-xl border border-border/70 bg-muted/40 p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
export const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1 text-xs font-medium transition-all",
"ring-focus disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-soft",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
export const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn("mt-3 ring-focus", className)} {...props} />
));
TabsContent.displayName = TabsPrimitive.Content.displayName;

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
export const TooltipProvider = TooltipPrimitive.Provider;
export const Tooltip = TooltipPrimitive.Root;
export const TooltipTrigger = TooltipPrimitive.Trigger;
export const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-border/70 bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-elevated",
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;

View File

@@ -0,0 +1,10 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay = 250): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}

View File

@@ -0,0 +1,27 @@
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { getDashboardStats, getDocument, listDocuments, type DocumentListParams } from "@/services/documents";
export function useDocuments(params: DocumentListParams) {
return useQuery({
queryKey: ["documents", params],
queryFn: () => listDocuments(params),
placeholderData: keepPreviousData,
staleTime: 20_000,
});
}
export function useDocument(id: string | undefined) {
return useQuery({
queryKey: ["document", id],
queryFn: () => getDocument(id!),
enabled: Boolean(id),
});
}
export function useDashboardStats() {
return useQuery({
queryKey: ["dashboard", "stats"],
queryFn: getDashboardStats,
refetchInterval: 30_000,
});
}

View File

@@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { getHealth, getQueueState } from "@/services/health";
export function useHealth() {
return useQuery({
queryKey: ["health"],
queryFn: getHealth,
refetchInterval: 15_000,
staleTime: 10_000,
});
}
export function useQueue() {
return useQuery({
queryKey: ["queue"],
queryFn: getQueueState,
refetchInterval: 10_000,
});
}

View File

@@ -0,0 +1,23 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ingestFolder, listRuns } from "@/services/ingestion";
import type { IngestFolderRequest } from "@/services/types";
export function useIngestionRuns() {
return useQuery({
queryKey: ["ingestion-runs"],
queryFn: listRuns,
refetchInterval: 15_000,
});
}
export function useStartIngestion() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: IngestFolderRequest) => ingestFolder(req),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ingestion-runs"] });
qc.invalidateQueries({ queryKey: ["documents"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}

View File

@@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getQualityQueue, type QualityQueueKind } from "@/services/quality";
export function useQualityQueue(kind: QualityQueueKind) {
return useQuery({
queryKey: ["quality", kind],
queryFn: () => getQualityQueue(kind),
staleTime: 30_000,
});
}

View File

@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { search, suggest } from "@/services/search";
import type { SearchFilters, SearchMode } from "@/services/types";
export function useSearchResults(opts: {
query: string;
mode: SearchMode;
filters: SearchFilters;
limit: number;
enabled?: boolean;
}) {
return useQuery({
queryKey: ["search", opts.query, opts.mode, opts.filters, opts.limit],
queryFn: () =>
search({
query: opts.query,
limit: opts.limit,
filters: opts.filters,
search_mode: opts.mode,
}),
enabled: opts.enabled !== false && opts.query.trim().length > 0,
staleTime: 60_000,
});
}
export function useSuggestions(query: string) {
return useQuery({
queryKey: ["search-suggest", query],
queryFn: () => suggest(query),
staleTime: 30_000,
});
}

View File

@@ -0,0 +1,15 @@
import { useEffect } from "react";
import { applyTheme, useUiStore } from "@/stores/uiStore";
export function useThemeBootstrap() {
const theme = useUiStore((s) => s.theme);
useEffect(() => {
applyTheme(theme);
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const fn = () => applyTheme("system");
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}, [theme]);
}

View File

@@ -0,0 +1,30 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "@/layouts/Sidebar";
import { Topbar } from "@/layouts/Topbar";
import { CommandPalette } from "@/layouts/CommandPalette";
import { useThemeBootstrap } from "@/hooks/useTheme";
export function AppShell() {
useThemeBootstrap();
return (
<div className="relative flex min-h-screen bg-background">
{/* Soft ambient backdrop */}
<div
aria-hidden
className="pointer-events-none fixed inset-0 -z-10 opacity-[0.45] dark:opacity-30"
style={{
background:
"radial-gradient(60% 50% at 18% 14%, hsl(var(--primary) / 0.16), transparent 70%), radial-gradient(40% 30% at 90% 0%, hsl(var(--primary) / 0.10), transparent 60%)",
}}
/>
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />
<main className="relative flex min-w-0 flex-1 flex-col gap-6 px-4 py-6 lg:px-8 lg:py-8 2xl:px-12">
<Outlet />
</main>
</div>
<CommandPalette />
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { ChevronRight, Home } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { NAV } from "@/layouts/navConfig";
import { cn } from "@/lib/utils";
export function Breadcrumbs({ className }: { className?: string }) {
const { pathname } = useLocation();
const segments = pathname.split("/").filter(Boolean);
const trail = segments.map((seg, i) => {
const url = "/" + segments.slice(0, i + 1).join("/");
const match = NAV.find((n) => n.to === url);
return { url, label: match?.label ?? prettify(seg) };
});
return (
<nav className={cn("flex items-center gap-1.5 text-xs text-muted-foreground", className)}>
<Link
to="/"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-muted hover:text-foreground"
>
<Home className="h-3.5 w-3.5" />
<span className="font-medium">LegacyHUB</span>
</Link>
{trail.map((t, i) => (
<span key={t.url} className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" />
{i === trail.length - 1 ? (
<span className="font-medium text-foreground">{t.label}</span>
) : (
<Link to={t.url} className="hover:text-foreground">
{t.label}
</Link>
)}
</span>
))}
</nav>
);
}
function prettify(s: string): string {
return s.replace(/-/g, " ").replace(/\b\w/g, (m) => m.toUpperCase());
}

View File

@@ -0,0 +1,87 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import {
Command,
CommandInput,
CommandList,
CommandGroup,
CommandItem,
CommandEmpty,
CommandShortcut,
} from "@/components/ui/command";
import { useUiStore } from "@/stores/uiStore";
import { NAV } from "@/layouts/navConfig";
export function CommandPalette() {
const open = useUiStore((s) => s.commandOpen);
const close = useUiStore((s) => s.closeCommand);
const toggle = useUiStore((s) => s.toggleCommand);
const navigate = useNavigate();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
toggle();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [toggle]);
return (
<Dialog open={open} onOpenChange={(o) => (o ? null : close())}>
<DialogContent className="max-w-xl gap-0 overflow-hidden p-0">
<Command label="Global command palette">
<CommandInput placeholder="Search pages, documents, recent queries…" />
<CommandList>
<CommandEmpty>No matching results.</CommandEmpty>
<CommandGroup heading="Navigation">
{NAV.map((n) => (
<CommandItem
key={n.to}
value={n.label}
onSelect={() => {
navigate(n.to);
close();
}}
>
<n.icon className="h-4 w-4 text-muted-foreground" />
{n.label}
{n.shortcut && <CommandShortcut>{n.shortcut}</CommandShortcut>}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Actions">
<CommandItem
onSelect={() => {
navigate("/ingestion");
close();
}}
>
Start new ingestion run
</CommandItem>
<CommandItem
onSelect={() => {
navigate("/quality");
close();
}}
>
Review low-confidence queue
</CommandItem>
<CommandItem
onSelect={() => {
navigate("/search");
close();
}}
>
Open AI search workspace
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,171 @@
import { NavLink } from "react-router-dom";
import { ChevronsLeft, ChevronsRight, Sparkles } from "lucide-react";
import { motion } from "framer-motion";
import { Logo } from "@/components/common/Logo";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { useUiStore } from "@/stores/uiStore";
import { GROUPS, NAV, type NavItem } from "@/layouts/navConfig";
import { cn } from "@/lib/utils";
export function Sidebar() {
const collapsed = useUiStore((s) => s.sidebarCollapsed);
const toggle = useUiStore((s) => s.toggleSidebar);
return (
<motion.aside
initial={false}
animate={{ width: collapsed ? 72 : 248 }}
transition={{ type: "spring", stiffness: 280, damping: 32 }}
className={cn(
"relative z-30 flex h-screen shrink-0 flex-col border-r border-border/70 bg-surface",
"shadow-[1px_0_0_rgba(15,23,42,0.02)]"
)}
>
<div className="flex h-14 items-center justify-between px-3">
<Logo compact={collapsed} />
<Button
variant="ghost"
size="icon-sm"
aria-label="Toggle sidebar"
onClick={toggle}
className="hidden lg:inline-flex"
>
{collapsed ? <ChevronsRight className="h-4 w-4" /> : <ChevronsLeft className="h-4 w-4" />}
</Button>
</div>
<div className="px-3 pb-2">
<PromoCard collapsed={collapsed} />
</div>
<nav className="flex-1 overflow-y-auto px-2 py-2 scrollbar-thin">
{(Object.keys(GROUPS) as NavItem["group"][]).map((group) => (
<SidebarGroup
key={group}
title={GROUPS[group]}
items={NAV.filter((n) => n.group === group)}
collapsed={collapsed}
/>
))}
</nav>
<div className="border-t border-border/70 p-3">
<div
className={cn(
"rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-[11px] leading-relaxed text-muted-foreground",
collapsed && "hidden"
)}
>
<div className="flex items-center gap-1.5 font-medium text-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
All services healthy
</div>
<span>Last sync · 2m ago</span>
</div>
</div>
</motion.aside>
);
}
function SidebarGroup({
title,
items,
collapsed,
}: {
title: string;
items: NavItem[];
collapsed: boolean;
}) {
return (
<div className="mb-2">
<div
className={cn(
"px-2 pb-1 pt-3 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/80",
collapsed && "sr-only"
)}
>
{title}
</div>
<ul className="flex flex-col gap-0.5">
{items.map((item) => (
<SidebarLink key={item.to} item={item} collapsed={collapsed} />
))}
</ul>
</div>
);
}
function SidebarLink({ item, collapsed }: { item: NavItem; collapsed: boolean }) {
const Icon = item.icon;
const link = (
<NavLink
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-xl px-2.5 py-2 text-sm font-medium transition-colors",
"ring-focus",
isActive
? "bg-primary/10 text-primary-700 dark:text-primary-100"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)
}
>
{({ isActive }) => (
<>
{isActive && (
<motion.span
layoutId="sidebar-active"
transition={{ type: "spring", stiffness: 380, damping: 32 }}
className="absolute left-0 top-1/2 -translate-y-1/2 h-6 w-[3px] rounded-r-full bg-primary"
/>
)}
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
{!collapsed && item.badge && (
<Badge variant="warning" className="ml-auto">
{item.badge}
</Badge>
)}
{!collapsed && item.shortcut && (
<span className="ml-auto font-mono text-[10px] uppercase tracking-widest text-muted-foreground/70">
{item.shortcut}
</span>
)}
</>
)}
</NavLink>
);
if (!collapsed) return <li>{link}</li>;
return (
<li>
<Tooltip>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
</li>
);
}
function PromoCard({ collapsed }: { collapsed: boolean }) {
if (collapsed) return null;
return (
<div className="relative overflow-hidden rounded-xl border border-border/70 bg-gradient-to-br from-primary/8 via-transparent to-transparent p-3">
<div className="absolute -right-6 -top-6 h-20 w-20 rounded-full bg-primary/15 blur-2xl" aria-hidden />
<div className="relative flex items-start gap-2">
<Sparkles className="mt-0.5 h-4 w-4 text-primary" />
<div className="space-y-0.5">
<div className="text-[11px] font-medium text-foreground">AI Knowledge Console</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
Hybrid retrieval over your archive. Ask in plain language.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { Bell, Command as CommandIcon, HelpCircle, Search } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Breadcrumbs } from "@/layouts/Breadcrumbs";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/common/ThemeToggle";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useUiStore } from "@/stores/uiStore";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function Topbar() {
const openCommand = useUiStore((s) => s.openCommand);
const navigate = useNavigate();
return (
<header className="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border/70 bg-background/80 px-4 backdrop-blur lg:px-6">
<Breadcrumbs className="hidden md:flex" />
<div className="ml-auto flex flex-1 items-center justify-end gap-2 md:flex-none md:gap-3">
<button
onClick={openCommand}
className={cn(
"group hidden h-9 w-full max-w-[420px] items-center gap-3 rounded-xl border border-border/70 bg-surface px-3 text-left text-sm text-muted-foreground shadow-sm transition-colors hover:border-primary/40 hover:bg-card md:flex"
)}
>
<Search className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 truncate">Search documents, chunks, queries</span>
<kbd className="hidden items-center gap-0.5 rounded-md border border-border/80 bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] font-medium text-muted-foreground md:inline-flex">
<CommandIcon className="h-3 w-3" /> K
</kbd>
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={() => navigate("/search")}>
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search</TooltipContent>
</Tooltip>
<ThemeToggle />
<NotificationCenter />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="Help">
<HelpCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Docs & shortcuts</TooltipContent>
</Tooltip>
<UserMenu />
</div>
</header>
);
}
function NotificationCenter() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-sm" className="relative" aria-label="Notifications">
<Bell className="h-4 w-4" />
<span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-background" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold">Activity</div>
<Badge variant="muted" className="font-mono">3 new</Badge>
</div>
<ul className="divide-y divide-border/60 text-sm">
<li className="py-2.5">
<div className="flex items-center justify-between">
<span className="font-medium">Ingestion run #2284 completed</span>
<span className="text-[10px] text-muted-foreground">2m</span>
</div>
<div className="text-xs text-muted-foreground">482 docs · 9 failures</div>
</li>
<li className="py-2.5">
<div className="flex items-center justify-between">
<span className="font-medium">Reranker model warmed up</span>
<span className="text-[10px] text-muted-foreground">14m</span>
</div>
<div className="text-xs text-muted-foreground">BGE-reranker-v2-m3 loaded on CPU</div>
</li>
<li className="py-2.5">
<div className="flex items-center justify-between">
<span className="font-medium">Low OCR confidence queue grew</span>
<span className="text-[10px] text-muted-foreground">1h</span>
</div>
<div className="text-xs text-muted-foreground">17 new documents flagged for review</div>
</li>
</ul>
</PopoverContent>
</Popover>
);
}
function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full border border-border/70 bg-card pl-1 pr-3 py-1 text-left transition-colors hover:bg-muted ring-focus">
<div className="grid h-7 w-7 place-items-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
VM
</div>
<div className="hidden text-xs leading-tight md:block">
<div className="font-medium text-foreground">Vadim Malanov</div>
<div className="text-[10px] text-muted-foreground">Architect · TeamHUB</div>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Workspaces</DropdownMenuItem>
<DropdownMenuItem>API tokens</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>TeamHUB Suite</DropdownMenuLabel>
<DropdownMenuItem>Switch to QMS Hub</DropdownMenuItem>
<DropdownMenuItem>Switch to Project Hub</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,39 @@
import {
LayoutDashboard,
FileText,
Workflow,
Search,
ScanLine,
Table2,
ShieldCheck,
Activity,
Settings,
type LucideIcon,
} from "lucide-react";
export interface NavItem {
to: string;
label: string;
icon: LucideIcon;
group: "primary" | "operations" | "system";
shortcut?: string;
badge?: string;
}
export const NAV: NavItem[] = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, group: "primary", shortcut: "G D" },
{ to: "/documents", label: "Documents", icon: FileText, group: "primary", shortcut: "G O" },
{ to: "/search", label: "Search", icon: Search, group: "primary", shortcut: "G S" },
{ to: "/viewer", label: "Document Viewer", icon: ScanLine, group: "primary" },
{ to: "/ingestion", label: "Ingestion Jobs", icon: Workflow, group: "operations" },
{ to: "/tables-figures", label: "Tables & Figures", icon: Table2, group: "operations" },
{ to: "/quality", label: "Quality Control", icon: ShieldCheck, group: "operations", badge: "review" },
{ to: "/health", label: "System Health", icon: Activity, group: "system" },
{ to: "/settings", label: "Settings", icon: Settings, group: "system" },
];
export const GROUPS: Record<NavItem["group"], string> = {
primary: "Workspace",
operations: "Operations",
system: "System",
};

59
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,59 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number, decimals = 1): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(decimals).replace(/\.0$/, "")} ${units[i]}`;
}
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) return "—";
return new Intl.NumberFormat("en-US").format(value);
}
export function formatPercent(value: number, digits = 0): string {
return `${(value * 100).toFixed(digits)}%`;
}
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "—";
if (ms < 1000) return `${Math.round(ms)} ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)} s`;
const m = s / 60;
if (m < 60) return `${m.toFixed(1)} min`;
const h = m / 60;
return `${h.toFixed(1)} h`;
}
export function relativeTime(iso: string): string {
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const diffSec = Math.round((t - Date.now()) / 1000);
const abs = Math.abs(diffSec);
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (abs < 60) return rtf.format(diffSec, "second");
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), "minute");
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), "hour");
return rtf.format(Math.round(diffSec / 86400), "day");
}
export function truncate(text: string, max: number): string {
if (!text) return "";
return text.length > max ? text.slice(0, max - 1) + "…" : text;
}
export function classFromConfidence(value: number | null | undefined): string {
if (value == null) return "text-muted-foreground";
if (value >= 0.85) return "text-success";
if (value >= 0.65) return "text-primary";
if (value >= 0.45) return "text-warning";
return "text-destructive";
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "@/app/App";
import "@/styles/globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,113 @@
import { FileText, Layers, ShieldAlert, Sparkles, Cpu, Database } from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
import { Button } from "@/components/ui/button";
import { KpiCard } from "@/widgets/KpiCard";
import { IngestionStatsChart } from "@/widgets/IngestionStatsChart";
import { OCRQualityWidget } from "@/widgets/OCRQualityWidget";
import { QueueWidget } from "@/widgets/QueueWidget";
import { StorageWidget } from "@/widgets/StorageWidget";
import { ServiceHealthCard } from "@/widgets/ServiceHealthCard";
import { RecentRunsWidget } from "@/widgets/RecentRunsWidget";
import { useDashboardStats } from "@/hooks/useDocuments";
import { formatBytes, formatNumber, formatPercent } from "@/lib/utils";
export function DashboardPage() {
const { data } = useDashboardStats();
return (
<>
<PageHeader
title="Knowledge operations dashboard"
description="Live view of ingestion throughput, OCR quality, and the search backbone powering the TeamHUB suite."
actions={
<>
<Button variant="outline" size="sm">Export snapshot</Button>
<Button size="sm" className="gap-1.5">
<Sparkles className="h-3.5 w-3.5" />
Open AI Search
</Button>
</>
}
/>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="Indexed documents"
value={formatNumber(data?.total_documents ?? 0)}
helper={`${formatNumber(data?.total_pages ?? 0)} pages · ${formatNumber(data?.total_chunks ?? 0)} chunks`}
delta={4.2}
icon={<FileText className="h-4 w-4" />}
tone="primary"
trend={data?.daily_ingest.slice(-12).map((d) => d.ingested) ?? []}
/>
<KpiCard
label="OCR confidence"
value={formatPercent(data?.avg_ocr_confidence ?? 0, 1)}
helper="weighted by page count"
delta={1.3}
icon={<Layers className="h-4 w-4" />}
tone="success"
trend={data?.ocr_distribution.map((d) => d.count) ?? []}
/>
<KpiCard
label="Needs manual review"
value={formatNumber(data?.needs_review ?? 0)}
helper="handwriting, garbled, or low confidence"
delta={-2.6}
icon={<ShieldAlert className="h-4 w-4" />}
tone="warning"
/>
<KpiCard
label="Failed documents"
value={formatNumber(data?.failed_documents ?? 0)}
helper="retryable via reindex"
delta={-1.4}
icon={<Cpu className="h-4 w-4" />}
tone="destructive"
/>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="xl:col-span-2">
<IngestionStatsChart data={data?.daily_ingest ?? []} />
</div>
<QueueWidget />
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="xl:col-span-2">
<StorageWidget
totalBytes={data?.total_storage_bytes ?? 0}
growth={data?.storage_growth ?? []}
/>
</div>
<OCRQualityWidget
distribution={data?.ocr_distribution ?? []}
avg={data?.avg_ocr_confidence ?? 0}
/>
</section>
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="xl:col-span-2">
<RecentRunsWidget />
</div>
<div className="space-y-4">
<ServiceHealthCard />
<div className="panel p-5">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<div className="text-sm font-semibold">Storage</div>
</div>
<div className="mt-2 text-2xl font-semibold tracking-tight">
{formatBytes(data?.total_storage_bytes ?? 0)}
</div>
<div className="text-xs text-muted-foreground">
MinIO originals + derived artifacts (Markdown, Docling JSON, page images)
</div>
</div>
</div>
</section>
</>
);
}

View File

@@ -0,0 +1,179 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { FileText, Image as ImageIcon, Layers, Table as TableIcon } from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { EmptyState } from "@/components/common/EmptyState";
import { ConfidenceMeter } from "@/components/common/ConfidenceMeter";
import { BlockTypeLabel } from "@/components/common/BlockTypeIcon";
import { QualityFlags } from "@/components/common/QualityFlag";
import { PdfPreviewPane } from "@/widgets/PdfPreviewPane";
import { DocumentTimeline } from "@/widgets/DocumentTimeline";
import { useDocument, useDocuments } from "@/hooks/useDocuments";
import { cn, formatBytes } from "@/lib/utils";
export function DocumentViewerPage() {
const { id } = useParams<{ id?: string }>();
const { data: list } = useDocuments({ page_size: 12, status: "INDEXING_COMPLETED" });
const fallbackId = list?.items[0]?.id;
const effectiveId = id ?? fallbackId;
const { data: doc, isLoading } = useDocument(effectiveId);
const [activePage, setActivePage] = useState<number>(1);
if (!effectiveId) {
return <EmptyState title="No document selected" description="Pick a document from the list to inspect." />;
}
if (isLoading || !doc) {
return (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_1fr]">
<div className="skeleton-shimmer h-[70vh] rounded-2xl" />
<div className="skeleton-shimmer h-[70vh] rounded-2xl" />
</div>
);
}
return (
<>
<PageHeader
title={doc.original_file_name}
description={`${doc.pages} pages · ${doc.chunks} chunks · ${formatBytes(doc.file_size_bytes)} · ${doc.language_hint ?? "—"}`}
actions={
<>
<Button size="sm" variant="outline">Download original</Button>
<Button size="sm">Re-index</Button>
</>
}
/>
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="muted" className="font-mono">SHA256 · {doc.sha256.slice(0, 12)}</Badge>
<Badge variant="outline" className="font-mono">{doc.source_path}</Badge>
<ConfidenceMeter value={doc.ocr_confidence} />
<QualityFlags flags={doc.flags} compact />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_1fr]">
<PdfPreviewPane
fileName={doc.original_file_name}
pages={doc.pages_data}
onPageChange={setActivePage}
/>
<Card className="flex h-full flex-col">
<CardHeader>
<CardTitle>Extracted structure</CardTitle>
<CardDescription>Docling output synchronized with the page above.</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<Tabs defaultValue="chunks" className="flex flex-1 flex-col">
<TabsList>
<TabsTrigger value="chunks">
<Layers className="h-3.5 w-3.5" /> Chunks
</TabsTrigger>
<TabsTrigger value="tables">
<TableIcon className="h-3.5 w-3.5" /> Tables
</TabsTrigger>
<TabsTrigger value="figures">
<ImageIcon className="h-3.5 w-3.5" /> Figures
</TabsTrigger>
<TabsTrigger value="metadata">
<FileText className="h-3.5 w-3.5" /> Metadata
</TabsTrigger>
</TabsList>
<TabsContent value="chunks" className="flex-1 overflow-hidden">
<ScrollArea className="h-[460px] pr-2">
<div className="space-y-2">
{doc.chunks_data.map((c) => (
<button
key={c.id}
onClick={() => setActivePage(c.page_number)}
className={cn(
"block w-full rounded-xl border border-border/70 bg-card px-3 py-2.5 text-left transition-colors hover:border-primary/40",
c.page_number === activePage && "border-primary/60 bg-accent/30"
)}
>
<div className="flex items-center gap-2 text-xs">
<BlockTypeLabel type={c.block_type} />
<span className="font-mono text-muted-foreground">p.{c.page_number}</span>
<Badge variant="outline" className="ml-auto font-mono">
#{c.chunk_index}
</Badge>
</div>
<p className="mt-1.5 line-clamp-3 text-[13px] leading-relaxed text-foreground/90">
{c.text}
</p>
</button>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="tables">
<div className="space-y-3">
{doc.tables.map((t) => (
<Card key={t.id}>
<CardHeader>
<CardTitle className="text-sm">Table {t.table_index + 1}</CardTitle>
<CardDescription>{t.summary}</CardDescription>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-md border border-border/70 bg-muted/30 p-3 font-mono text-[12px] leading-relaxed text-foreground">
{t.markdown}
</pre>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="figures">
<div className="grid grid-cols-2 gap-3">
{doc.figures.map((f) => (
<Card key={f.id}>
<CardContent className="space-y-2 p-3">
<div className="bg-grid-faint relative aspect-video rounded-lg border border-border/60" />
<div className="flex items-center justify-between text-xs">
<Badge variant="outline" className="font-mono">p.{f.page_number}</Badge>
<Badge variant="muted">figure #{f.figure_index + 1}</Badge>
</div>
<p className="text-xs text-muted-foreground">{f.caption ?? f.description}</p>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="metadata">
<div className="grid grid-cols-2 gap-3 text-sm">
{[
["Document ID", doc.id],
["SHA256", doc.sha256],
["Source path", doc.source_path],
["Language hint", doc.language_hint ?? "—"],
["Pages", doc.pages],
["Chunks", doc.chunks],
["Status", doc.status],
["Size", formatBytes(doc.file_size_bytes)],
].map(([k, v]) => (
<div key={String(k)} className="rounded-xl border border-border/70 bg-card px-3 py-2">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{k}</div>
<div className="mt-0.5 truncate font-mono text-xs">{String(v)}</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
<DocumentTimeline events={doc.timeline} />
</>
);
}

View File

@@ -0,0 +1,197 @@
import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useVirtualizer } from "@tanstack/react-virtual";
import { FileText, Filter, Inbox, Search, SlidersHorizontal } from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ConfidenceMeter } from "@/components/common/ConfidenceMeter";
import { StatusChip, statusToTone } from "@/components/common/StatusChip";
import { QualityFlags } from "@/components/common/QualityFlag";
import { EmptyState } from "@/components/common/EmptyState";
import { useDocuments } from "@/hooks/useDocuments";
import { useDebounce } from "@/hooks/useDebounce";
import { formatBytes, formatNumber, relativeTime } from "@/lib/utils";
export function DocumentsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [status, setStatus] = useState<string>("any");
const [needsReview, setNeedsReview] = useState(false);
const debouncedQuery = useDebounce(query, 220);
const { data, isLoading } = useDocuments({
q: debouncedQuery,
status: status === "any" ? undefined : status,
needs_review: needsReview,
page_size: 200,
});
const items = data?.items ?? [];
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
overscan: 12,
});
const totals = useMemo(
() => ({
docs: data?.total ?? 0,
pages: items.reduce((a, d) => a + d.pages, 0),
chunks: items.reduce((a, d) => a + d.chunks, 0),
}),
[data?.total, items]
);
return (
<>
<PageHeader
title="Documents"
description="Browse, filter, and inspect every PDF the platform has touched."
actions={
<>
<Button variant="outline" size="sm">
Bulk actions
</Button>
<Button size="sm" className="gap-1.5">
<Inbox className="h-3.5 w-3.5" />
Trigger ingestion
</Button>
</>
}
/>
<Card className="overflow-hidden">
<div className="flex flex-col gap-3 border-b border-border/70 px-4 py-3 lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by file name, source path, hash…"
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-44">
<Filter className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any status</SelectItem>
<SelectItem value="INDEXING_COMPLETED">Indexed</SelectItem>
<SelectItem value="EXTRACTION_COMPLETED">Extracted</SelectItem>
<SelectItem value="OCR_FAILED">OCR failed</SelectItem>
<SelectItem value="EXTRACTION_FAILED">Extraction failed</SelectItem>
<SelectItem value="FAILED">Failed</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
variant={needsReview ? "subtle" : "outline"}
onClick={() => setNeedsReview((v) => !v)}
className="gap-1.5"
>
<SlidersHorizontal className="h-3.5 w-3.5" />
Needs review
</Button>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border/70 bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-x-3 gap-y-1">
<span>
<span className="font-mono text-foreground">{formatNumber(totals.docs)}</span> documents
</span>
<span>
<span className="font-mono text-foreground">{formatNumber(totals.pages)}</span> pages
</span>
<span>
<span className="font-mono text-foreground">{formatNumber(totals.chunks)}</span> chunks
</span>
</div>
<Badge variant="muted" className="font-mono">virtualized</Badge>
</div>
<div ref={parentRef} className="max-h-[640px] overflow-y-auto scrollbar-thin">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-card text-[10px] uppercase tracking-wide text-muted-foreground">
<tr className="border-b border-border/70">
<th className="px-4 py-2 text-left font-medium">Document</th>
<th className="px-4 py-2 text-left font-medium">Status</th>
<th className="px-4 py-2 text-left font-medium">OCR</th>
<th className="px-4 py-2 text-left font-medium">Flags</th>
<th className="px-4 py-2 text-right font-medium">Pages</th>
<th className="px-4 py-2 text-right font-medium">Chunks</th>
<th className="px-4 py-2 text-right font-medium">Size</th>
<th className="px-4 py-2 text-right font-medium">Updated</th>
</tr>
</thead>
<tbody>
<tr style={{ height: rowVirtualizer.getTotalSize() }}>
<td colSpan={8} className="p-0">
<div className="relative" style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map((v) => {
const d = items[v.index];
return (
<button
key={d.id}
onClick={() => navigate(`/viewer/${d.id}`)}
className="group absolute left-0 right-0 grid w-full grid-cols-[1.5fr_0.9fr_0.9fr_1fr_0.5fr_0.5fr_0.6fr_0.7fr] items-center gap-x-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/40 border-b border-border/60"
style={{ transform: `translateY(${v.start}px)`, height: `${v.size}px` }}
>
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid h-9 w-9 place-items-center rounded-lg border border-border/70 bg-card text-primary">
<FileText className="h-4 w-4" />
</span>
<div className="min-w-0 leading-tight">
<div className="truncate text-sm font-medium text-foreground">
{d.original_file_name}
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">
{d.source_path}
</div>
</div>
</div>
<StatusChip tone={statusToTone(d.status)} label={d.status} />
<ConfidenceMeter value={d.ocr_confidence} />
<QualityFlags flags={d.flags} compact />
<div className="text-right font-mono text-xs tabular-nums">{d.pages}</div>
<div className="text-right font-mono text-xs tabular-nums">{d.chunks}</div>
<div className="text-right font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(d.file_size_bytes)}
</div>
<div className="text-right text-xs text-muted-foreground">
{relativeTime(d.updated_at)}
</div>
</button>
);
})}
</div>
</td>
</tr>
</tbody>
</table>
</div>
{!isLoading && items.length === 0 && (
<CardContent className="py-10">
<EmptyState
icon={<FileText className="h-5 w-5" />}
title="No documents match those filters"
description="Adjust your search, drop the status filter, or trigger a new ingestion run from the Operations panel."
/>
</CardContent>
)}
</Card>
</>
);
}

View File

@@ -0,0 +1,167 @@
import { useState } from "react";
import { Loader2, PlayCircle } from "lucide-react";
import { toast } from "sonner";
import { PageHeader } from "@/components/common/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { StatusChip, statusToTone } from "@/components/common/StatusChip";
import { Progress } from "@/components/ui/progress";
import { formatNumber, relativeTime } from "@/lib/utils";
import { useIngestionRuns, useStartIngestion } from "@/hooks/useIngestion";
export function IngestionJobsPage() {
const { data: runs, isLoading } = useIngestionRuns();
const start = useStartIngestion();
const [path, setPath] = useState("/data/input");
const [recursive, setRecursive] = useState(true);
const [force, setForce] = useState(false);
function submit() {
start.mutate(
{ path, recursive, force },
{
onSuccess: (res) => {
toast.success(`Run ${res.run_id.slice(0, 8)} queued`, {
description: `${res.queued} queued · ${res.skipped_duplicates} duplicates · ${res.invalid_files} invalid`,
});
},
onError: (err: unknown) =>
toast.error("Ingestion failed", {
description: err instanceof Error ? err.message : "Unknown error",
}),
}
);
}
return (
<>
<PageHeader
title="Ingestion jobs"
description="Schedule new ingestion runs and review the history of every batch operation."
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>New run</CardTitle>
<CardDescription>Discover PDFs, OCR, extract and index.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<label className="block space-y-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span>Source folder</span>
<Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="/data/input" />
</label>
<ToggleRow label="Recursive" hint="Walk into all subdirectories" checked={recursive} onChange={setRecursive} />
<ToggleRow
label="Force re-process"
hint="Re-run pipeline for already-known SHA256 hashes"
checked={force}
onChange={setForce}
/>
<Button onClick={submit} disabled={start.isPending} className="w-full">
{start.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Queuing
</>
) : (
<>
<PlayCircle className="h-4 w-4" /> Start ingestion
</>
)}
</Button>
</CardContent>
</Card>
<div className="xl:col-span-2">
<Card>
<CardHeader>
<CardTitle>Run history</CardTitle>
<CardDescription>Most recent jobs across all sources</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="text-[10px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-left font-medium">Source</th>
<th className="px-3 py-2 text-left font-medium">Progress</th>
<th className="px-3 py-2 text-right font-medium">Failed</th>
<th className="px-3 py-2 text-right font-medium">Started</th>
<th className="px-3 py-2 text-right font-medium">Finished</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{isLoading &&
Array.from({ length: 4 }).map((_, i) => (
<tr key={i}>
<td colSpan={6} className="px-3 py-2">
<div className="skeleton-shimmer h-7 w-full rounded" />
</td>
</tr>
))}
{runs?.map((r) => {
const pct = r.total_files > 0 ? Math.round((r.processed_files / r.total_files) * 100) : 0;
return (
<tr key={r.id} className="transition-colors hover:bg-muted/30">
<td className="px-3 py-2.5">
<StatusChip tone={statusToTone(r.status)} label={r.status} />
</td>
<td className="px-3 py-2.5 font-mono text-xs text-muted-foreground">
{r.source_folder}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<Progress value={pct} className="h-1.5 w-40" />
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{formatNumber(r.processed_files)}/{formatNumber(r.total_files)}
</span>
</div>
</td>
<td className="px-3 py-2.5 text-right font-mono text-xs tabular-nums">
<span className={r.failed_files > 0 ? "text-destructive" : "text-muted-foreground"}>
{formatNumber(r.failed_files)}
</span>
</td>
<td className="px-3 py-2.5 text-right text-xs text-muted-foreground">
{relativeTime(r.started_at)}
</td>
<td className="px-3 py-2.5 text-right text-xs text-muted-foreground">
{r.finished_at ? relativeTime(r.finished_at) : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
</div>
</div>
</>
);
}
function ToggleRow({
label,
hint,
checked,
onChange,
}: {
label: string;
hint: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-3 rounded-xl border border-border/70 bg-muted/20 p-3">
<div className="leading-tight">
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</label>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from "react";
import { CheckCircle2, FileWarning, PenLine, ShieldCheck } from "lucide-react";
import { motion } from "framer-motion";
import { PageHeader } from "@/components/common/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConfidenceMeter } from "@/components/common/ConfidenceMeter";
import { QualityFlags } from "@/components/common/QualityFlag";
import { useQualityQueue } from "@/hooks/useQuality";
import { relativeTime } from "@/lib/utils";
import type { QualityQueueKind } from "@/services/quality";
const TABS: { kind: QualityQueueKind; label: string; icon: typeof PenLine; tone: string }[] = [
{ kind: "low_confidence", label: "Low confidence", icon: FileWarning, tone: "text-warning" },
{ kind: "handwriting", label: "Handwriting", icon: PenLine, tone: "text-destructive" },
{ kind: "failed", label: "Failed extraction", icon: ShieldCheck, tone: "text-destructive" },
];
export function QualityControlPage() {
const [kind, setKind] = useState<QualityQueueKind>("low_confidence");
const { data, isLoading } = useQualityQueue(kind);
return (
<>
<PageHeader
title="Quality control"
description="Review queues for handwriting detection, low-confidence OCR, and failed extractions."
actions={
<Button variant="outline" size="sm">Export audit log</Button>
}
/>
<Tabs value={kind} onValueChange={(v) => setKind(v as QualityQueueKind)}>
<TabsList>
{TABS.map(({ kind: k, label, icon: Icon, tone }) => (
<TabsTrigger key={k} value={k}>
<Icon className={"h-3.5 w-3.5 " + tone} />
{label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Card>
<CardHeader>
<CardTitle>Review queue</CardTitle>
<CardDescription>
{data?.length ?? 0} documents flagged · sorted by detection time
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{isLoading &&
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="skeleton-shimmer h-16 rounded-xl" />
))}
{data?.map((item, idx) => (
<motion.div
key={item.document.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.02 }}
className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-card px-3 py-3 transition-colors hover:border-primary/30"
>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-primary/8 text-primary">
<FileWarning className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-medium">{item.document.original_file_name}</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">
{item.document.source_path}
</div>
</div>
<Badge variant="warning" className="font-mono">
{item.pages_flagged} pages flagged
</Badge>
<ConfidenceMeter value={item.document.ocr_confidence} />
<QualityFlags flags={item.document.flags} compact />
<span className="text-xs text-muted-foreground">{relativeTime(item.detected_at)}</span>
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
<Button size="sm" variant="outline">Open viewer</Button>
<Button size="sm" className="gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" />
Mark reviewed
</Button>
</div>
</motion.div>
))}
</CardContent>
</Card>
<AuditLog />
</>
);
}
function AuditLog() {
const events = [
{ stage: "Manual review approved", message: "Vadim cleared Регламент_ТО_2014_1102.pdf", time: "5m" },
{ stage: "Reindex triggered", message: ОСТ_21.501-93_1003.pdf · reranker enabled", time: "32m" },
{ stage: "Handwriting flagged", message: урнал_ремонтов_1009.pdf · pages 4, 6, 11", time: "2h" },
{ stage: "Low confidence", message: "Архивный_отчет_1156.pdf · 17 chunks below threshold", time: "5h" },
];
return (
<Card>
<CardHeader>
<CardTitle>Audit log</CardTitle>
<CardDescription>Recent reviewer actions and automated flags</CardDescription>
</CardHeader>
<CardContent>
<ol className="relative ml-2 border-l border-border/70 pl-5">
{events.map((e, i) => (
<li key={i} className="relative pb-4 last:pb-0">
<span className="absolute -left-[7px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-card" />
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium">{e.stage}</div>
<span className="text-[11px] text-muted-foreground">{e.time} ago</span>
</div>
<div className="text-xs text-muted-foreground">{e.message}</div>
</li>
))}
</ol>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,336 @@
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight, Filter, Loader2, Search as SearchIcon, Sparkles, X } from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/common/EmptyState";
import { SearchResultCard } from "@/widgets/SearchResultCard";
import { ChunkPreview } from "@/widgets/ChunkPreview";
import { useSearchStore } from "@/stores/searchStore";
import { useSearchResults, useSuggestions } from "@/hooks/useSearch";
import { useDebounce } from "@/hooks/useDebounce";
import type { SearchMode } from "@/services/types";
import { cn, formatNumber } from "@/lib/utils";
export function SearchPage() {
const { query, mode, filters, setQuery, setMode, setFilters, pushHistory, history } = useSearchStore();
const [draft, setDraft] = useState(query);
const [activeId, setActiveId] = useState<string | null>(null);
const debounced = useDebounce(draft, 320);
useEffect(() => {
setQuery(debounced);
}, [debounced, setQuery]);
const { data: suggestions } = useSuggestions(draft);
const { data, isFetching } = useSearchResults({
query,
mode,
filters,
limit: 20,
enabled: query.trim().length > 0,
});
const results = data?.results ?? [];
const active = useMemo(
() => results.find((r) => r.chunk_id === activeId) ?? results[0] ?? null,
[results, activeId]
);
useEffect(() => {
if (results.length > 0 && !activeId) setActiveId(results[0].chunk_id);
}, [results, activeId]);
function submit(value?: string) {
const q = (value ?? draft).trim();
if (!q) return;
setQuery(q);
pushHistory(q);
}
return (
<>
<PageHeader
title="AI knowledge search"
description="Hybrid lexical + semantic retrieval with BGE reranking over the entire archive."
actions={
<Badge variant="muted" className="font-mono">
<Sparkles className="h-3 w-3 text-primary" /> hybrid · BGE-M3
</Badge>
}
/>
<Card className="overflow-visible">
<CardContent className="space-y-4 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative flex-1">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder='Ask in plain language, e.g. "ГОСТ 21.501-93 рабочие чертежи"'
className="h-12 rounded-xl pl-9 pr-32 text-base"
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Button size="sm" className="gap-1.5" onClick={() => submit()}>
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowRight className="h-3.5 w-3.5" />}
Search
</Button>
</div>
</div>
<Tabs value={mode} onValueChange={(v) => setMode(v as SearchMode)}>
<TabsList>
<TabsTrigger value="hybrid">Hybrid</TabsTrigger>
<TabsTrigger value="lexical">Lexical</TabsTrigger>
<TabsTrigger value="semantic">Semantic</TabsTrigger>
</TabsList>
</Tabs>
<FiltersPopover />
</div>
{suggestions && draft.length === 0 && (
<Suggestions items={[...history, ...suggestions].slice(0, 6)} onPick={(v) => { setDraft(v); submit(v); }} />
)}
{suggestions && draft.length > 0 && suggestions.length > 0 && (
<Suggestions items={suggestions} onPick={(v) => { setDraft(v); submit(v); }} />
)}
<ActiveFilters />
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.2fr_1fr]">
<ScrollArea className="max-h-[78vh]">
<div className="space-y-3 pr-1">
{query.trim() === "" ? (
<EmptyState
icon={<SearchIcon className="h-5 w-5" />}
title="Ask a question to begin"
description="Try ГОСТ codes, regulation IDs, project names, or natural language — the hybrid retriever handles all of them."
/>
) : isFetching && results.length === 0 ? (
<>
{Array.from({ length: 4 }).map((_, i) => (
<ResultSkeleton key={i} />
))}
</>
) : results.length === 0 ? (
<EmptyState title="No results" description="Try broadening the query or removing filters." />
) : (
<>
<ResultsHeader
totalCandidates={data?.total_candidates ?? 0}
reranked={Boolean(data?.reranked)}
shown={results.length}
/>
<AnimatePresence mode="popLayout">
{results.map((hit) => (
<SearchResultCard
key={hit.chunk_id}
hit={hit}
query={query}
active={hit.chunk_id === active?.chunk_id}
onSelect={() => setActiveId(hit.chunk_id)}
reranked={Boolean(data?.reranked)}
/>
))}
</AnimatePresence>
</>
)}
</div>
</ScrollArea>
<div className="hidden xl:block">
<ChunkPreview hit={active} query={query} />
</div>
</div>
</>
);
}
function ResultsHeader({
totalCandidates,
reranked,
shown,
}: {
totalCandidates: number;
reranked: boolean;
shown: number;
}) {
return (
<div className="flex flex-wrap items-center gap-2 px-1 text-xs text-muted-foreground">
<span>
Showing <span className="font-mono text-foreground">{shown}</span> of{" "}
<span className="font-mono text-foreground">{formatNumber(totalCandidates)}</span> candidates
</span>
<span>·</span>
<Badge variant={reranked ? "default" : "muted"} className="font-mono">
{reranked ? "BGE reranker active" : "raw RRF order"}
</Badge>
</div>
);
}
function Suggestions({
items,
onPick,
}: {
items: string[];
onPick: (q: string) => void;
}) {
if (!items.length) return null;
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">Try</span>
{Array.from(new Set(items)).map((s) => (
<button
key={s}
onClick={() => onPick(s)}
className={cn(
"group rounded-full border border-border/70 bg-muted/40 px-3 py-1 text-xs text-foreground/90 transition-colors",
"hover:border-primary/50 hover:bg-accent/40 hover:text-primary-700 dark:hover:text-primary-100"
)}
>
{s}
</button>
))}
</div>
);
}
function FiltersPopover() {
const filters = useSearchStore((s) => s.filters);
const setFilters = useSearchStore((s) => s.setFilters);
const reset = useSearchStore((s) => s.reset);
const activeCount = Object.values(filters).filter((v) => v !== null && v !== undefined && v !== "").length;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="default" className="gap-1.5">
<Filter className="h-3.5 w-3.5" />
Filters
{activeCount > 0 && <Badge className="ml-1">{activeCount}</Badge>}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">Refine results</div>
<button
onClick={reset}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
Clear all
</button>
</div>
<div className="space-y-2">
<Label>Block type</Label>
<Select
value={filters.block_type ?? "any"}
onValueChange={(v) => setFilters({ block_type: v === "any" ? null : v })}
>
<SelectTrigger>
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="paragraph">Paragraph</SelectItem>
<SelectItem value="heading">Heading</SelectItem>
<SelectItem value="table">Table</SelectItem>
<SelectItem value="figure_caption">Figure caption</SelectItem>
<SelectItem value="list">List</SelectItem>
<SelectItem value="handwriting">Handwriting</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Min OCR confidence</Label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={100}
step={5}
value={Math.round((filters.min_ocr_confidence ?? 0) * 100)}
onChange={(e) => setFilters({ min_ocr_confidence: Number(e.target.value) / 100 || null })}
className="h-2 w-full appearance-none rounded-full bg-muted [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
/>
<span className="w-12 font-mono text-xs tabular-nums">
{filters.min_ocr_confidence ? `${Math.round(filters.min_ocr_confidence * 100)}%` : "any"}
</span>
</div>
</div>
<div className="space-y-2">
<Label>Source path</Label>
<Input
placeholder="/archive/scanned"
value={filters.source_path ?? ""}
onChange={(e) => setFilters({ source_path: e.target.value || null })}
/>
</div>
</PopoverContent>
</Popover>
);
}
function Label({ children }: { children: React.ReactNode }) {
return <div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{children}</div>;
}
function ActiveFilters() {
const filters = useSearchStore((s) => s.filters);
const setFilters = useSearchStore((s) => s.setFilters);
const chips = (Object.entries(filters) as [keyof typeof filters, unknown][])
.filter(([, v]) => v !== null && v !== undefined && v !== "")
.map(([k, v]) => ({ k, v }));
if (chips.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{chips.map(({ k, v }) => (
<Badge key={k} variant="muted" className="gap-1 pr-1.5">
{k}: <span className="font-mono">{String(v)}</span>
<button
onClick={() => setFilters({ [k]: null } as any)}
className="rounded-full p-0.5 hover:bg-muted-foreground/20"
aria-label={`Remove ${k} filter`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
);
}
function ResultSkeleton() {
return (
<div className="panel space-y-2.5 p-4">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-12" />
<Skeleton className="h-5 w-24" />
</div>
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState } from "react";
import { PageHeader } from "@/components/common/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { useUiStore } from "@/stores/uiStore";
export function SettingsPage() {
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
const [model, setModel] = useState("BAAI/bge-m3");
const [reranker, setReranker] = useState(true);
const [device, setDevice] = useState<"cpu" | "cuda" | "mps">("cpu");
return (
<>
<PageHeader title="Settings" description="Personal preferences and platform-wide configuration." />
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="appearance">Appearance</TabsTrigger>
<TabsTrigger value="search">Search</TabsTrigger>
<TabsTrigger value="integrations">Integrations</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Your TeamHUB SSO identity</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Name" value="Vadim Malanov" />
<Field label="Role" value="Architect" />
<Field label="Email" value="vadim.malanov@gmail.com" />
<Field label="Workspace" value="TeamHUB · LegacyHUB" />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="appearance">
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>Theme and density</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Row label="Theme">
<Select value={theme} onValueChange={(v) => setTheme(v as typeof theme)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</Row>
<Row label="Compact density">
<Switch />
</Row>
<Row label="Animated transitions">
<Switch defaultChecked />
</Row>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="search">
<Card>
<CardHeader>
<CardTitle>Search & retrieval</CardTitle>
<CardDescription>Embedding model, reranker, and hybrid weighting</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Row label="Embedding model">
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BAAI/bge-m3">BAAI/bge-m3 (dense, 1024)</SelectItem>
<SelectItem value="BAAI/bge-small-en">BAAI/bge-small-en</SelectItem>
</SelectContent>
</Select>
</Row>
<Row label="Device">
<Select value={device} onValueChange={(v) => setDevice(v as typeof device)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cpu">CPU</SelectItem>
<SelectItem value="cuda">CUDA</SelectItem>
<SelectItem value="mps">Apple MPS</SelectItem>
</SelectContent>
</Select>
</Row>
<Row label="BGE reranker">
<Switch checked={reranker} onCheckedChange={setReranker} />
</Row>
<Row label="RRF k constant">
<Input className="w-32" defaultValue={60} />
</Row>
<div className="flex justify-end">
<Button>Save changes</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="integrations">
<Card>
<CardHeader>
<CardTitle>TeamHUB Suite</CardTitle>
<CardDescription>Connected modules in the suite</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{[
{ name: "QMS Hub", status: "Connected" },
{ name: "Project Hub", status: "Connected" },
{ name: "Asset Hub", status: "Pending" },
].map((m) => (
<div key={m.name} className="flex items-center justify-between rounded-xl border border-border/70 bg-card px-3 py-2">
<div className="text-sm font-medium">{m.name}</div>
<Badge variant={m.status === "Connected" ? "success" : "muted"}>{m.status}</Badge>
</div>
))}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/40 px-3 py-2">
<div className="text-sm text-foreground">{label}</div>
{children}
</div>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border/70 bg-card px-3 py-2">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-0.5 text-sm">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { PageHeader } from "@/components/common/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatusChip } from "@/components/common/StatusChip";
import { ServiceHealthCard } from "@/widgets/ServiceHealthCard";
import { QueueWidget } from "@/widgets/QueueWidget";
import { StorageWidget } from "@/widgets/StorageWidget";
import { useDashboardStats } from "@/hooks/useDocuments";
import { useHealth } from "@/hooks/useHealth";
export function SystemHealthPage() {
const { data } = useDashboardStats();
const { data: health } = useHealth();
return (
<>
<PageHeader
title="System health"
description="Backing services, queue metrics, throughput, and storage growth."
actions={
<Badge variant={health?.status === "ok" ? "success" : "warning"} className="font-mono">
v{health?.version ?? "—"}
</Badge>
}
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<ServiceHealthCard />
<QueueWidget />
<Card>
<CardHeader>
<CardTitle>Embeddings & reranker</CardTitle>
<CardDescription>Inference latency & queue depth</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Row label="Embedding model" value="BAAI/bge-m3" badge="cpu" />
<Row label="Reranker" value="BAAI/bge-reranker-v2-m3" badge="cpu" />
<Row label="Embedding p95" value="142 ms" tone="ok" />
<Row label="Reranker p95" value="380 ms" tone="warning" />
<Row label="Inference workers" value="2" />
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Throughput (24h)</CardTitle>
<CardDescription>Documents & chunks processed per minute</CardDescription>
</CardHeader>
<CardContent className="h-[260px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data?.throughput ?? []}>
<XAxis dataKey="time" tickLine={false} axisLine={false} fontSize={11} stroke="hsl(var(--muted-foreground))" />
<YAxis tickLine={false} axisLine={false} fontSize={11} stroke="hsl(var(--muted-foreground))" />
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 12,
fontSize: 12,
}}
/>
<Line type="monotone" dataKey="docs_per_min" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="chunks_per_min" stroke="hsl(var(--muted-foreground))" strokeWidth={1.5} dot={false} strokeDasharray="3 4" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
<StorageWidget totalBytes={data?.total_storage_bytes ?? 0} growth={data?.storage_growth ?? []} />
</div>
</>
);
}
function Row({
label,
value,
badge,
tone,
}: {
label: string;
value: string;
badge?: string;
tone?: "ok" | "warning";
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/40 px-3 py-2">
<div className="text-xs font-medium text-muted-foreground">{label}</div>
<div className="flex items-center gap-2 font-mono text-sm">
{value}
{badge && <Badge variant="muted" className="font-mono">{badge}</Badge>}
{tone && <StatusChip tone={tone === "ok" ? "ok" : "warning"} label={tone} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useState } from "react";
import { Image as ImageIcon, Table as TableIcon } from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { useDocuments, useDocument } from "@/hooks/useDocuments";
export function TablesFiguresPage() {
const { data: list } = useDocuments({ page_size: 8, status: "INDEXING_COMPLETED" });
const [docId, setDocId] = useState<string | undefined>(undefined);
const effective = docId ?? list?.items[0]?.id;
const { data: doc } = useDocument(effective);
return (
<>
<PageHeader
title="Tables & Figures"
description="Browse every structured artifact Docling extracted from your archive."
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[260px_1fr]">
<Card className="h-fit">
<CardHeader>
<CardTitle>Documents</CardTitle>
<CardDescription>Select a document to inspect</CardDescription>
</CardHeader>
<CardContent className="space-y-1.5">
{list?.items.map((d) => (
<button
key={d.id}
onClick={() => setDocId(d.id)}
className={
"flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-xs transition-colors " +
(effective === d.id
? "border-primary/60 bg-accent/40 text-foreground"
: "border-border/60 hover:bg-muted/40")
}
>
<div className="grid h-7 w-7 place-items-center rounded-md bg-primary/10 text-primary">
<TableIcon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<div className="truncate font-medium">{d.original_file_name}</div>
<div className="font-mono text-[10px] text-muted-foreground">
{d.pages} pages
</div>
</div>
</button>
))}
</CardContent>
</Card>
<Tabs defaultValue="tables" className="space-y-4">
<TabsList>
<TabsTrigger value="tables">
<TableIcon className="h-3.5 w-3.5" /> Tables
{doc && <Badge variant="muted" className="ml-1">{doc.tables.length}</Badge>}
</TabsTrigger>
<TabsTrigger value="figures">
<ImageIcon className="h-3.5 w-3.5" /> Figures
{doc && <Badge variant="muted" className="ml-1">{doc.figures.length}</Badge>}
</TabsTrigger>
</TabsList>
<TabsContent value="tables">
<div className="space-y-3">
{doc?.tables.map((t) => (
<Card key={t.id}>
<CardHeader>
<CardTitle className="text-sm">Table {t.table_index + 1}</CardTitle>
<CardDescription>
<Badge variant="outline" className="mr-2 font-mono">p.{t.page_number}</Badge>
{t.summary}
</CardDescription>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-lg border border-border/60 bg-muted/30 p-3 font-mono text-[12px] leading-relaxed">
{t.markdown}
</pre>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="figures">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{doc?.figures.map((f) => (
<Card key={f.id}>
<CardContent className="space-y-2 p-3">
<div className="bg-grid-faint relative aspect-video rounded-lg border border-border/60" />
<div className="flex items-center justify-between text-xs">
<Badge variant="outline" className="font-mono">p.{f.page_number}</Badge>
<Badge variant="muted">figure #{f.figure_index + 1}</Badge>
</div>
<p className="text-xs text-muted-foreground">{f.caption ?? f.description}</p>
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
</div>
</>
);
}

View File

@@ -0,0 +1,33 @@
import axios, { type AxiosInstance, type AxiosError } from "axios";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
export const apiClient: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: 60_000,
headers: { "Content-Type": "application/json" },
});
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const status = error.response?.status;
const message =
(error.response?.data as { detail?: string } | undefined)?.detail ?? error.message;
return Promise.reject(new ApiError(message, status, error));
}
);
export class ApiError extends Error {
status: number | undefined;
cause: unknown;
constructor(message: string, status: number | undefined, cause: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.cause = cause;
}
}
export const USE_MOCK =
(import.meta.env.VITE_USE_MOCK ?? "true").toString().toLowerCase() === "true";

View File

@@ -0,0 +1,71 @@
import { apiClient, USE_MOCK } from "@/services/apiClient";
import * as mock from "@/services/mock/mockData";
import type { DocumentDetail, DocumentSummary } from "@/services/types";
export interface DocumentListParams {
q?: string;
status?: string;
block_type?: string;
min_confidence?: number;
needs_review?: boolean;
page?: number;
page_size?: number;
}
export interface DocumentList {
items: DocumentSummary[];
total: number;
page: number;
page_size: number;
}
export async function listDocuments(params: DocumentListParams = {}): Promise<DocumentList> {
if (USE_MOCK) {
await delay();
let items = [...mock.documents];
if (params.q) {
const q = params.q.toLowerCase();
items = items.filter(
(d) =>
d.original_file_name.toLowerCase().includes(q) ||
d.source_path.toLowerCase().includes(q)
);
}
if (params.status) items = items.filter((d) => d.status === params.status);
if (params.min_confidence != null)
items = items.filter((d) => (d.ocr_confidence ?? 0) >= params.min_confidence!);
if (params.needs_review) items = items.filter((d) => d.flags.needs_manual_review);
const page = params.page ?? 1;
const pageSize = params.page_size ?? 25;
return {
items: items.slice((page - 1) * pageSize, page * pageSize),
total: items.length,
page,
page_size: pageSize,
};
}
const { data } = await apiClient.get<DocumentList>("/documents", { params });
return data;
}
export async function getDocument(id: string): Promise<DocumentDetail | undefined> {
if (USE_MOCK) {
await delay();
return mock.findDocument(id);
}
const { data } = await apiClient.get<DocumentDetail>(`/documents/${id}`);
return data;
}
export async function getDashboardStats() {
if (USE_MOCK) {
await delay();
return mock.dashboard;
}
const { data } = await apiClient.get("/dashboard/stats");
return data;
}
function delay(ms = 250) {
return new Promise<void>((r) => setTimeout(r, ms));
}

View File

@@ -0,0 +1,25 @@
import { apiClient, USE_MOCK } from "@/services/apiClient";
import * as mock from "@/services/mock/mockData";
import type { HealthResponse, QueueState } from "@/services/types";
export async function getHealth(): Promise<HealthResponse> {
if (USE_MOCK) {
await delay();
return mock.health;
}
const { data } = await apiClient.get<HealthResponse>("/health");
return data;
}
export async function getQueueState(): Promise<QueueState> {
if (USE_MOCK) {
await delay();
return mock.queue;
}
// Endpoint not yet implemented backend-side; fall back to mock.
return mock.queue;
}
function delay(ms = 220): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,27 @@
import { apiClient, USE_MOCK } from "@/services/apiClient";
import * as mock from "@/services/mock/mockData";
import type { IngestFolderRequest, IngestFolderResponse, IngestionRun } from "@/services/types";
export async function ingestFolder(req: IngestFolderRequest): Promise<IngestFolderResponse> {
if (USE_MOCK) {
await new Promise((r) => setTimeout(r, 600));
return {
run_id: crypto.randomUUID(),
discovered: 78,
queued: 72,
skipped_duplicates: 4,
invalid_files: 2,
};
}
const { data } = await apiClient.post<IngestFolderResponse>("/ingest/folder", req);
return data;
}
export async function listRuns(): Promise<IngestionRun[]> {
if (USE_MOCK) {
await new Promise((r) => setTimeout(r, 220));
return mock.ingestionRuns;
}
const { data } = await apiClient.get<IngestionRun[]>("/ingest/runs");
return data;
}

View File

@@ -0,0 +1,298 @@
import type {
ChunkSummary,
DashboardStats,
DocumentDetail,
DocumentStatus,
DocumentSummary,
FigureData,
HealthResponse,
IngestionRun,
PageSummary,
QueueState,
SearchHit,
SearchResponse,
TableData,
TimelineEvent,
} from "@/services/types";
const RNG = mulberry32(20260510);
function mulberry32(seed: number) {
return function () {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function uuid(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (RNG() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function pick<T>(items: readonly T[]): T {
return items[Math.floor(RNG() * items.length)];
}
function isoDaysAgo(days: number, jitterMin = 0): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() - days);
d.setUTCMinutes(d.getUTCMinutes() - Math.floor(RNG() * jitterMin));
return d.toISOString();
}
const FILE_PREFIXES = [
ОСТ_21.501-93",
"ТУ_5781-001-2010",
ертеж_фундамента",
"Спецификация_узлов",
"Регламент_ТО_2014",
урнал_ремонтов",
ОСорпус_3",
"Расчет_прочности",
"Схема_электропитания",
"Архивный_отчет",
];
const STATUSES: DocumentStatus[] = [
"INDEXING_COMPLETED",
"INDEXING_COMPLETED",
"INDEXING_COMPLETED",
"CHUNKING_COMPLETED",
"EXTRACTION_COMPLETED",
"OCR_COMPLETED",
"OCR_STARTED",
"OCR_FAILED",
"EXTRACTION_FAILED",
"FAILED",
];
function makeDocument(i: number): DocumentSummary {
const status = i % 17 === 0 ? "FAILED" : i % 11 === 0 ? "OCR_FAILED" : pick(STATUSES);
const ok = status === "INDEXING_COMPLETED";
const conf = ok ? 0.7 + RNG() * 0.28 : 0.3 + RNG() * 0.4;
const name = `${pick(FILE_PREFIXES)}_${String(1000 + i).slice(-4)}.pdf`;
return {
id: uuid(),
original_file_name: name,
source_path: `/archive/${pick(["raw", "scanned", "vendor"])}/${name}`,
sha256: Array.from({ length: 64 }, () => Math.floor(RNG() * 16).toString(16)).join(""),
status,
file_size_bytes: Math.floor(120_000 + RNG() * 25_000_000),
pages: Math.floor(3 + RNG() * 180),
chunks: Math.floor(8 + RNG() * 220),
ocr_confidence: conf,
language_hint: RNG() > 0.4 ? "ru" : "en",
created_at: isoDaysAgo(Math.floor(RNG() * 30), 1440),
updated_at: isoDaysAgo(Math.floor(RNG() * 5), 720),
flags: {
low_ocr_confidence: conf < 0.6,
possible_garbled_text: RNG() > 0.9,
table_detected: RNG() > 0.55,
figure_detected: RNG() > 0.7,
handwriting_detected: RNG() > 0.92,
needs_manual_review: !ok || conf < 0.6,
},
};
}
export const documents: DocumentSummary[] = Array.from({ length: 280 }, (_, i) => makeDocument(i));
export function findDocument(id: string): DocumentDetail | undefined {
const doc = documents.find((d) => d.id === id);
if (!doc) return undefined;
const pages: PageSummary[] = Array.from({ length: Math.min(doc.pages, 24) }, (_, p) => ({
page_number: p + 1,
text: `Страница ${p + 1}. Образец извлечённого текста для ${doc.original_file_name}. ГОСТ 21.501-93 определяет правила выполнения архитектурно-строительных рабочих чертежей.`,
ocr_confidence: Math.min(1, (doc.ocr_confidence ?? 0.7) + (RNG() - 0.5) * 0.2),
has_tables: RNG() > 0.6,
has_figures: RNG() > 0.75,
has_handwriting: RNG() > 0.92,
}));
const chunks: ChunkSummary[] = Array.from({ length: Math.min(doc.chunks, 40) }, (_, c) => ({
id: uuid(),
document_id: doc.id,
page_number: 1 + Math.floor(c / 3),
block_type: pick([
"paragraph",
"paragraph",
"heading",
"list",
"table",
"figure_caption",
"title",
]),
block_id: `block-${c}`,
chunk_index: c,
text: `Фрагмент ${c}. ${pick([
"Описание узлов и сопряжений несущих конструкций.",
"Требования к точности изготовления железобетонных изделий.",
"Перечень используемых материалов с указанием марок и ГОСТ.",
"Указания по производству работ при пониженных температурах.",
"Контрольные размеры приведены в таблице на следующей странице.",
])}`,
token_count: 80 + Math.floor(RNG() * 600),
quality_flags: { ...doc.flags },
metadata: { section_heading: "Глава 2. Основные положения" },
}));
const tables: TableData[] = Array.from({ length: 4 }, (_, t) => ({
id: uuid(),
page_number: 3 + t * 2,
table_index: t,
markdown: `| Параметр | Значение | Ед. |\n| --- | --- | --- |\n| Высота | 3.4 | м |\n| Толщина | 200 | мм |\n| Класс | B25 | — |`,
summary: `Table ${t} on page ${3 + t * 2}: 3 rows × 3 cols. Columns: Параметр, Значение, Ед.`,
}));
const figures: FigureData[] = Array.from({ length: 3 }, (_, f) => ({
id: uuid(),
page_number: 5 + f * 3,
figure_index: f,
caption: `Рисунок ${f + 1}. Схема расположения узлов`,
description: `Figure ${f + 1} detected on page ${5 + f * 3}.`,
}));
const stages = [
"DISCOVERED",
"STORED_ORIGINAL",
"OCR_STARTED",
"OCR_COMPLETED",
"EXTRACTION_STARTED",
"EXTRACTION_COMPLETED",
"CHUNKING_COMPLETED",
"INDEXING_COMPLETED",
];
const timeline: TimelineEvent[] = stages.map((s, i) => ({
id: uuid(),
stage: s,
level: "INFO",
message: `${s.replaceAll("_", " ")} completed`,
data: {},
created_at: isoDaysAgo(0, 60 * (stages.length - i)),
}));
return { ...doc, pages_data: pages, chunks_data: chunks, tables, figures, timeline };
}
export function searchMock(query: string, mode: string, limit: number): SearchResponse {
const q = query.toLowerCase();
const candidates = documents
.filter((d) => d.status === "INDEXING_COMPLETED")
.slice(0, 80);
const results: SearchHit[] = candidates.slice(0, limit).map((d, i) => {
const score = Math.max(0.05, 1 - i * 0.04 - RNG() * 0.05);
const block_type = pick(["paragraph", "heading", "table", "figure_caption", "list"]);
return {
rank: i + 1,
score,
document_id: d.id,
chunk_id: uuid(),
original_file_name: d.original_file_name,
source_path: d.source_path,
page_number: 1 + Math.floor(RNG() * d.pages),
block_type,
text: highlightedSnippet(query),
citation: {
pdf: d.original_file_name,
page: 1 + Math.floor(RNG() * d.pages),
block_id: `block-${i}`,
},
quality_flags: d.flags,
metadata: { section_heading: "Глава 2", mode, q },
};
});
return {
query,
mode: mode as SearchResponse["mode"],
total_candidates: candidates.length,
reranked: mode === "hybrid",
results,
};
}
function highlightedSnippet(query: string): string {
const q = query.trim() || "ГОСТ";
return (
`Согласно ${q}, требования к выполнению рабочих чертежей определяются разделом 4. ` +
`Все размеры приведены в миллиметрах, если иное не указано. Ссылка на смежные документы — ` +
`${q} приложение Б. Контрольные значения должны соответствовать таблице 5.1.`
);
}
export const health: HealthResponse = {
status: "ok",
version: "0.1.0",
components: [
{ name: "postgres", status: "ok", detail: { latency_ms: 4 } },
{ name: "minio", status: "ok", detail: { buckets: ["legacyhub-originals", "legacyhub-derived"] } },
{ name: "opensearch", status: "ok", detail: { cluster_status: "yellow", nodes: 1 } },
{ name: "qdrant", status: "ok", detail: { collections: ["legacy_chunks"] } },
{ name: "redis", status: "ok", detail: {} },
],
};
export const queue: QueueState = {
pending: 1234,
in_progress: 16,
completed_last_hour: 482,
failed_last_hour: 9,
average_latency_ms: 12_400,
};
export const ingestionRuns: IngestionRun[] = Array.from({ length: 12 }, (_, i) => {
const total = 200 + Math.floor(RNG() * 1500);
const failed = Math.floor(total * (RNG() * 0.05));
return {
id: uuid(),
started_at: isoDaysAgo(i),
finished_at: i === 0 ? null : isoDaysAgo(i - 0.1),
status: i === 0 ? "RUNNING" : failed > total * 0.03 ? "PARTIAL" : "COMPLETED",
source_folder: pick(["/data/input/2024", "/data/input/2023", "/data/input/archive"]),
total_files: total,
processed_files: total - failed,
failed_files: failed,
};
});
export const dashboard: DashboardStats = (() => {
const byStatus = documents.reduce((acc, d) => {
acc[d.status] = (acc[d.status] ?? 0) + 1;
return acc;
}, {} as Record<DocumentStatus, number>);
const totalChunks = documents.reduce((a, d) => a + d.chunks, 0);
const totalPages = documents.reduce((a, d) => a + d.pages, 0);
return {
total_documents: documents.length,
total_pages: totalPages,
total_chunks: totalChunks,
total_storage_bytes: documents.reduce((a, d) => a + d.file_size_bytes, 0),
failed_documents: (byStatus.FAILED ?? 0) + (byStatus.OCR_FAILED ?? 0) + (byStatus.EXTRACTION_FAILED ?? 0),
needs_review: documents.filter((d) => d.flags.needs_manual_review).length,
avg_ocr_confidence:
documents.reduce((a, d) => a + (d.ocr_confidence ?? 0), 0) / documents.length,
processed_last_24h: 482,
by_status: byStatus,
daily_ingest: Array.from({ length: 14 }, (_, i) => ({
date: new Date(Date.now() - (13 - i) * 86_400_000).toISOString().slice(0, 10),
ingested: 120 + Math.floor(RNG() * 280),
failed: Math.floor(RNG() * 18),
})),
ocr_distribution: [
{ bucket: "0.0-0.4", count: 18 },
{ bucket: "0.4-0.6", count: 42 },
{ bucket: "0.6-0.75", count: 76 },
{ bucket: "0.75-0.85", count: 84 },
{ bucket: "0.85-0.95", count: 41 },
{ bucket: "0.95-1.0", count: 19 },
],
storage_growth: Array.from({ length: 14 }, (_, i) => ({
date: new Date(Date.now() - (13 - i) * 86_400_000).toISOString().slice(0, 10),
bytes: (3 + i * 0.4 + RNG()) * 1024 * 1024 * 1024,
})),
throughput: Array.from({ length: 24 }, (_, i) => ({
time: `${String(i).padStart(2, "0")}:00`,
docs_per_min: 4 + Math.floor(RNG() * 18),
chunks_per_min: 80 + Math.floor(RNG() * 420),
})),
};
})();

View File

@@ -0,0 +1,42 @@
import { USE_MOCK } from "@/services/apiClient";
import * as mock from "@/services/mock/mockData";
import type { DocumentSummary } from "@/services/types";
export type QualityQueueKind = "low_confidence" | "handwriting" | "failed";
export interface QualityQueueItem {
document: DocumentSummary;
reason: string;
pages_flagged: number;
detected_at: string;
}
export async function getQualityQueue(kind: QualityQueueKind): Promise<QualityQueueItem[]> {
if (USE_MOCK) {
await new Promise((r) => setTimeout(r, 200));
return mock.documents
.filter((d) => {
if (kind === "low_confidence") return (d.ocr_confidence ?? 1) < 0.6;
if (kind === "handwriting") return d.flags.handwriting_detected;
return d.status === "FAILED" || d.status === "OCR_FAILED" || d.status === "EXTRACTION_FAILED";
})
.slice(0, 40)
.map((d) => ({
document: d,
reason:
kind === "low_confidence"
? "OCR confidence below 60%"
: kind === "handwriting"
? "Handwritten fragments detected"
: `Pipeline failure (${d.status})`,
pages_flagged: Math.max(1, Math.floor(d.pages * 0.18)),
detected_at: d.updated_at,
}));
}
// Placeholder for real endpoint
return [];
}
export async function markReviewed(_documentId: string): Promise<void> {
await new Promise((r) => setTimeout(r, 200));
}

View File

@@ -0,0 +1,29 @@
import { apiClient, USE_MOCK } from "@/services/apiClient";
import * as mock from "@/services/mock/mockData";
import type { SearchRequest, SearchResponse } from "@/services/types";
export async function search(req: SearchRequest): Promise<SearchResponse> {
if (USE_MOCK) {
await new Promise((r) => setTimeout(r, 320));
return mock.searchMock(req.query, req.search_mode, req.limit);
}
const { data } = await apiClient.post<SearchResponse>("/search", req);
return data;
}
export async function suggest(query: string): Promise<string[]> {
// No backend endpoint yet — derive cheap suggestions from history + recent docs.
await new Promise((r) => setTimeout(r, 80));
const q = query.toLowerCase();
const base = [
"ГОСТ 21.501-93 рабочие чертежи",
"класс бетона B25",
"журнал ремонтов узлов",
"правила производства земляных работ",
"схема электропитания корпус 3",
"регламент ТО 2014",
"контроль качества сварных соединений",
];
if (!q) return base.slice(0, 5);
return base.filter((s) => s.toLowerCase().includes(q)).slice(0, 6);
}

View File

@@ -0,0 +1,209 @@
// Typed API contracts (mirror backend Pydantic schemas)
export type SearchMode = "lexical" | "semantic" | "hybrid";
export interface SearchFilters {
document_id: string | null;
source_path: string | null;
block_type: string | null;
min_ocr_confidence: number | null;
}
export interface SearchRequest {
query: string;
limit: number;
filters: SearchFilters;
search_mode: SearchMode;
}
export interface Citation {
pdf: string;
page: number;
block_id?: string | null;
table_id?: string | null;
figure_id?: string | null;
}
export interface QualityFlags {
low_ocr_confidence?: boolean;
very_short_text?: boolean;
possible_garbled_text?: boolean;
table_detected?: boolean;
figure_detected?: boolean;
handwriting_detected?: boolean;
needs_manual_review?: boolean;
}
export interface SearchHit {
rank: number;
score: number;
document_id: string;
chunk_id: string;
original_file_name: string;
source_path: string;
page_number: number;
block_type: string;
text: string;
citation: Citation;
quality_flags: QualityFlags;
metadata: Record<string, unknown>;
}
export interface SearchResponse {
query: string;
mode: SearchMode;
total_candidates: number;
reranked: boolean;
results: SearchHit[];
}
// Health
export interface ComponentHealth {
name: string;
status: "ok" | "error" | "degraded";
detail: Record<string, unknown>;
}
export interface HealthResponse {
status: "ok" | "error" | "degraded";
version: string;
components: ComponentHealth[];
}
// Documents
export type DocumentStatus =
| "DISCOVERED"
| "STORED_ORIGINAL"
| "OCR_STARTED"
| "OCR_COMPLETED"
| "OCR_FAILED"
| "EXTRACTION_STARTED"
| "EXTRACTION_COMPLETED"
| "EXTRACTION_FAILED"
| "CHUNKING_COMPLETED"
| "INDEXING_COMPLETED"
| "FAILED";
export interface DocumentSummary {
id: string;
original_file_name: string;
source_path: string;
sha256: string;
status: DocumentStatus;
file_size_bytes: number;
pages: number;
chunks: number;
ocr_confidence: number | null;
language_hint: string | null;
created_at: string;
updated_at: string;
flags: QualityFlags;
}
export interface PageSummary {
page_number: number;
text: string;
ocr_confidence: number | null;
has_tables: boolean;
has_figures: boolean;
has_handwriting: boolean;
}
export interface ChunkSummary {
id: string;
document_id: string;
page_number: number;
block_type: string;
block_id: string | null;
chunk_index: number;
text: string;
token_count: number | null;
quality_flags: QualityFlags;
metadata: Record<string, unknown>;
}
export interface DocumentDetail extends DocumentSummary {
pages_data: PageSummary[];
chunks_data: ChunkSummary[];
tables: TableData[];
figures: FigureData[];
timeline: TimelineEvent[];
}
export interface TableData {
id: string;
page_number: number;
table_index: number;
markdown: string;
summary: string | null;
}
export interface FigureData {
id: string;
page_number: number;
figure_index: number;
caption: string | null;
description: string | null;
}
export interface TimelineEvent {
id: string;
stage: string;
level: "INFO" | "WARN" | "ERROR";
message: string;
data: Record<string, unknown>;
created_at: string;
}
// Ingestion
export type RunStatus = "RUNNING" | "COMPLETED" | "FAILED" | "PARTIAL";
export interface IngestionRun {
id: string;
started_at: string;
finished_at: string | null;
status: RunStatus;
source_folder: string;
total_files: number;
processed_files: number;
failed_files: number;
}
export interface IngestFolderRequest {
path: string;
recursive: boolean;
force: boolean;
}
export interface IngestFolderResponse {
run_id: string;
discovered: number;
queued: number;
skipped_duplicates: number;
invalid_files: number;
}
// Dashboard / system
export interface DashboardStats {
total_documents: number;
total_pages: number;
total_chunks: number;
total_storage_bytes: number;
failed_documents: number;
needs_review: number;
avg_ocr_confidence: number;
processed_last_24h: number;
by_status: Record<DocumentStatus, number>;
daily_ingest: { date: string; ingested: number; failed: number }[];
ocr_distribution: { bucket: string; count: number }[];
storage_growth: { date: string; bytes: number }[];
throughput: { time: string; docs_per_min: number; chunks_per_min: number }[];
}
export interface QueueState {
pending: number;
in_progress: number;
completed_last_hour: number;
failed_last_hour: number;
average_latency_ms: number;
}

View File

@@ -0,0 +1,36 @@
import { create } from "zustand";
import type { SearchFilters, SearchMode } from "@/services/types";
interface SearchState {
query: string;
mode: SearchMode;
filters: SearchFilters;
history: string[];
setQuery: (q: string) => void;
setMode: (mode: SearchMode) => void;
setFilters: (filters: Partial<SearchFilters>) => void;
pushHistory: (q: string) => void;
reset: () => void;
}
const emptyFilters: SearchFilters = {
document_id: null,
source_path: null,
block_type: null,
min_ocr_confidence: null,
};
export const useSearchStore = create<SearchState>((set) => ({
query: "",
mode: "hybrid",
filters: emptyFilters,
history: [],
setQuery: (q) => set({ query: q }),
setMode: (mode) => set({ mode }),
setFilters: (filters) => set((s) => ({ filters: { ...s.filters, ...filters } })),
pushHistory: (q) =>
set((s) => ({
history: [q, ...s.history.filter((x) => x !== q)].slice(0, 12),
})),
reset: () => set({ query: "", filters: emptyFilters }),
}));

View File

@@ -0,0 +1,47 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type ThemeMode = "light" | "dark" | "system";
interface UiState {
theme: ThemeMode;
sidebarCollapsed: boolean;
commandOpen: boolean;
setTheme: (theme: ThemeMode) => void;
toggleSidebar: () => void;
setSidebar: (collapsed: boolean) => void;
openCommand: () => void;
closeCommand: () => void;
toggleCommand: () => void;
}
export const useUiStore = create<UiState>()(
persist(
(set) => ({
theme: "light",
sidebarCollapsed: false,
commandOpen: false,
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setSidebar: (collapsed) => set({ sidebarCollapsed: collapsed }),
openCommand: () => set({ commandOpen: true }),
closeCommand: () => set({ commandOpen: false }),
toggleCommand: () => set((s) => ({ commandOpen: !s.commandOpen })),
}),
{
name: "legacyhub-ui",
partialize: (s) => ({ theme: s.theme, sidebarCollapsed: s.sidebarCollapsed }),
}
)
);
export function applyTheme(theme: ThemeMode) {
const root = document.documentElement;
const effective =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
root.classList.toggle("dark", effective === "dark");
}

View File

@@ -0,0 +1,172 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* base */
--background: 210 40% 99%;
--foreground: 222 47% 11%;
--surface: 0 0% 100%;
--surface-raised: 0 0% 100%;
--surface-sunken: 210 40% 96%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
/* TeamHUB green accent */
--primary: 158 64% 32%;
--primary-foreground: 0 0% 100%;
--primary-50: 152 70% 97%;
--primary-100: 152 67% 92%;
--primary-200: 154 60% 84%;
--primary-500: 158 64% 42%;
--primary-600: 158 64% 36%;
--primary-700: 158 64% 28%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 152 67% 95%;
--accent-foreground: 158 64% 24%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning-foreground: 30 80% 20%;
--success: 158 64% 36%;
--success-foreground: 0 0% 100%;
--border: 215 20% 91%;
--input: 215 20% 91%;
--ring: 158 64% 42%;
--radius: 14px;
color-scheme: light;
}
.dark {
--background: 222 47% 5%;
--foreground: 210 40% 98%;
--surface: 222 47% 8%;
--surface-raised: 222 47% 11%;
--surface-sunken: 222 47% 4%;
--card: 222 47% 9%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 9%;
--popover-foreground: 210 40% 98%;
--primary: 158 64% 48%;
--primary-foreground: 222 47% 5%;
--primary-50: 158 50% 12%;
--primary-100: 158 50% 16%;
--primary-200: 158 50% 22%;
--primary-500: 158 64% 50%;
--primary-600: 158 64% 44%;
--primary-700: 158 64% 36%;
--secondary: 217 32% 14%;
--secondary-foreground: 210 40% 98%;
--muted: 217 32% 14%;
--muted-foreground: 215 20% 65%;
--accent: 158 50% 14%;
--accent-foreground: 158 60% 80%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 100%;
--warning: 38 92% 55%;
--warning-foreground: 30 80% 12%;
--success: 158 64% 48%;
--success-foreground: 222 47% 5%;
--border: 217 32% 17%;
--input: 217 32% 17%;
--ring: 158 64% 48%;
color-scheme: dark;
}
* {
@apply border-border;
}
html, body, #root {
@apply h-full;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: "ss01", "cv11", "tnum";
}
::selection {
@apply bg-primary/20 text-primary-foreground;
}
}
@layer components {
.glass {
background: hsl(var(--surface) / 0.72);
backdrop-filter: saturate(140%) blur(14px);
-webkit-backdrop-filter: saturate(140%) blur(14px);
border: 1px solid hsl(var(--border) / 0.7);
}
.glass-strong {
background: hsl(var(--surface) / 0.86);
backdrop-filter: saturate(150%) blur(20px);
-webkit-backdrop-filter: saturate(150%) blur(20px);
border: 1px solid hsl(var(--border) / 0.85);
}
.panel {
@apply rounded-2xl bg-card text-card-foreground shadow-soft border border-border/70;
}
.panel-raised {
@apply rounded-2xl bg-card text-card-foreground shadow-elevated border border-border/70;
}
.ring-focus {
@apply outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
.skeleton-shimmer {
background: linear-gradient(
90deg,
hsl(var(--muted)) 0%,
hsl(var(--muted) / 0.6) 50%,
hsl(var(--muted)) 100%
);
background-size: 200% 100%;
@apply animate-shimmer;
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 8px;
}
.grid-canvas {
background-image: var(--tw-bg-grid, none);
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.bg-grid-faint {
background-image:
linear-gradient(to right, hsl(var(--border) / 0.45) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--border) / 0.45) 1px, transparent 1px);
background-size: 32px 32px;
}
.mask-fade-b {
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}
}

View File

@@ -0,0 +1,108 @@
import { motion } from "framer-motion";
import { FileText } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ConfidenceMeter } from "@/components/common/ConfidenceMeter";
import { QualityFlags } from "@/components/common/QualityFlag";
import { Highlight } from "@/components/common/Highlight";
import { BlockTypeLabel } from "@/components/common/BlockTypeIcon";
import type { SearchHit } from "@/services/types";
interface Props {
hit: SearchHit | null;
query: string;
}
export function ChunkPreview({ hit, query }: Props) {
if (!hit) {
return (
<Card className="sticky top-20 h-full">
<CardContent className="grid h-full place-items-center p-10 text-center text-sm text-muted-foreground">
Select a result to preview the chunk, page thumbnail, and full citation.
</CardContent>
</Card>
);
}
return (
<motion.div
key={hit.chunk_id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="sticky top-20"
>
<Card>
<CardHeader>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<BlockTypeLabel type={hit.block_type} />
<span>·</span>
<span className="font-mono">page {hit.page_number}</span>
<span>·</span>
<span className="font-mono">chunk {hit.chunk_id.slice(0, 8)}</span>
</div>
<CardTitle className="flex items-baseline gap-2 truncate">
<FileText className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate">{hit.original_file_name}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<PageThumbnail page={hit.page_number} fileName={hit.original_file_name} />
<div className="rounded-xl border border-border/70 bg-muted/30 p-4 text-[13px] leading-relaxed text-foreground/90">
<Highlight text={hit.text} query={query} />
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<Field label="Source">{hit.source_path}</Field>
<Field label="Page">{hit.page_number}</Field>
<Field label="Block id">{hit.citation.block_id ?? "—"}</Field>
<Field label="Score">{hit.score.toFixed(4)}</Field>
</div>
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">Quality</div>
<QualityFlags flags={hit.quality_flags} />
</div>
<div className="flex items-center justify-between gap-2">
<ConfidenceMeter value={0.82} />
<div className="flex gap-2">
<Button size="sm" variant="outline">Open viewer</Button>
<Button size="sm">Copy citation</Button>
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border/60 bg-card px-3 py-2">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate font-mono text-xs text-foreground">{children}</div>
</div>
);
}
function PageThumbnail({ page, fileName }: { page: number; fileName: string }) {
return (
<div className="relative overflow-hidden rounded-xl border border-border/70 bg-card">
<div className="bg-grid-faint relative aspect-[3/4] w-full">
<div className="absolute inset-x-6 top-8 h-2 rounded bg-muted-foreground/20" />
<div className="absolute inset-x-6 top-14 h-2 w-2/3 rounded bg-muted-foreground/20" />
<div className="absolute inset-x-6 top-24 h-1.5 rounded bg-primary/30" />
<div className="absolute inset-x-6 top-28 h-1.5 w-5/6 rounded bg-muted-foreground/20" />
<div className="absolute inset-x-6 top-32 h-1.5 w-4/6 rounded bg-muted-foreground/20" />
<div className="absolute inset-x-6 bottom-12 h-12 rounded bg-muted-foreground/12 ring-1 ring-inset ring-primary/30" />
</div>
<div className="flex items-center justify-between border-t border-border/70 bg-card px-3 py-2 text-[11px] text-muted-foreground">
<span className="truncate font-medium">{fileName}</span>
<Badge variant="outline" className="font-mono">p.{page}</Badge>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { Check, CircleDashed } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { relativeTime } from "@/lib/utils";
import type { TimelineEvent } from "@/services/types";
const STAGES = [
"DISCOVERED",
"STORED_ORIGINAL",
"OCR_STARTED",
"OCR_COMPLETED",
"EXTRACTION_STARTED",
"EXTRACTION_COMPLETED",
"CHUNKING_COMPLETED",
"INDEXING_COMPLETED",
];
export function DocumentTimeline({ events }: { events: TimelineEvent[] }) {
const seen = new Set(events.map((e) => e.stage));
return (
<Card>
<CardHeader>
<CardTitle>Pipeline timeline</CardTitle>
<CardDescription>End-to-end stages for this document</CardDescription>
</CardHeader>
<CardContent>
<ol className="relative ml-2 border-l border-border/70 pl-5">
{STAGES.map((stage, i) => {
const evt = events.find((e) => e.stage === stage);
const done = seen.has(stage);
return (
<li key={stage} className="relative pb-4 last:pb-0">
<span
className={cn(
"absolute -left-[31px] grid h-6 w-6 place-items-center rounded-full border-2",
done
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground"
)}
>
{done ? <Check className="h-3 w-3" /> : <CircleDashed className="h-3 w-3" />}
</span>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium">{labelFor(stage)}</div>
{evt && (
<span className="text-[11px] text-muted-foreground">
{relativeTime(evt.created_at)}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{evt?.message ?? `Stage ${i + 1} of ${STAGES.length}`}
</p>
</li>
);
})}
</ol>
</CardContent>
</Card>
);
}
function labelFor(stage: string): string {
return stage
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (m) => m.toUpperCase());
}

View File

@@ -0,0 +1,92 @@
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface Props {
data: { date: string; ingested: number; failed: number }[];
}
export function IngestionStatsChart({ data }: Props) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle>Ingestion volume</CardTitle>
<CardDescription>Documents processed per day · last 14 days</CardDescription>
</div>
<Legend
verticalAlign="top"
iconType="circle"
wrapperStyle={{ fontSize: 11, color: "hsl(var(--muted-foreground))" }}
/>
</div>
</CardHeader>
<CardContent className="h-[260px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 5, right: 8, left: -10, bottom: 0 }}>
<defs>
<linearGradient id="ingested" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.4} />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
<linearGradient id="failed" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--destructive))" stopOpacity={0.35} />
<stop offset="100%" stopColor="hsl(var(--destructive))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="hsl(var(--border))" strokeOpacity={0.5} vertical={false} />
<XAxis
dataKey="date"
tickFormatter={(v) => v.slice(5)}
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ stroke: "hsl(var(--border))", strokeDasharray: "3 3" }}
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 12,
fontSize: 12,
boxShadow: "0 12px 40px -12px rgba(15,23,42,0.18)",
}}
/>
<Area
type="monotone"
dataKey="ingested"
stroke="hsl(var(--primary))"
strokeWidth={2}
fill="url(#ingested)"
name="Ingested"
/>
<Area
type="monotone"
dataKey="failed"
stroke="hsl(var(--destructive))"
strokeWidth={1.5}
fill="url(#failed)"
name="Failed"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,111 @@
import type { ReactNode } from "react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
interface KpiCardProps {
label: string;
value: ReactNode;
delta?: number;
helper?: string;
icon?: ReactNode;
tone?: "default" | "success" | "warning" | "destructive" | "primary";
trend?: number[];
className?: string;
}
const TONE: Record<NonNullable<KpiCardProps["tone"]>, string> = {
default: "from-muted/30",
primary: "from-primary/10",
success: "from-success/10",
warning: "from-warning/10",
destructive: "from-destructive/10",
};
export function KpiCard({
label,
value,
delta,
helper,
icon,
tone = "default",
trend,
className,
}: KpiCardProps) {
const up = (delta ?? 0) >= 0;
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className={cn(
"relative overflow-hidden panel p-5",
"before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-border before:to-transparent",
className
)}
>
<div className={cn("pointer-events-none absolute -right-12 -top-16 h-32 w-32 rounded-full bg-gradient-to-br to-transparent blur-3xl", TONE[tone])} />
<div className="relative flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{label}
</div>
<div className="text-2xl font-semibold tracking-tight text-foreground">{value}</div>
</div>
{icon && (
<div className="grid h-9 w-9 place-items-center rounded-xl border border-border/70 bg-card text-muted-foreground">
{icon}
</div>
)}
</div>
<div className="relative mt-3 flex items-center justify-between gap-3 text-xs">
{delta !== undefined && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium",
up ? "bg-success/12 text-success" : "bg-destructive/12 text-destructive"
)}
>
{up ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
{Math.abs(delta).toFixed(1)}%
</span>
)}
{helper && <span className="ml-auto text-muted-foreground">{helper}</span>}
</div>
{trend && trend.length > 1 && <Sparkline values={trend} />}
</motion.div>
);
}
function Sparkline({ values }: { values: number[] }) {
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const points = values
.map((v, i) => {
const x = (i / (values.length - 1)) * 100;
const y = 100 - ((v - min) / range) * 100;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
return (
<svg viewBox="0 0 100 32" className="mt-3 h-7 w-full" preserveAspectRatio="none">
<defs>
<linearGradient id="kpiSpark" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.4" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</linearGradient>
</defs>
<polyline
points={points}
fill="none"
stroke="hsl(var(--primary))"
strokeWidth="1.6"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
<polygon points={`0,32 ${points} 100,32`} fill="url(#kpiSpark)" opacity="0.6" />
</svg>
);
}

View File

@@ -0,0 +1,53 @@
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface Props {
distribution: { bucket: string; count: number }[];
avg: number;
}
const COLORS = [
"hsl(var(--destructive))",
"hsl(var(--warning))",
"hsl(var(--warning))",
"hsl(var(--primary))",
"hsl(var(--success))",
"hsl(var(--success))",
];
export function OCRQualityWidget({ distribution, avg }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>OCR confidence distribution</CardTitle>
<CardDescription>
Average <span className="font-mono text-foreground">{(avg * 100).toFixed(1)}%</span>{" "}
across all documents
</CardDescription>
</CardHeader>
<CardContent className="h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={distribution} margin={{ top: 5, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid stroke="hsl(var(--border))" strokeOpacity={0.5} vertical={false} />
<XAxis dataKey="bucket" tickLine={false} axisLine={false} fontSize={11} />
<YAxis tickLine={false} axisLine={false} fontSize={11} />
<Tooltip
cursor={{ fill: "hsl(var(--muted) / 0.5)" }}
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 12,
fontSize: 12,
}}
/>
<Bar dataKey="count" radius={[8, 8, 0, 0]}>
{distribution.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,108 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { PageSummary } from "@/services/types";
interface Props {
fileName: string;
pages: PageSummary[];
onPageChange?: (page: number) => void;
}
export function PdfPreviewPane({ fileName, pages, onPageChange }: Props) {
const [active, setActive] = useState(pages[0]?.page_number ?? 1);
const current = pages.find((p) => p.page_number === active) ?? pages[0];
function set(page: number) {
setActive(page);
onPageChange?.(page);
}
return (
<Card className="flex h-full flex-col">
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-0">
<div>
<CardTitle className="truncate text-sm">{fileName}</CardTitle>
<div className="text-[11px] text-muted-foreground">
Page {active} of {pages.length}
</div>
</div>
<div className="flex items-center gap-1">
<Button
size="icon-sm"
variant="outline"
onClick={() => set(Math.max(pages[0]?.page_number ?? 1, active - 1))}
disabled={active <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
size="icon-sm"
variant="outline"
onClick={() => set(Math.min(pages.at(-1)?.page_number ?? active, active + 1))}
disabled={active >= pages.length}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3 pt-3">
<div className="relative grid flex-1 grid-cols-[60px_1fr] gap-3">
<div className="space-y-1.5 overflow-y-auto pr-1 scrollbar-thin">
{pages.map((p) => (
<button
key={p.page_number}
onClick={() => set(p.page_number)}
className={cn(
"block w-full overflow-hidden rounded-md border text-left transition-all",
p.page_number === active
? "border-primary shadow-soft"
: "border-border/60 hover:border-primary/40"
)}
>
<div className="bg-grid-faint relative aspect-[3/4] w-full">
<div className="absolute inset-2 rounded-sm bg-muted-foreground/15" />
</div>
<div className="bg-card px-1.5 py-0.5 text-center text-[10px] font-mono text-muted-foreground">
{p.page_number}
</div>
</button>
))}
</div>
<div className="relative overflow-hidden rounded-xl border border-border/70 bg-card shadow-soft">
<div className="bg-grid-faint flex h-full min-h-[420px] flex-col">
<div className="flex items-center justify-between border-b border-border/70 bg-card/80 px-4 py-2 text-[11px] text-muted-foreground backdrop-blur">
<Badge variant="outline" className="font-mono">scanned page</Badge>
<span className="font-mono">p.{active}</span>
</div>
<div className="flex flex-1 flex-col gap-3 px-8 py-6 text-[13px] leading-relaxed text-foreground/90">
<div className="h-2 w-3/4 rounded bg-foreground/10" />
<div className="h-2 w-2/3 rounded bg-foreground/10" />
<div className="h-2 w-5/6 rounded bg-foreground/10" />
<div className="h-2 w-1/2 rounded bg-foreground/10" />
<div className="mt-4 rounded-lg border border-primary/40 bg-primary/5 px-3 py-3 text-xs text-foreground">
<strong className="text-primary">Highlighted block</strong>
<p className="mt-1 text-muted-foreground">
{current?.text ?? "Sample text content for this page."}
</p>
</div>
<div className="mt-2 grid grid-cols-3 gap-1 rounded-md border border-border/60 p-2">
<div className="h-2 rounded bg-foreground/10" />
<div className="h-2 rounded bg-foreground/15" />
<div className="h-2 rounded bg-foreground/10" />
<div className="h-2 rounded bg-foreground/15" />
<div className="h-2 rounded bg-foreground/10" />
<div className="h-2 rounded bg-foreground/15" />
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,75 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { useQueue } from "@/hooks/useHealth";
import { formatDuration, formatNumber } from "@/lib/utils";
import { Activity, AlertCircle, Loader2 } from "lucide-react";
export function QueueWidget() {
const { data, isLoading } = useQueue();
const pending = data?.pending ?? 0;
const inProgress = data?.in_progress ?? 0;
const failedHour = data?.failed_last_hour ?? 0;
const completedHour = data?.completed_last_hour ?? 0;
const total = pending + inProgress;
const progress = total > 0 ? Math.min(100, (completedHour / (completedHour + total)) * 100) : 100;
return (
<Card>
<CardHeader>
<CardTitle>Processing queue</CardTitle>
<CardDescription>Live Celery workload</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Tile icon={<Activity className="h-4 w-4 text-primary" />} label="Pending" value={pending} loading={isLoading} />
<Tile icon={<Loader2 className="h-4 w-4 animate-spin text-primary" />} label="In progress" value={inProgress} loading={isLoading} />
<Tile label="Done · 1h" value={completedHour} loading={isLoading} tone="success" />
<Tile icon={<AlertCircle className="h-4 w-4 text-destructive" />} label="Failed · 1h" value={failedHour} loading={isLoading} tone="destructive" />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Throughput vs. backlog</span>
<span>Avg latency · {formatDuration(data?.average_latency_ms ?? 0)}</span>
</div>
<Progress value={progress} indicatorClassName="bg-gradient-to-r from-primary to-primary-700" />
</div>
</CardContent>
</Card>
);
}
function Tile({
icon,
label,
value,
loading,
tone,
}: {
icon?: React.ReactNode;
label: string;
value: number;
loading?: boolean;
tone?: "success" | "destructive";
}) {
return (
<div className="rounded-xl border border-border/70 bg-card/40 px-3 py-3">
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{icon}
{label}
</div>
<div
className={
"mt-1.5 font-mono text-xl tabular-nums " +
(tone === "success"
? "text-success"
: tone === "destructive"
? "text-destructive"
: "text-foreground")
}
>
{loading ? "—" : formatNumber(value)}
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusChip, statusToTone } from "@/components/common/StatusChip";
import { useIngestionRuns } from "@/hooks/useIngestion";
import { formatNumber, relativeTime } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
export function RecentRunsWidget() {
const { data, isLoading } = useIngestionRuns();
return (
<Card>
<CardHeader>
<CardTitle>Recent ingestion runs</CardTitle>
<CardDescription>Most recent batch operations</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-xl border border-border/60">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-[10px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-left font-medium">Source</th>
<th className="px-3 py-2 text-right font-medium">Processed</th>
<th className="px-3 py-2 text-right font-medium">Failed</th>
<th className="px-3 py-2 text-right font-medium">Started</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60 bg-card">
{isLoading &&
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
<td className="px-3 py-2.5">
<Skeleton className="h-5 w-16" />
</td>
<td className="px-3 py-2.5">
<Skeleton className="h-4 w-48" />
</td>
<td className="px-3 py-2.5 text-right">
<Skeleton className="ml-auto h-4 w-12" />
</td>
<td className="px-3 py-2.5 text-right">
<Skeleton className="ml-auto h-4 w-8" />
</td>
<td className="px-3 py-2.5 text-right">
<Skeleton className="ml-auto h-4 w-16" />
</td>
</tr>
))}
{data?.slice(0, 6).map((r) => (
<tr key={r.id} className="transition-colors hover:bg-muted/30">
<td className="px-3 py-2.5">
<StatusChip tone={statusToTone(r.status)} label={r.status} />
</td>
<td className="px-3 py-2.5 font-mono text-xs text-muted-foreground">
{r.source_folder}
</td>
<td className="px-3 py-2.5 text-right font-mono text-xs tabular-nums">
{formatNumber(r.processed_files)}/{formatNumber(r.total_files)}
</td>
<td className="px-3 py-2.5 text-right font-mono text-xs tabular-nums">
<span className={r.failed_files > 0 ? "text-destructive" : "text-muted-foreground"}>
{formatNumber(r.failed_files)}
</span>
</td>
<td className="px-3 py-2.5 text-right text-xs text-muted-foreground">
{relativeTime(r.started_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,93 @@
import { motion } from "framer-motion";
import { FileText, Hash, MoveUpRight } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfidenceMeter } from "@/components/common/ConfidenceMeter";
import { Highlight } from "@/components/common/Highlight";
import { BlockTypeLabel } from "@/components/common/BlockTypeIcon";
import { QualityFlags } from "@/components/common/QualityFlag";
import type { SearchHit } from "@/services/types";
import { cn } from "@/lib/utils";
interface Props {
hit: SearchHit;
query: string;
active: boolean;
onSelect: () => void;
reranked: boolean;
}
export function SearchResultCard({ hit, query, active, onSelect, reranked }: Props) {
const ocrConf =
(hit.metadata as { ocr_confidence?: number })?.ocr_confidence ??
null;
return (
<motion.button
layout
onClick={onSelect}
whileHover={{ y: -1 }}
transition={{ duration: 0.15 }}
className={cn(
"panel w-full overflow-hidden text-left transition-colors",
active ? "border-primary/60 shadow-elevated ring-1 ring-primary/30" : "hover:border-primary/30"
)}
>
<Card className="border-0 shadow-none">
<CardContent className="space-y-2.5 p-4">
<div className="flex items-center gap-2 text-xs">
<span className="inline-flex h-6 min-w-[1.6rem] items-center justify-center rounded-md bg-muted px-1.5 font-mono font-medium text-muted-foreground">
#{hit.rank}
</span>
<BlockTypeLabel type={hit.block_type} />
<span className="text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
<Hash className="inline h-3 w-3" /> p.{hit.page_number}
</span>
<ScoreBar score={hit.score} reranked={reranked} />
</div>
<div className="flex items-baseline gap-2">
<FileText className="h-4 w-4 shrink-0 text-primary" />
<div className="truncate text-sm font-semibold text-foreground">
{hit.original_file_name}
</div>
</div>
<p className="line-clamp-4 text-[13px] leading-relaxed text-foreground/90">
<Highlight text={hit.text} query={query} />
</p>
<div className="flex flex-wrap items-center gap-2 pt-1 text-xs">
<ConfidenceMeter value={ocrConf ?? 0.8} />
<Badge variant="outline" className="font-mono text-[10px]">
{hit.source_path}
</Badge>
<QualityFlags flags={hit.quality_flags} compact className="ml-auto" />
</div>
<div className="-mb-1 flex items-center justify-end">
<Button variant="ghost" size="sm" className="text-xs text-primary">
Open citation
<MoveUpRight className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
</motion.button>
);
}
function ScoreBar({ score, reranked }: { score: number; reranked: boolean }) {
const pct = Math.max(0, Math.min(1, score)) * 100;
return (
<div className="ml-auto flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="hidden sm:inline">{reranked ? "reranked" : "raw"}</span>
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
<div className="h-full bg-primary" style={{ width: `${pct}%` }} />
</div>
<span className="font-mono tabular-nums text-foreground">{score.toFixed(3)}</span>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusChip } from "@/components/common/StatusChip";
import { useHealth } from "@/hooks/useHealth";
import { Database, Cloud, Search, Boxes, MemoryStick } from "lucide-react";
import type { ReactNode } from "react";
const ICONS: Record<string, ReactNode> = {
postgres: <Database className="h-4 w-4" />,
minio: <Cloud className="h-4 w-4" />,
opensearch: <Search className="h-4 w-4" />,
qdrant: <Boxes className="h-4 w-4" />,
redis: <MemoryStick className="h-4 w-4" />,
};
export function ServiceHealthCard() {
const { data, isLoading } = useHealth();
const components = data?.components ?? [];
return (
<Card>
<CardHeader>
<CardTitle>Service health</CardTitle>
<CardDescription>Backing services for the indexing platform</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y divide-border/60">
{(isLoading || components.length === 0) &&
["postgres", "minio", "opensearch", "qdrant", "redis"].map((n) => (
<li key={n} className="flex items-center gap-3 py-2.5">
<span className="grid h-8 w-8 place-items-center rounded-lg border border-border/70 bg-muted/40 text-muted-foreground">
{ICONS[n] ?? null}
</span>
<span className="font-medium capitalize">{n}</span>
<span className="ml-auto text-xs text-muted-foreground">checking</span>
</li>
))}
{components.map((c) => (
<li key={c.name} className="flex items-center gap-3 py-2.5">
<span className="grid h-8 w-8 place-items-center rounded-lg border border-border/70 bg-muted/40 text-muted-foreground">
{ICONS[c.name] ?? null}
</span>
<div className="leading-tight">
<div className="text-sm font-medium capitalize">{c.name}</div>
<div className="text-[11px] text-muted-foreground">{describe(c.detail)}</div>
</div>
<StatusChip
className="ml-auto"
tone={c.status === "ok" ? "ok" : c.status === "degraded" ? "warning" : "error"}
label={c.status}
/>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function describe(detail: Record<string, unknown>): string {
if (!detail) return "";
const cluster = detail.cluster_status;
if (cluster) return `cluster · ${String(cluster)}`;
const buckets = detail.buckets;
if (Array.isArray(buckets) && buckets.length) return `${buckets.length} buckets`;
const cols = detail.collections;
if (Array.isArray(cols) && cols.length) return `${cols.length} collections`;
if (detail.latency_ms) return `${detail.latency_ms} ms latency`;
return "operational";
}

View File

@@ -0,0 +1,84 @@
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatBytes } from "@/lib/utils";
import { Database, HardDrive } from "lucide-react";
interface Props {
totalBytes: number;
growth: { date: string; bytes: number }[];
}
export function StorageWidget({ totalBytes, growth }: Props) {
const latest = growth.at(-1)?.bytes ?? totalBytes;
const earliest = growth[0]?.bytes ?? totalBytes;
const delta = ((latest - earliest) / Math.max(earliest, 1)) * 100;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle>Storage usage</CardTitle>
<CardDescription>MinIO + derived artifacts</CardDescription>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<Legend label="Originals" color="hsl(var(--primary))" icon={<Database className="h-3.5 w-3.5" />} />
<Legend label="Derived" color="hsl(var(--primary) / 0.4)" icon={<HardDrive className="h-3.5 w-3.5" />} />
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3 pb-3">
<Stat label="Used" value={formatBytes(totalBytes)} />
<Stat label="14d Δ" value={`${delta >= 0 ? "+" : ""}${delta.toFixed(1)}%`} tone={delta >= 0 ? "default" : "success"} />
<Stat label="Projected · 30d" value={formatBytes(totalBytes + (latest - earliest) * 2)} />
</div>
<div className="h-[160px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={growth} margin={{ top: 0, right: 8, left: -10, bottom: 0 }}>
<defs>
<linearGradient id="storage" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.4} />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" tickFormatter={(v) => v.slice(5)} tickLine={false} axisLine={false} fontSize={11} />
<YAxis tickFormatter={(v) => formatBytes(v, 0)} tickLine={false} axisLine={false} fontSize={11} width={48} />
<Tooltip
formatter={(v: number) => formatBytes(v)}
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 12,
fontSize: 12,
}}
/>
<Area type="monotone" dataKey="bytes" stroke="hsl(var(--primary))" strokeWidth={2} fill="url(#storage)" />
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
function Legend({ label, color, icon }: { label: string; color: string; icon: React.ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: color }} />
{icon}
{label}
</span>
);
}
function Stat({ label, value, tone = "default" }: { label: string; value: string; tone?: "default" | "success" }) {
return (
<div className="rounded-xl border border-border/70 bg-card/40 px-3 py-2">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
<div className={"mt-0.5 font-mono text-base " + (tone === "success" ? "text-success" : "text-foreground")}>
{value}
</div>
</div>
);
}

131
frontend/tailwind.config.ts Normal file
View File

@@ -0,0 +1,131 @@
import type { Config } from "tailwindcss";
import animate from "tailwindcss-animate";
const config: Config = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1.5rem",
screens: {
"2xl": "1440px",
},
},
extend: {
fontFamily: {
sans: [
"InterVariable",
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"Segoe UI",
"Roboto",
"sans-serif",
],
mono: ["JetBrains Mono", "ui-monospace", "SFMono-Regular", "monospace"],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
50: "hsl(var(--primary-50))",
100: "hsl(var(--primary-100))",
200: "hsl(var(--primary-200))",
500: "hsl(var(--primary-500))",
600: "hsl(var(--primary-600))",
700: "hsl(var(--primary-700))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
surface: {
DEFAULT: "hsl(var(--surface))",
raised: "hsl(var(--surface-raised))",
sunken: "hsl(var(--surface-sunken))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 4px)",
sm: "calc(var(--radius) - 8px)",
xl: "calc(var(--radius) + 4px)",
"2xl": "calc(var(--radius) + 10px)",
},
boxShadow: {
soft: "0 1px 2px rgba(15,23,42,0.04), 0 4px 16px rgba(15,23,42,0.04)",
glass: "0 1px 0 rgba(255,255,255,0.6) inset, 0 8px 32px rgba(15,23,42,0.06)",
ring: "0 0 0 4px hsl(var(--primary) / 0.18)",
elevated: "0 12px 40px -12px rgba(15,23,42,0.18)",
},
backgroundImage: {
"grid-light":
"linear-gradient(to right, hsl(var(--border)) 1px, transparent 1px), linear-gradient(to bottom, hsl(var(--border)) 1px, transparent 1px)",
"radial-fade":
"radial-gradient(60% 40% at 30% 20%, hsl(var(--primary) / 0.10), transparent 70%)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
shimmer: {
"0%": { backgroundPosition: "-200% 0" },
"100%": { backgroundPosition: "200% 0" },
},
"fade-in-up": {
"0%": { opacity: "0", transform: "translateY(6px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
shimmer: "shimmer 1.6s linear infinite",
"fade-in-up": "fade-in-up 0.3s ease-out",
},
},
},
plugins: [animate],
};
export default config;

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"useDefineForClassFields": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

26
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5273,
strictPort: true,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: true,
},
});