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:
4
frontend/.env.example
Normal file
4
frontend/.env.example
Normal 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
7
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.vite
|
||||
.DS_Store
|
||||
*.log
|
||||
140
frontend/README.md
Normal file
140
frontend/README.md
Normal 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.
|
||||
- 1024–1280 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
19
frontend/index.html
Normal 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
56
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
10
frontend/public/favicon.svg
Normal file
10
frontend/public/favicon.svg
Normal 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
11
frontend/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/app/providers.tsx
Normal file
34
frontend/src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/app/router.tsx
Normal file
31
frontend/src/app/router.tsx
Normal 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 /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
44
frontend/src/components/common/BlockTypeIcon.tsx
Normal file
44
frontend/src/components/common/BlockTypeIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/common/ConfidenceMeter.tsx
Normal file
38
frontend/src/components/common/ConfidenceMeter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/common/EmptyState.tsx
Normal file
38
frontend/src/components/common/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/common/Highlight.tsx
Normal file
45
frontend/src/components/common/Highlight.tsx
Normal 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;
|
||||
}
|
||||
23
frontend/src/components/common/Logo.tsx
Normal file
23
frontend/src/components/common/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/common/PageHeader.tsx
Normal file
28
frontend/src/components/common/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/common/QualityFlag.tsx
Normal file
74
frontend/src/components/common/QualityFlag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/common/StatusChip.tsx
Normal file
48
frontend/src/components/common/StatusChip.tsx
Normal 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";
|
||||
}
|
||||
28
frontend/src/components/common/ThemeToggle.tsx
Normal file
28
frontend/src/components/common/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/ui/badge.tsx
Normal file
29
frontend/src/components/ui/badge.tsx
Normal 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} />;
|
||||
}
|
||||
56
frontend/src/components/ui/button.tsx
Normal file
56
frontend/src/components/ui/button.tsx
Normal 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 };
|
||||
53
frontend/src/components/ui/card.tsx
Normal file
53
frontend/src/components/ui/card.tsx
Normal 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";
|
||||
90
frontend/src/components/ui/command.tsx
Normal file
90
frontend/src/components/ui/command.tsx
Normal 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} />
|
||||
);
|
||||
76
frontend/src/components/ui/dialog.tsx
Normal file
76
frontend/src/components/ui/dialog.tsx
Normal 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;
|
||||
113
frontend/src/components/ui/dropdown-menu.tsx
Normal file
113
frontend/src/components/ui/dropdown-menu.tsx
Normal 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;
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal 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";
|
||||
27
frontend/src/components/ui/popover.tsx
Normal file
27
frontend/src/components/ui/popover.tsx
Normal 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;
|
||||
22
frontend/src/components/ui/progress.tsx
Normal file
22
frontend/src/components/ui/progress.tsx
Normal 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";
|
||||
36
frontend/src/components/ui/scroll-area.tsx
Normal file
36
frontend/src/components/ui/scroll-area.tsx
Normal 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";
|
||||
89
frontend/src/components/ui/select.tsx
Normal file
89
frontend/src/components/ui/select.tsx
Normal 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;
|
||||
21
frontend/src/components/ui/separator.tsx
Normal file
21
frontend/src/components/ui/separator.tsx
Normal 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";
|
||||
5
frontend/src/components/ui/skeleton.tsx
Normal file
5
frontend/src/components/ui/skeleton.tsx
Normal 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} />;
|
||||
}
|
||||
27
frontend/src/components/ui/switch.tsx
Normal file
27
frontend/src/components/ui/switch.tsx
Normal 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";
|
||||
45
frontend/src/components/ui/tabs.tsx
Normal file
45
frontend/src/components/ui/tabs.tsx
Normal 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;
|
||||
24
frontend/src/components/ui/tooltip.tsx
Normal file
24
frontend/src/components/ui/tooltip.tsx
Normal 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;
|
||||
10
frontend/src/hooks/useDebounce.ts
Normal file
10
frontend/src/hooks/useDebounce.ts
Normal 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;
|
||||
}
|
||||
27
frontend/src/hooks/useDocuments.ts
Normal file
27
frontend/src/hooks/useDocuments.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
19
frontend/src/hooks/useHealth.ts
Normal file
19
frontend/src/hooks/useHealth.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
23
frontend/src/hooks/useIngestion.ts
Normal file
23
frontend/src/hooks/useIngestion.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
10
frontend/src/hooks/useQuality.ts
Normal file
10
frontend/src/hooks/useQuality.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
32
frontend/src/hooks/useSearch.ts
Normal file
32
frontend/src/hooks/useSearch.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
15
frontend/src/hooks/useTheme.ts
Normal file
15
frontend/src/hooks/useTheme.ts
Normal 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]);
|
||||
}
|
||||
30
frontend/src/layouts/AppShell.tsx
Normal file
30
frontend/src/layouts/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/layouts/Breadcrumbs.tsx
Normal file
43
frontend/src/layouts/Breadcrumbs.tsx
Normal 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());
|
||||
}
|
||||
87
frontend/src/layouts/CommandPalette.tsx
Normal file
87
frontend/src/layouts/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
frontend/src/layouts/Sidebar.tsx
Normal file
171
frontend/src/layouts/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
frontend/src/layouts/Topbar.tsx
Normal file
145
frontend/src/layouts/Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/layouts/navConfig.ts
Normal file
39
frontend/src/layouts/navConfig.ts
Normal 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
59
frontend/src/lib/utils.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
113
frontend/src/pages/DashboardPage.tsx
Normal file
113
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
179
frontend/src/pages/DocumentViewerPage.tsx
Normal file
179
frontend/src/pages/DocumentViewerPage.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
197
frontend/src/pages/DocumentsPage.tsx
Normal file
197
frontend/src/pages/DocumentsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
frontend/src/pages/IngestionJobsPage.tsx
Normal file
167
frontend/src/pages/IngestionJobsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
frontend/src/pages/QualityControlPage.tsx
Normal file
128
frontend/src/pages/QualityControlPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
frontend/src/pages/SearchPage.tsx
Normal file
336
frontend/src/pages/SearchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
frontend/src/pages/SettingsPage.tsx
Normal file
159
frontend/src/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/pages/SystemHealthPage.tsx
Normal file
100
frontend/src/pages/SystemHealthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/pages/TablesFiguresPage.tsx
Normal file
108
frontend/src/pages/TablesFiguresPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
frontend/src/services/apiClient.ts
Normal file
33
frontend/src/services/apiClient.ts
Normal 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";
|
||||
71
frontend/src/services/documents.ts
Normal file
71
frontend/src/services/documents.ts
Normal 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));
|
||||
}
|
||||
25
frontend/src/services/health.ts
Normal file
25
frontend/src/services/health.ts
Normal 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));
|
||||
}
|
||||
27
frontend/src/services/ingestion.ts
Normal file
27
frontend/src/services/ingestion.ts
Normal 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;
|
||||
}
|
||||
298
frontend/src/services/mock/mockData.ts
Normal file
298
frontend/src/services/mock/mockData.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
})();
|
||||
42
frontend/src/services/quality.ts
Normal file
42
frontend/src/services/quality.ts
Normal 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));
|
||||
}
|
||||
29
frontend/src/services/search.ts
Normal file
29
frontend/src/services/search.ts
Normal 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);
|
||||
}
|
||||
209
frontend/src/services/types.ts
Normal file
209
frontend/src/services/types.ts
Normal 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;
|
||||
}
|
||||
36
frontend/src/stores/searchStore.ts
Normal file
36
frontend/src/stores/searchStore.ts
Normal 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 }),
|
||||
}));
|
||||
47
frontend/src/stores/uiStore.ts
Normal file
47
frontend/src/stores/uiStore.ts
Normal 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");
|
||||
}
|
||||
172
frontend/src/styles/globals.css
Normal file
172
frontend/src/styles/globals.css
Normal 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%);
|
||||
}
|
||||
}
|
||||
108
frontend/src/widgets/ChunkPreview.tsx
Normal file
108
frontend/src/widgets/ChunkPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
frontend/src/widgets/DocumentTimeline.tsx
Normal file
68
frontend/src/widgets/DocumentTimeline.tsx
Normal 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());
|
||||
}
|
||||
92
frontend/src/widgets/IngestionStatsChart.tsx
Normal file
92
frontend/src/widgets/IngestionStatsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
frontend/src/widgets/KpiCard.tsx
Normal file
111
frontend/src/widgets/KpiCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/widgets/OCRQualityWidget.tsx
Normal file
53
frontend/src/widgets/OCRQualityWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/widgets/PdfPreviewPane.tsx
Normal file
108
frontend/src/widgets/PdfPreviewPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
frontend/src/widgets/QueueWidget.tsx
Normal file
75
frontend/src/widgets/QueueWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
frontend/src/widgets/RecentRunsWidget.tsx
Normal file
75
frontend/src/widgets/RecentRunsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
frontend/src/widgets/SearchResultCard.tsx
Normal file
93
frontend/src/widgets/SearchResultCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/widgets/ServiceHealthCard.tsx
Normal file
69
frontend/src/widgets/ServiceHealthCard.tsx
Normal 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";
|
||||
}
|
||||
84
frontend/src/widgets/StorageWidget.tsx
Normal file
84
frontend/src/widgets/StorageWidget.tsx
Normal 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
131
frontend/tailwind.config.ts
Normal 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
26
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
26
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user