feat(frontend): add Front/ — Vite/React frontend with backend pipeline integration
Mirror of design_agent_front/design-agent/ for shipping alongside backend.
Vite plugin (vitePluginPhaseZApi) endpoints :
- POST /api/run — spawn `python -m src.phase_z2_pipeline` with overrides
- GET /api/sample-mdx?mdx=03/04/05 — fixed sample MDX
- GET /frame-preview/{n} — figma preview thumbnails
- GET /data/runs/{run_id}/{path} — pipeline artifacts (final.html, step*.json, ...)
Env toggle forward (보고용) :
PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT / PHASE_Z_MAX_RANK=32
Components :
- LeftMdxPanel (03/04/05 fix list + section tree)
- SlideCanvas (iframe + slideOverrideCss prop for inline CSS inject)
- FramePanel (label priority + confidence sort)
- LayoutPanel
README with mermaid diagrams covering the 5-step demo flow.
node_modules / dist / .manus-logs / .env excluded via .gitignore.
This commit is contained in:
344
Front/client/src/components/LeftMdxPanel.tsx
Normal file
344
Front/client/src/components/LeftMdxPanel.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* LeftMdxPanel - 좌측 MDX 구조 탐색기
|
||||
*
|
||||
* 기능:
|
||||
* - MDX 파일 업로드 버튼
|
||||
* - 추출된 섹션들의 계층 구조(Level 1~3) 표시
|
||||
* - 섹션 클릭 시 중앙 캔버스의 해당 구역 하이라이트
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Upload,
|
||||
Zap,
|
||||
Layers,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { toast } from 'sonner';
|
||||
import type { NormalizedContent } from '../types/designAgent';
|
||||
|
||||
interface LeftMdxPanelProps {
|
||||
normalizedContent: NormalizedContent | null;
|
||||
uploadedFile: File | null;
|
||||
isLoading: boolean;
|
||||
selectedSectionId: string | null;
|
||||
hasSlidePlan: boolean;
|
||||
/** 사용자가 frame / section / layout / zone 등 override 를 변경했을 때 true.
|
||||
* 하단 버튼이 "선택대로 재생성하기" 강조 버튼으로 전환. */
|
||||
hasPendingChanges?: boolean;
|
||||
onFileUpload: (file: File) => void;
|
||||
onGenerate: () => void;
|
||||
onSectionClick: (sectionId: string) => void;
|
||||
/** 사용자 lock 2026-05-14 — 좌측 패널에 03/04/05 fix 고정 list. 클릭 시 callback. */
|
||||
onSelectSample?: (which: "03" | "04" | "05") => void;
|
||||
selectedSample?: "03" | "04" | "05" | null;
|
||||
}
|
||||
|
||||
const SAMPLE_MDX_LIST: { key: "03" | "04" | "05"; label: string; subtitle: string }[] = [
|
||||
{ key: "03", label: "03. DX 시행을 위한 필수 요건", subtitle: "필수 요건 + Process/Product 혁신" },
|
||||
{ key: "04", label: "04. DX 지연 요인", subtitle: "DX 인식 + 정책/조직 실태" },
|
||||
{ key: "05", label: "05. 설계 방식의 왜곡", subtitle: "설계 자동화 오용 + S/W 한계" },
|
||||
];
|
||||
|
||||
export default function LeftMdxPanel({
|
||||
normalizedContent,
|
||||
uploadedFile,
|
||||
isLoading,
|
||||
selectedSectionId,
|
||||
hasSlidePlan,
|
||||
hasPendingChanges = false,
|
||||
onFileUpload,
|
||||
onGenerate,
|
||||
onSectionClick,
|
||||
onSelectSample,
|
||||
selectedSample = null,
|
||||
}: LeftMdxPanelProps) {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onFileUpload(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-slate-50">
|
||||
{/* ── 헤더: 문서 정보 ── */}
|
||||
<div className="p-4 border-b border-slate-200 bg-white">
|
||||
<h2 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
MDX Source
|
||||
</h2>
|
||||
|
||||
{/* 2026-05-14 — 03/04/05 fix 고정 list. 클릭 시 해당 mdx 자동 fetch + 분석.
|
||||
frame/layout override 는 분석 후 우측 패널에서 가능. */}
|
||||
{onSelectSample && (
|
||||
<div className="space-y-1 mb-3">
|
||||
{SAMPLE_MDX_LIST.map((s) => {
|
||||
const isActive = selectedSample === s.key;
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => onSelectSample(s.key)}
|
||||
disabled={isLoading}
|
||||
className={`w-full text-left p-2 rounded-md border transition-all ${
|
||||
isActive
|
||||
? "bg-blue-50 border-blue-300 ring-1 ring-blue-200"
|
||||
: "bg-white border-slate-200 hover:bg-slate-50 hover:border-slate-300"
|
||||
} ${isLoading ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
title={`${s.label} — 클릭하여 슬라이드 생성`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded flex items-center justify-center shrink-0 ${
|
||||
isActive ? "bg-blue-200 text-blue-700" : "bg-slate-100 text-slate-500"
|
||||
}`}>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-[11px] font-bold truncate ${
|
||||
isActive ? "text-blue-700" : "text-slate-700"
|
||||
}`}>{s.label}</p>
|
||||
<p className="text-[10px] text-slate-400 truncate">{s.subtitle}</p>
|
||||
</div>
|
||||
{isActive && <div className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-slate-50 border border-slate-200">
|
||||
<div className="w-8 h-8 rounded bg-blue-100 flex items-center justify-center text-blue-600">
|
||||
<FileText className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-semibold text-slate-700 truncate">{uploadedFile.name}</p>
|
||||
<p className="text-[10px] text-slate-400">{(uploadedFile.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
{!hasSlidePlan && (
|
||||
<button
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 transition-colors"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Layers className="w-4 h-4 rotate-45" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 border-dashed border-2 flex items-center justify-center gap-2 text-slate-500 hover:text-blue-600 hover:border-blue-300 hover:bg-blue-50"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="text-[11px] font-medium">또는 직접 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept=".mdx,.md"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 분석 진행 상태 (로딩 시) ── */}
|
||||
{isLoading && (
|
||||
<div className="p-4 border-b border-slate-200 bg-white">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] font-semibold text-blue-600">콘텐츠 분석 중...</span>
|
||||
<span className="text-[10px] text-slate-400">STAGE 1</span>
|
||||
</div>
|
||||
<Progress value={45} className="h-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 섹션 트리 리스트 ── */}
|
||||
<ScrollArea className="flex-1 p-2">
|
||||
{!normalizedContent ? (
|
||||
<div className="py-20 text-center px-4">
|
||||
<List className="w-8 h-8 text-slate-200 mx-auto mb-2" />
|
||||
<p className="text-[11px] text-slate-400">파일을 업로드하면<br />문서 구조가 표시됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* 문서 제목 (Level 1) */}
|
||||
<div className="px-2 py-1.5 mb-2">
|
||||
<h1 className="text-sm font-bold text-slate-800 leading-tight">
|
||||
{normalizedContent.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 섹션들 (Level 2+) — native HTML5 drag (framer-motion drag 와 충돌
|
||||
있어서 native 만 사용). dataTransfer 에 sectionId / section-id /
|
||||
text/plain 다 set 해서 SlideCanvas drop 이 어떤 키로든 받게. */}
|
||||
{normalizedContent.sections.map((section) => {
|
||||
const isSelected = selectedSectionId === section.id;
|
||||
const subSections = section.sub_sections ?? [];
|
||||
|
||||
return (
|
||||
<div key={section.id} className="space-y-0.5">
|
||||
{/* 중목차 (S1, S2 ...) */}
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("sectionId", section.id);
|
||||
e.dataTransfer.setData("section-id", section.id);
|
||||
e.dataTransfer.setData("text/plain", section.id);
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-md transition-all group relative cursor-grab active:cursor-grabbing ${
|
||||
isSelected
|
||||
? 'bg-blue-50 text-blue-700 shadow-sm ring-1 ring-blue-100'
|
||||
: 'hover:bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onSectionClick(section.id)}
|
||||
title={`S${section.index} — 드래그하여 zone 에 배정`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<ChevronRight className={`w-3 h-3 mt-1 shrink-0 transition-transform ${subSections.length > 0 ? 'rotate-90' : ''} ${isSelected ? 'text-blue-500' : 'text-slate-300'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className={`text-[10px] font-bold px-1 rounded ${isSelected ? 'bg-blue-200/50 text-blue-600' : 'bg-slate-200 text-slate-500'}`}>
|
||||
S{section.index}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold truncate">
|
||||
{section.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-[10px] line-clamp-2 leading-relaxed ${isSelected ? 'text-blue-600/70' : 'text-slate-400'}`}>
|
||||
{section.content_objects?.[0]?.raw_payload || (subSections.length > 0 ? `소목차 ${subSections.length} 개` : "내용 없음")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-blue-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 소목차 (S1.1, S1.2 ...) — 펼친 상태로 indent 표시. 각자 draggable. */}
|
||||
{subSections.length > 0 && (
|
||||
<div className="ml-4 pl-2 border-l border-slate-200 space-y-0.5">
|
||||
{subSections.map((sub) => {
|
||||
const isSubSelected = selectedSectionId === sub.id;
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("sectionId", sub.id);
|
||||
e.dataTransfer.setData("section-id", sub.id);
|
||||
e.dataTransfer.setData("text/plain", sub.id);
|
||||
}}
|
||||
className={`w-full text-left p-1.5 rounded-md transition-all relative cursor-grab active:cursor-grabbing ${
|
||||
isSubSelected
|
||||
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-100'
|
||||
: 'hover:bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
onClick={() => onSectionClick(sub.id)}
|
||||
title={`S${section.index}.${sub.index} — 드래그하여 zone 에 배정`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[9px] font-bold px-1 rounded shrink-0 ${isSubSelected ? 'bg-blue-200/50 text-blue-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||
S{section.index}.{sub.index}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium truncate">
|
||||
{sub.title}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* ── 하단 분석 버튼 ── */}
|
||||
<div className="p-3 border-t border-slate-200 bg-white">
|
||||
{!normalizedContent ? (
|
||||
<div className="text-center py-2">
|
||||
<p className="text-[10px] text-slate-400 mb-2 font-medium uppercase tracking-tight">MDX 분석 대기 중</p>
|
||||
<Button
|
||||
className="w-full h-10 border-slate-200 text-slate-400"
|
||||
variant="outline"
|
||||
disabled
|
||||
>
|
||||
슬라이드 생성하기
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{!hasSlidePlan ? (
|
||||
<Button
|
||||
className="w-full gap-2 bg-blue-600 hover:bg-blue-700 h-10 shadow-lg shadow-blue-500/20"
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5 fill-current" />
|
||||
슬라이드 플랜 생성하기
|
||||
</Button>
|
||||
) : hasPendingChanges ? (
|
||||
// override 변경 있음 — 강조 "선택대로 재생성하기" 버튼
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 px-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="text-[10px] font-black text-amber-700 uppercase tracking-tighter">
|
||||
Pending Changes
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full gap-2 bg-amber-500 hover:bg-amber-600 h-11 shadow-lg shadow-amber-500/30 text-white font-bold"
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Zap className="w-4 h-4 fill-current" />
|
||||
선택대로 재생성하기
|
||||
</Button>
|
||||
<p className="text-[10px] text-slate-500 leading-tight px-1">
|
||||
레이아웃 / 프레임 / 섹션 변경을 backend 에 적용해 새 final.html 을
|
||||
생성합니다. (※ Step D backend forwarding 연결 후 활성)
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[10px] font-bold text-emerald-600 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3 fill-current" />
|
||||
플랜 생성 완료
|
||||
</span>
|
||||
<button
|
||||
className="text-[10px] text-blue-600 font-bold hover:underline"
|
||||
onClick={onGenerate}
|
||||
>
|
||||
재생성
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-8 text-[11px] border-slate-200 bg-slate-50"
|
||||
onClick={() => toast.info("MDX 원문 편집 기능은 준비 중입니다.")}
|
||||
>
|
||||
MDX 소스 보기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user