From 37cad4047222559b9c1b5182f74ef67bdcb70d9f Mon Sep 17 00:00:00 2001 From: lasta Date: Thu, 14 May 2026 14:17:26 +0900 Subject: [PATCH] =?UTF-8?q?Phase=200.5:=20React=20UI=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC=EC=B6=95=20=E2=80=94=20API=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20+=20TanStack=20?= =?UTF-8?q?Query=20+=20shadcn=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../frontend/public/locales/en/common.json | 33 ++++ .../frontend/public/locales/ko/common.json | 33 ++++ crawler_platform/app/web/frontend/src/App.tsx | 22 ++- .../src/components/layout/AppShell.tsx | 86 +++++++++ .../web/frontend/src/components/ui/button.tsx | 51 ++++++ .../web/frontend/src/components/ui/card.tsx | 76 ++++++++ .../frontend/src/components/ui/skeleton.tsx | 13 ++ .../app/web/frontend/src/hooks/queryKeys.ts | 8 + .../app/web/frontend/src/hooks/useProjects.ts | 33 ++++ .../app/web/frontend/src/{ => legacy}/api.js | 0 .../web/frontend/src/{ => legacy}/graph.js | 0 .../app/web/frontend/src/{ => legacy}/i18n.js | 0 .../frontend/src/{ => legacy}/inspector.js | 0 .../app/web/frontend/src/{ => legacy}/main.js | 0 .../web/frontend/src/{ => legacy}/pipeline.js | 0 .../web/frontend/src/{ => legacy}/search.js | 0 .../web/frontend/src/{ => legacy}/shell.js | 0 .../web/frontend/src/{ => legacy}/sidebar.js | 0 .../web/frontend/src/{ => legacy}/state.js | 0 .../src/{ => legacy}/styles/components.css | 0 .../src/{ => legacy}/styles/graph.css | 0 .../src/{ => legacy}/styles/index.css | 0 .../src/{ => legacy}/styles/inspector.css | 0 .../src/{ => legacy}/styles/pipeline.css | 0 .../src/{ => legacy}/styles/search.css | 0 .../src/{ => legacy}/styles/shell.css | 0 .../src/{ => legacy}/styles/sidebar.css | 0 .../src/{ => legacy}/styles/tokens.css | 0 .../src/{ => legacy}/styles/workbench.css | 0 .../src/{ => legacy}/styles/workspace.css | 0 .../web/frontend/src/{ => legacy}/utils.js | 0 .../frontend/src/{ => legacy}/workbench.js | 0 .../frontend/src/{ => legacy}/workspace.js | 0 .../app/web/frontend/src/lib/api/client.ts | 89 +++++++++ .../app/web/frontend/src/lib/api/index.ts | 2 + .../app/web/frontend/src/lib/api/projects.ts | 54 ++++++ .../app/web/frontend/src/lib/utils.ts | 6 + .../web/frontend/src/pages/DashboardPage.tsx | 170 +++++++++++++----- .../frontend/src/stores/slices/crawlSlice.ts | 132 +++----------- .../src/stores/slices/ontologySlice.ts | 91 +++------- .../app/web/frontend/tailwind.config.js | 2 +- 41 files changed, 669 insertions(+), 232 deletions(-) create mode 100644 crawler_platform/app/web/frontend/public/locales/en/common.json create mode 100644 crawler_platform/app/web/frontend/public/locales/ko/common.json create mode 100644 crawler_platform/app/web/frontend/src/components/layout/AppShell.tsx create mode 100644 crawler_platform/app/web/frontend/src/components/ui/button.tsx create mode 100644 crawler_platform/app/web/frontend/src/components/ui/card.tsx create mode 100644 crawler_platform/app/web/frontend/src/components/ui/skeleton.tsx create mode 100644 crawler_platform/app/web/frontend/src/hooks/queryKeys.ts create mode 100644 crawler_platform/app/web/frontend/src/hooks/useProjects.ts rename crawler_platform/app/web/frontend/src/{ => legacy}/api.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/graph.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/i18n.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/inspector.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/main.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/pipeline.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/search.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/shell.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/sidebar.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/state.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/components.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/graph.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/index.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/inspector.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/pipeline.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/search.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/shell.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/sidebar.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/tokens.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/workbench.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/styles/workspace.css (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/utils.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/workbench.js (100%) rename crawler_platform/app/web/frontend/src/{ => legacy}/workspace.js (100%) create mode 100644 crawler_platform/app/web/frontend/src/lib/api/client.ts create mode 100644 crawler_platform/app/web/frontend/src/lib/api/index.ts create mode 100644 crawler_platform/app/web/frontend/src/lib/api/projects.ts create mode 100644 crawler_platform/app/web/frontend/src/lib/utils.ts diff --git a/crawler_platform/app/web/frontend/public/locales/en/common.json b/crawler_platform/app/web/frontend/public/locales/en/common.json new file mode 100644 index 0000000..91a7d4e --- /dev/null +++ b/crawler_platform/app/web/frontend/public/locales/en/common.json @@ -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" + } +} diff --git a/crawler_platform/app/web/frontend/public/locales/ko/common.json b/crawler_platform/app/web/frontend/public/locales/ko/common.json new file mode 100644 index 0000000..274c0a1 --- /dev/null +++ b/crawler_platform/app/web/frontend/public/locales/ko/common.json @@ -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": "완료" + } +} diff --git a/crawler_platform/app/web/frontend/src/App.tsx b/crawler_platform/app/web/frontend/src/App.tsx index baad009..c3878ee 100644 --- a/crawler_platform/app/web/frontend/src/App.tsx +++ b/crawler_platform/app/web/frontend/src/App.tsx @@ -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 ( -
- + + }> } /> } /> } /> } /> } /> - -
+ + ); } diff --git a/crawler_platform/app/web/frontend/src/components/layout/AppShell.tsx b/crawler_platform/app/web/frontend/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..c00768b --- /dev/null +++ b/crawler_platform/app/web/frontend/src/components/layout/AppShell.tsx @@ -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 ( +
+ + +
+
+

+ {t("app.subtitle", "AI-powered ontology construction")} +

+
+
+ +
+
+
+ ); +} diff --git a/crawler_platform/app/web/frontend/src/components/ui/button.tsx b/crawler_platform/app/web/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..169c28a --- /dev/null +++ b/crawler_platform/app/web/frontend/src/components/ui/button.tsx @@ -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, + VariantProps {} + +export const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( + - - -
-
- -

{t("Quick Start")}

-

- {t("Get started by uploading your domain ontology")} -

-
- -
- -

{t("Phase 5 GraphRAG")}

-

- {t("Intelligent entity resolution and deduplication")} -

-
- -
- -

{t("Phase 7 LLM")}

-

- {t("AI-powered extraction and validation")} -

-
+
+
+
+

+ {t("dashboard.title", "Ontology Builder")} +

+

+ {t( + "dashboard.subtitle", + "Build and manage domain ontologies with AI-powered extraction", + )} +

+
+ +
+
+

+ {t("dashboard.projects", "Projects")} +

+ {projects && ( + + {t("dashboard.projectCount", "{{count}} total", { + count: projects.length, + })} + + )} +
+ + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {isError && ( + + + + + {t("dashboard.loadFailed", "Failed to load projects")} + + {(error as Error).message} + + + + + + )} + + {projects && projects.length === 0 && ( + + + +
+

+ {t("dashboard.empty.title", "No projects yet")} +

+

+ {t( + "dashboard.empty.hint", + "Create your first project to start building an ontology", + )} +

+
+ +
+
+ )} + + {projects && projects.length > 0 && ( +
+ {projects.map((p) => ( + 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" + > + + {p.name} + {p.domain} + + + + {formatRelative(p.updated_at)} + + + ))} +
+ )} +
); } diff --git a/crawler_platform/app/web/frontend/src/stores/slices/crawlSlice.ts b/crawler_platform/app/web/frontend/src/stores/slices/crawlSlice.ts index 02e894c..19900a3 100644 --- a/crawler_platform/app/web/frontend/src/stores/slices/crawlSlice.ts +++ b/crawler_platform/app/web/frontend/src/stores/slices/crawlSlice.ts @@ -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) => { - 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) => { + 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) => { + state.expandedStep = action.payload; }, - updateStats: (state, action: PayloadAction>) => { - 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) => { - state.error = action.payload; - state.isRunning = false; - }, - completeCrawl: (state) => { - state.isRunning = false; - }, - resetCrawl: (state) => { - Object.assign(state, initialState); + setAutoScroll: (state, action: PayloadAction) => { + 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; diff --git a/crawler_platform/app/web/frontend/src/stores/slices/ontologySlice.ts b/crawler_platform/app/web/frontend/src/stores/slices/ontologySlice.ts index d19339c..8ef23d2 100644 --- a/crawler_platform/app/web/frontend/src/stores/slices/ontologySlice.ts +++ b/crawler_platform/app/web/frontend/src/stores/slices/ontologySlice.ts @@ -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>) => { - Object.assign(state, action.payload); + updateDraft: (state, action: PayloadAction>) => { + state.draft = { ...state.draft, ...action.payload }; + state.isDirty = true; }, - addEntity: (state, action: PayloadAction) => { - state.entities.push(action.payload); + resetDraft: (state) => { + state.draft = emptyDraft; + state.isDirty = false; }, - removeEntity: (state, action: PayloadAction) => { - state.entities = state.entities.filter((e) => e.id !== action.payload); - }, - addRelation: (state, action: PayloadAction) => { - state.relations.push(action.payload); - }, - removeRelation: (state, action: PayloadAction) => { - state.relations = state.relations.filter((r) => r.id !== action.payload); - }, - setLoading: (state, action: PayloadAction) => { - state.isLoaded = action.payload; - }, - setSaving: (state, action: PayloadAction) => { - state.isSaving = action.payload; - }, - setError: (state, action: PayloadAction) => { - 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; diff --git a/crawler_platform/app/web/frontend/tailwind.config.js b/crawler_platform/app/web/frontend/tailwind.config.js index 83b8a92..36ffc4d 100644 --- a/crawler_platform/app/web/frontend/tailwind.config.js +++ b/crawler_platform/app/web/frontend/tailwind.config.js @@ -2,7 +2,7 @@ export default { content: [ "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", + "./src/**/*.{ts,tsx}", ], theme: { extend: {