Phase 0.5: React UI 기반 계층 구축 — API 클라이언트 + TanStack Query + shadcn UI
1. 서버/UI 상태 분리 (선택지 B 채택)
- ontologySlice: entities/relations 등 서버 상태 제거 → OntologyDraft(편집 폼)만
- crawlSlice: progress/stats/logs 제거 → CrawlUiState(토글/확장 상태)만
- 서버 상태는 전부 TanStack Query로 이전
2. API 클라이언트 계층 (src/lib/api/)
- client.ts: fetch 래퍼 + Zod 스키마 검증 + ApiError
- projects.ts: projectsApi.list/detail/create + 도메인 스키마
3. TanStack Query 훅 (src/hooks/)
- useProjects, useProject, useCreateProject
- queryKeys 팩토리로 키 일관성
4. shadcn 스타일 UI 프리미티브 (src/components/ui/)
- button, card, skeleton
- lib/utils.ts: cn() helper (clsx + tailwind-merge)
5. 레이아웃 (src/components/layout/)
- AppShell: 축소 가능 Sidebar + Header + Outlet
- App.tsx 라우트를 AppShell로 중첩
6. DashboardPage End-to-end 연결
- useProjects()로 백엔드 /projects 호출
- loading skeleton / error+retry / empty state / 카드 그리드 4가지 상태 모두 처리
7. i18n locale 파일 추가
- public/locales/ko/common.json (한국어)
- public/locales/en/common.json (영문 fallback)
- 키: nav.*, dashboard.*, common.*, app.*
8. 레거시 정리
- vanilla JS/CSS 24개 → src/legacy/로 git mv
- tailwind content를 *.{ts,tsx}로 좁혀 legacy 제외
다음 단계: Phase 1 — OnboardingPage 파일 업로드 + useCreateProject 연결
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Ontology Builder",
|
||||
"subtitle": "AI-powered ontology construction"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"onboard": "Upload Ontology",
|
||||
"sources": "Sources",
|
||||
"crawl": "Crawl",
|
||||
"review": "Review",
|
||||
"toggleSidebar": "Toggle sidebar"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ontology Builder",
|
||||
"subtitle": "Build and manage domain ontologies with AI-powered extraction",
|
||||
"newProject": "New Project",
|
||||
"projects": "Projects",
|
||||
"projectCount": "{{count}} total",
|
||||
"loadFailed": "Failed to load projects",
|
||||
"empty": {
|
||||
"title": "No projects yet",
|
||||
"hint": "Create your first project to start building an ontology"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"retry": "Retry",
|
||||
"cancel": "Cancel",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"complete": "Complete"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "온톨로지 빌더",
|
||||
"subtitle": "AI 기반 온톨로지 구축 플랫폼"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "대시보드",
|
||||
"onboard": "온톨로지 업로드",
|
||||
"sources": "참고 소스",
|
||||
"crawl": "크롤 진행",
|
||||
"review": "결과 검토",
|
||||
"toggleSidebar": "사이드바 토글"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "온톨로지 빌더",
|
||||
"subtitle": "도메인 온톨로지를 AI 추출로 구축하고 관리합니다",
|
||||
"newProject": "새 프로젝트",
|
||||
"projects": "프로젝트 목록",
|
||||
"projectCount": "{{count}}개",
|
||||
"loadFailed": "프로젝트를 불러오지 못했습니다",
|
||||
"empty": {
|
||||
"title": "아직 프로젝트가 없습니다",
|
||||
"hint": "첫 프로젝트를 만들어 온톨로지 구축을 시작하세요"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"retry": "다시 시도",
|
||||
"cancel": "취소",
|
||||
"next": "다음",
|
||||
"back": "이전",
|
||||
"complete": "완료"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,22 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OnboardingPage from "./pages/OnboardingPage";
|
||||
import ConfigureSourcesPage from "./pages/ConfigureSourcesPage";
|
||||
import CrawlPage from "./pages/CrawlPage";
|
||||
import ReviewPage from "./pages/ReviewPage";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
import AppShell from "@/components/layout/AppShell";
|
||||
import OnboardingPage from "@/pages/OnboardingPage";
|
||||
import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage";
|
||||
import CrawlPage from "@/pages/CrawlPage";
|
||||
import ReviewPage from "@/pages/ReviewPage";
|
||||
import DashboardPage from "@/pages/DashboardPage";
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={i18n.language === "ko" ? "font-sans" : "font-sans"}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/onboard" element={<OnboardingPage />} />
|
||||
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
|
||||
<Route path="/crawl/:projectId" element={<CrawlPage />} />
|
||||
<Route path="/review/:projectId" element={<ReviewPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { LayoutDashboard, UploadCloud, Settings2, Activity, ListChecks, Menu } from "lucide-react";
|
||||
import { RootState } from "@/stores";
|
||||
import { toggleSidebar } from "@/stores/slices/uiSlice";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: "/", labelKey: "nav.dashboard", icon: LayoutDashboard },
|
||||
{ to: "/onboard", labelKey: "nav.onboard", icon: UploadCloud },
|
||||
{ to: "/sources/demo-project", labelKey: "nav.sources", icon: Settings2 },
|
||||
{ to: "/crawl/demo-project", labelKey: "nav.crawl", icon: Activity },
|
||||
{ to: "/review/demo-project", labelKey: "nav.review", icon: ListChecks },
|
||||
];
|
||||
|
||||
export default function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const sidebarOpen = useSelector((s: RootState) => s.ui.sidebarOpen);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background text-foreground">
|
||||
<aside
|
||||
className={cn(
|
||||
"border-r bg-card transition-all duration-200 ease-out",
|
||||
sidebarOpen ? "w-60" : "w-16",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between px-4 border-b">
|
||||
{sidebarOpen && (
|
||||
<span className="text-sm font-semibold">
|
||||
{t("app.title", "Ontology Builder")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => dispatch(toggleSidebar())}
|
||||
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
{navItems.map(({ to, labelKey, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{sidebarOpen && <span>{t(labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
|
||||
<h1 className="text-sm font-medium text-muted-foreground">
|
||||
{t("app.subtitle", "AI-powered ontology construction")}
|
||||
</h1>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
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-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { buttonVariants };
|
||||
76
crawler_platform/app/web/frontend/src/components/ui/card.tsx
Normal file
76
crawler_platform/app/web/frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
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 space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
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("p-6 pt-0", 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 p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
8
crawler_platform/app/web/frontend/src/hooks/queryKeys.ts
Normal file
8
crawler_platform/app/web/frontend/src/hooks/queryKeys.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const queryKeys = {
|
||||
projects: {
|
||||
all: ["projects"] as const,
|
||||
list: () => [...queryKeys.projects.all, "list"] as const,
|
||||
detail: (name: string) =>
|
||||
[...queryKeys.projects.all, "detail", name] as const,
|
||||
},
|
||||
};
|
||||
33
crawler_platform/app/web/frontend/src/hooks/useProjects.ts
Normal file
33
crawler_platform/app/web/frontend/src/hooks/useProjects.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreateProjectRequest,
|
||||
projectsApi,
|
||||
ProjectSummary,
|
||||
ProjectDetail,
|
||||
} from "@/lib/api/projects";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function useProjects() {
|
||||
return useQuery<ProjectSummary[]>({
|
||||
queryKey: queryKeys.projects.list(),
|
||||
queryFn: () => projectsApi.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProject(name: string | undefined) {
|
||||
return useQuery<ProjectDetail>({
|
||||
queryKey: queryKeys.projects.detail(name ?? ""),
|
||||
queryFn: () => projectsApi.detail(name!),
|
||||
enabled: Boolean(name),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateProjectRequest) => projectsApi.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
89
crawler_platform/app/web/frontend/src/lib/api/client.ts
Normal file
89
crawler_platform/app/web/frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
public body: unknown,
|
||||
public url: string,
|
||||
) {
|
||||
super(`${status} ${statusText} — ${url}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: RequestOptions["query"]): string {
|
||||
if (!query) return path;
|
||||
const params = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v === undefined || v === null) continue;
|
||||
params.append(k, String(v));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const { body, query, headers, ...rest } = options;
|
||||
const url = buildUrl(path, query);
|
||||
|
||||
const init: RequestInit = {
|
||||
...rest,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
|
||||
const response = await fetch(url, init);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const raw =
|
||||
contentType.includes("application/json") && response.status !== 204
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText, raw, url);
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
"Response schema mismatch",
|
||||
{ issues: parsed.error.issues, raw },
|
||||
url,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: <T>(path: string, schema: z.ZodType<T>, opts?: RequestOptions) =>
|
||||
request(path, schema, { ...opts, method: "GET" }),
|
||||
post: <T>(
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
body?: unknown,
|
||||
opts?: RequestOptions,
|
||||
) => request(path, schema, { ...opts, method: "POST", body }),
|
||||
put: <T>(
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
body?: unknown,
|
||||
opts?: RequestOptions,
|
||||
) => request(path, schema, { ...opts, method: "PUT", body }),
|
||||
delete: <T>(path: string, schema: z.ZodType<T>, opts?: RequestOptions) =>
|
||||
request(path, schema, { ...opts, method: "DELETE" }),
|
||||
};
|
||||
2
crawler_platform/app/web/frontend/src/lib/api/index.ts
Normal file
2
crawler_platform/app/web/frontend/src/lib/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { apiClient, ApiError } from "./client";
|
||||
export * from "./projects";
|
||||
54
crawler_platform/app/web/frontend/src/lib/api/projects.ts
Normal file
54
crawler_platform/app/web/frontend/src/lib/api/projects.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export const projectSummarySchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
domain: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
|
||||
export const projectListSchema = z.array(projectSummarySchema);
|
||||
|
||||
export const projectSourceSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
trust_level: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
respect_robots_txt: z.boolean().nullable().optional(),
|
||||
rate_limit_per_minute: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const projectDetailSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
domain: z.string(),
|
||||
config: z.unknown().optional(),
|
||||
sources: z.array(projectSourceSchema).default([]),
|
||||
});
|
||||
|
||||
export const createProjectResponseSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
domain: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectSummary = z.infer<typeof projectSummarySchema>;
|
||||
export type ProjectDetail = z.infer<typeof projectDetailSchema>;
|
||||
export type ProjectSource = z.infer<typeof projectSourceSchema>;
|
||||
|
||||
export interface CreateProjectRequest {
|
||||
config_path: string;
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
list: () => apiClient.get("/projects", projectListSchema),
|
||||
detail: (projectName: string) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}`,
|
||||
projectDetailSchema,
|
||||
),
|
||||
create: (body: CreateProjectRequest) =>
|
||||
apiClient.post("/projects", createProjectResponseSchema, body),
|
||||
};
|
||||
6
crawler_platform/app/web/frontend/src/lib/utils.ts
Normal file
6
crawler_platform/app/web/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,58 +1,138 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, BarChart3, Settings } from "lucide-react";
|
||||
import { Plus, FolderOpen, AlertCircle, Clock } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: projects, isLoading, isError, error, refetch } = useProjects();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{t("Ontology Builder")}
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t("dashboard.title", "Ontology Builder")}
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{t("Build and manage domain ontologies with AI-powered extraction")}
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t(
|
||||
"dashboard.subtitle",
|
||||
"Build and manage domain ontologies with AI-powered extraction",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate("/onboard")}
|
||||
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
|
||||
<Button onClick={() => navigate("/onboard")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.newProject", "New Project")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("dashboard.projects", "Projects")}
|
||||
</h2>
|
||||
{projects && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("dashboard.projectCount", "{{count}} total", {
|
||||
count: projects.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{t("dashboard.loadFailed", "Failed to load projects")}
|
||||
</CardTitle>
|
||||
<CardDescription>{(error as Error).message}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
{t("common.retry", "Retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{projects && projects.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<FolderOpen className="h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("dashboard.empty.title", "No projects yet")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"dashboard.empty.hint",
|
||||
"Create your first project to start building an ontology",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/onboard")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.newProject", "New Project")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/sources/${p.name}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
navigate(`/sources/${p.name}`);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t("New Project")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<BarChart3 className="w-8 h-8 text-blue-600 mb-4" />
|
||||
<h3 className="font-semibold text-lg mb-2">{t("Quick Start")}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{t("Get started by uploading your domain ontology")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<Settings className="w-8 h-8 text-green-600 mb-4" />
|
||||
<h3 className="font-semibold text-lg mb-2">{t("Phase 5 GraphRAG")}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{t("Intelligent entity resolution and deduplication")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<Settings className="w-8 h-8 text-purple-600 mb-4" />
|
||||
<h3 className="font-semibold text-lg mb-2">{t("Phase 7 LLM")}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{t("AI-powered extraction and validation")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CardHeader>
|
||||
<CardTitle>{p.name}</CardTitle>
|
||||
<CardDescription>{p.domain}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatRelative(p.updated_at)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,128 +1,40 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface CrawlStep {
|
||||
name: string;
|
||||
status: "pending" | "in_progress" | "completed" | "failed";
|
||||
progress: number; // 0-100
|
||||
message: string;
|
||||
error?: string;
|
||||
export type CrawlStepKey = "fetching" | "extracting" | "merging" | "validating";
|
||||
|
||||
export interface CrawlUiState {
|
||||
activeJobId: string | null;
|
||||
expandedStep: CrawlStepKey | null;
|
||||
showLogs: boolean;
|
||||
autoScrollLogs: boolean;
|
||||
}
|
||||
|
||||
export interface CrawlState {
|
||||
projectId: string | null;
|
||||
isRunning: boolean;
|
||||
steps: {
|
||||
fetching: CrawlStep;
|
||||
extracting: CrawlStep;
|
||||
merging: CrawlStep;
|
||||
validating: CrawlStep;
|
||||
};
|
||||
stats: {
|
||||
pagesFound: number;
|
||||
pagesCrawled: number;
|
||||
entitiesExtracted: number;
|
||||
entitiesMerged: number;
|
||||
claimsValidated: number;
|
||||
};
|
||||
logs: Array<{
|
||||
timestamp: string;
|
||||
level: "info" | "warning" | "error";
|
||||
message: string;
|
||||
}>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialCrawlStep: CrawlStep = {
|
||||
name: "",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const initialState: CrawlState = {
|
||||
projectId: null,
|
||||
isRunning: false,
|
||||
steps: {
|
||||
fetching: { ...initialCrawlStep, name: "Fetching pages" },
|
||||
extracting: { ...initialCrawlStep, name: "Extracting entities" },
|
||||
merging: { ...initialCrawlStep, name: "Merging duplicates" },
|
||||
validating: { ...initialCrawlStep, name: "Validating results" },
|
||||
},
|
||||
stats: {
|
||||
pagesFound: 0,
|
||||
pagesCrawled: 0,
|
||||
entitiesExtracted: 0,
|
||||
entitiesMerged: 0,
|
||||
claimsValidated: 0,
|
||||
},
|
||||
logs: [],
|
||||
error: null,
|
||||
const initialState: CrawlUiState = {
|
||||
activeJobId: null,
|
||||
expandedStep: null,
|
||||
showLogs: true,
|
||||
autoScrollLogs: true,
|
||||
};
|
||||
|
||||
const crawlSlice = createSlice({
|
||||
name: "crawl",
|
||||
initialState,
|
||||
reducers: {
|
||||
startCrawl: (state, action: PayloadAction<string>) => {
|
||||
state.projectId = action.payload;
|
||||
state.isRunning = true;
|
||||
state.error = null;
|
||||
state.logs = [];
|
||||
Object.values(state.steps).forEach((step) => {
|
||||
step.status = "pending";
|
||||
step.progress = 0;
|
||||
step.error = undefined;
|
||||
});
|
||||
setActiveJob: (state, action: PayloadAction<string | null>) => {
|
||||
state.activeJobId = action.payload;
|
||||
},
|
||||
updateStep: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
step: keyof typeof state.steps;
|
||||
status: CrawlStep["status"];
|
||||
progress: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
}>
|
||||
) => {
|
||||
const { step, status, progress, message, error } = action.payload;
|
||||
state.steps[step] = { ...state.steps[step], status, progress, message, error };
|
||||
expandStep: (state, action: PayloadAction<CrawlStepKey | null>) => {
|
||||
state.expandedStep = action.payload;
|
||||
},
|
||||
updateStats: (state, action: PayloadAction<Partial<typeof state.stats>>) => {
|
||||
Object.assign(state.stats, action.payload);
|
||||
toggleLogs: (state) => {
|
||||
state.showLogs = !state.showLogs;
|
||||
},
|
||||
addLog: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
level: "info" | "warning" | "error";
|
||||
message: string;
|
||||
}>
|
||||
) => {
|
||||
state.logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
...action.payload,
|
||||
});
|
||||
},
|
||||
setCrawlError: (state, action: PayloadAction<string>) => {
|
||||
state.error = action.payload;
|
||||
state.isRunning = false;
|
||||
},
|
||||
completeCrawl: (state) => {
|
||||
state.isRunning = false;
|
||||
},
|
||||
resetCrawl: (state) => {
|
||||
Object.assign(state, initialState);
|
||||
setAutoScroll: (state, action: PayloadAction<boolean>) => {
|
||||
state.autoScrollLogs = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
startCrawl,
|
||||
updateStep,
|
||||
updateStats,
|
||||
addLog,
|
||||
setCrawlError,
|
||||
completeCrawl,
|
||||
resetCrawl,
|
||||
} = crawlSlice.actions;
|
||||
|
||||
export const { setActiveJob, expandStep, toggleLogs, setAutoScroll } =
|
||||
crawlSlice.actions;
|
||||
export default crawlSlice.reducer;
|
||||
|
||||
@@ -1,87 +1,50 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface OntologyEntity {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
export type OntologyFormat = "yaml" | "json" | "owl";
|
||||
|
||||
export interface OntologyRelation {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
predicate: string;
|
||||
}
|
||||
|
||||
export interface OntologyState {
|
||||
id: string | null;
|
||||
export interface OntologyDraft {
|
||||
name: string;
|
||||
domain: string;
|
||||
format: "yaml" | "json" | "owl" | null;
|
||||
entities: OntologyEntity[];
|
||||
relations: OntologyRelation[];
|
||||
isLoaded: boolean;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
format: OntologyFormat | null;
|
||||
fileName: string | null;
|
||||
rawText: string;
|
||||
}
|
||||
|
||||
const initialState: OntologyState = {
|
||||
id: null,
|
||||
export interface OntologyDraftState {
|
||||
draft: OntologyDraft;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
const emptyDraft: OntologyDraft = {
|
||||
name: "",
|
||||
domain: "",
|
||||
format: null,
|
||||
entities: [],
|
||||
relations: [],
|
||||
isLoaded: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
fileName: null,
|
||||
rawText: "",
|
||||
};
|
||||
|
||||
const initialState: OntologyDraftState = {
|
||||
draft: emptyDraft,
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
const ontologySlice = createSlice({
|
||||
name: "ontology",
|
||||
initialState,
|
||||
reducers: {
|
||||
setOntology: (state, action: PayloadAction<Partial<OntologyState>>) => {
|
||||
Object.assign(state, action.payload);
|
||||
updateDraft: (state, action: PayloadAction<Partial<OntologyDraft>>) => {
|
||||
state.draft = { ...state.draft, ...action.payload };
|
||||
state.isDirty = true;
|
||||
},
|
||||
addEntity: (state, action: PayloadAction<OntologyEntity>) => {
|
||||
state.entities.push(action.payload);
|
||||
resetDraft: (state) => {
|
||||
state.draft = emptyDraft;
|
||||
state.isDirty = false;
|
||||
},
|
||||
removeEntity: (state, action: PayloadAction<string>) => {
|
||||
state.entities = state.entities.filter((e) => e.id !== action.payload);
|
||||
},
|
||||
addRelation: (state, action: PayloadAction<OntologyRelation>) => {
|
||||
state.relations.push(action.payload);
|
||||
},
|
||||
removeRelation: (state, action: PayloadAction<string>) => {
|
||||
state.relations = state.relations.filter((r) => r.id !== action.payload);
|
||||
},
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isLoaded = action.payload;
|
||||
},
|
||||
setSaving: (state, action: PayloadAction<boolean>) => {
|
||||
state.isSaving = action.payload;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload;
|
||||
},
|
||||
resetOntology: (state) => {
|
||||
Object.assign(state, initialState);
|
||||
markClean: (state) => {
|
||||
state.isDirty = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setOntology,
|
||||
addEntity,
|
||||
removeEntity,
|
||||
addRelation,
|
||||
removeRelation,
|
||||
setLoading,
|
||||
setSaving,
|
||||
setError,
|
||||
resetOntology,
|
||||
} = ontologySlice.actions;
|
||||
|
||||
export const { updateDraft, resetDraft, markClean } = ontologySlice.actions;
|
||||
export default ontologySlice.reducer;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
Reference in New Issue
Block a user