Files
C.E.L_Slide_test2/Front/client/src/components/LeftMdxPanel.tsx
kyeongmin 0f0d3fa91f 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.
2026-05-14 14:48:42 +09:00

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