Phase 1.1: 프로젝트 생성 — 폼 기반 도메인 선택 + 인라인 JSON 생성
백엔드 (crawler_platform/app/api/routes.py):
- POST /projects/inline 추가: 파일 시스템 의존 없이 JSON 본문으로 ProjectConfig 인라인 생성
- CreateProjectInlineRequest + InlineSourceConfig Pydantic 모델
- GET /domains 추가: 미리 정의된 도메인 목록 (perfume, tea, coffee, candle, supplement, gift)
- 각 도메인의 entity_types, predicates, attribute_count 반환
프론트엔드 API 레이어 (src/lib/api/):
- domains.ts: domainsApi.list + Zod 스키마
- ontology.ts: ontologyApi.get(domain) + Zod 스키마
- projects.ts: createInline() 메서드 + CreateProjectInlineRequest 타입
TanStack Query 훅 (src/hooks/):
- useDomains: 도메인 목록 (30분 staleTime)
- useOntology(domain): 도메인 상세
- useCreateProjectInline: 인라인 생성 mutation + projects 캐시 무효화
- queryKeys에 domains, ontology 키 팩토리 추가
UI 프리미티브 (src/components/ui/):
- input.tsx, label.tsx, textarea.tsx
OnboardingPage 완전 교체 (src/pages/OnboardingPage.tsx):
- react-hook-form + zod 검증 (project_name 정규식, 도메인 필수)
- 도메인 카드 그리드 선택 (entity/predicate 개수 미리보기)
- 백엔드 /projects/inline POST → 성공 시 sonner 토스트 + /sources/{name}으로 이동
- 로딩/에러/재시도 UI
기타:
- Vite proxy에 /domains prefix 추가
- i18n locale 키 onboarding.* 추가 (한/영)
다음 단계: Phase 1.2 — 온톨로지 엔티티/클레임 직접 입력 + URL 추출
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,41 +1,237 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { AlertCircle, ArrowLeft, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDomains } from "@/hooks/useDomains";
|
||||
import { useCreateProjectInline } from "@/hooks/useProjects";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const projectNameRegex = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
const schema = z.object({
|
||||
project_name: z
|
||||
.string()
|
||||
.min(2, "최소 2자 이상")
|
||||
.max(64, "최대 64자")
|
||||
.regex(projectNameRegex, "영문/숫자/_/- 만 허용"),
|
||||
domain: z.string().min(1, "도메인을 선택하세요"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: domains,
|
||||
isLoading: domainsLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useDomains();
|
||||
const createProject = useCreateProjectInline();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { project_name: "", domain: "" },
|
||||
});
|
||||
|
||||
const selectedDomain = watch("domain");
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const selected = domains?.find((d) => d.domain === values.domain);
|
||||
try {
|
||||
const created = await createProject.mutateAsync({
|
||||
project_name: values.project_name,
|
||||
domain: values.domain,
|
||||
target_entities: selected?.entity_types ?? [],
|
||||
});
|
||||
toast.success(
|
||||
t("onboarding.created", "프로젝트가 생성되었습니다: {{name}}", {
|
||||
name: created.name,
|
||||
}),
|
||||
);
|
||||
navigate(`/sources/${created.name}`);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("onboarding.createFailed", "생성 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-2xl w-full">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{t("Upload Ontology")}
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/")}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("onboarding.title", "새 프로젝트 만들기")}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{t("Step 1 of 4: Upload your domain ontology (YAML, JSON, or OWL)")}
|
||||
</p>
|
||||
|
||||
<div className="border-2 border-dashed border-blue-300 rounded-lg p-8 text-center bg-blue-50 mb-6">
|
||||
<p className="text-gray-600">
|
||||
{t("Drag and drop your ontology file here, or click to browse")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/sources/demo-project")}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
>
|
||||
{t("Next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("onboarding.formTitle", "온톨로지 도메인 선택")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"onboarding.formDesc",
|
||||
"어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project_name">
|
||||
{t("onboarding.projectName", "프로젝트 이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="project_name"
|
||||
placeholder="my_perfume_project"
|
||||
aria-invalid={Boolean(errors.project_name)}
|
||||
{...register("project_name")}
|
||||
/>
|
||||
{errors.project_name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.project_name.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"onboarding.projectNameHint",
|
||||
"영문, 숫자, _ , - 만 사용 (2~64자)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("onboarding.domain", "도메인")}</Label>
|
||||
{isError && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center justify-between gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{(error as Error).message}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{t("common.retry", "다시 시도")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{domainsLoading && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{domains && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{domains.map((d) => {
|
||||
const active = selectedDomain === d.domain;
|
||||
return (
|
||||
<button
|
||||
key={d.domain}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setValue("domain", d.domain, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active && "border-primary bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 font-semibold capitalize">
|
||||
{d.domain}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("onboarding.domainSummary", {
|
||||
entities: d.entity_types.length,
|
||||
predicates: d.predicates.length,
|
||||
defaultValue:
|
||||
"엔티티 {{entities}}종 · 관계 {{predicates}}개",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground/70">
|
||||
{d.entity_types.slice(0, 5).join(", ")}
|
||||
{d.entity_types.length > 5 && " …"}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{errors.domain && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.domain.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
disabled={isSubmitting || createProject.isPending}
|
||||
>
|
||||
{t("common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || createProject.isPending}
|
||||
>
|
||||
{createProject.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t("onboarding.submit", "프로젝트 만들기")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user