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:
lasta
2026-05-14 14:17:26 +09:00
parent 1be2c7d3a3
commit 37cad40472
41 changed files with 669 additions and 232 deletions

View File

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

View File

@@ -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": "완료"
}
}

View File

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

View File

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

View File

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

View 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";

View File

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

View 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,
},
};

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

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

View File

@@ -0,0 +1,2 @@
export { apiClient, ApiError } from "./client";
export * from "./projects";

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

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
extend: {