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.
345 lines
15 KiB
TypeScript
345 lines
15 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|