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:
2026-05-14 14:45:30 +09:00
parent 52ccb7fc8b
commit 0f0d3fa91f
99 changed files with 20280 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
import React from 'react';
import {
Check,
Zap,
Layout,
Trophy
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { motion } from 'framer-motion';
import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent';
import { getSectionsForZone } from '../utils/slidePlanUtils';
interface FramePanelProps {
slidePlan: SlidePlan | null;
selectedZone: Zone | null;
selectedRegion: InternalRegion | null;
userSelection: UserSelection;
onFrameSelect: (frameId: string) => void;
onNoDesignToggle: () => void;
}
export default function FramePanel({
slidePlan,
selectedZone,
selectedRegion,
userSelection,
onFrameSelect,
}: FramePanelProps) {
// Zone 중심 컨셉: 선택된 존에 할당된 섹션들의 프레임 후보들을 수집
const assignedSectionIds = selectedZone ? getSectionsForZone(selectedZone, userSelection) : [];
// 해당 섹션들이 포함된 원본 리전들에서 후보군 추출
const candidates: FrameCandidate[] = React.useMemo(() => {
if (!slidePlan || !selectedZone) return [];
// 단순화: 선택된 존의 첫 번째 리전의 후보군을 우선 사용 (Phase Z MVP1 기준)
// 실제로는 할당된 섹션에 따라 동적으로 필터링된 후보군이 필요함
const targetRegion = selectedRegion || selectedZone.internal_regions[0];
return targetRegion?.frame_candidates || [];
}, [slidePlan, selectedZone, selectedRegion]);
const currentFrameId = React.useMemo(() => {
const targetRegion = selectedRegion || selectedZone?.internal_regions[0];
if (!targetRegion) return null;
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
if (!selectedZone) {
return (
<div className="h-full flex flex-col items-center justify-center bg-slate-50 p-8 text-center text-slate-400">
<Layout className="w-12 h-12 mb-4 opacity-10" />
<p className="text-xs font-bold uppercase tracking-[0.2em]">Select a Zone<br />to View Designs</p>
</div>
);
}
return (
<div className="h-full flex flex-col bg-white">
{/* Header */}
<div className="p-5 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<Zap className="w-3.5 h-3.5 fill-blue-500 text-blue-500" />
Design Wheel
</h3>
<Badge variant="outline" className="text-[9px] font-black border-slate-200 text-slate-400">
TOP {candidates.length}
</Badge>
</div>
<p className="text-[11px] text-slate-500 font-medium leading-tight">
({assignedSectionIds.join(', ')}) .
</p>
</div>
{/* Vertical Wheel List */}
<div className="flex-1 overflow-y-auto p-4 space-y-5">
{candidates.length === 0 ? (
<div className="py-20 text-center border-2 border-dashed border-slate-50 rounded-2xl">
<p className="text-[10px] font-bold text-slate-300 uppercase">No Candidates Available</p>
</div>
) : (
candidates.map((candidate, index) => {
const isSelected = currentFrameId === candidate.id;
const isReject = candidate.label === "reject";
// catalog 미등록 = backend Step 7-A 가 override 시도해도 skip.
// catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리).
const isCatalogMissing = candidate.catalogRegistered === false;
return (
<motion.div
key={candidate.id}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
className="w-full"
>
<button
onClick={() => onFrameSelect(candidate.id)}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("frameId", candidate.id);
}}
className={`w-full text-left rounded-3xl border-2 transition-all overflow-hidden group relative flex flex-col ${
isSelected
? 'border-blue-500 bg-white shadow-xl shadow-blue-500/10'
: isCatalogMissing
? 'border-slate-100 bg-slate-50/40 opacity-60 hover:opacity-90 hover:border-amber-200'
: isReject
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-slate-200'
: 'border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-white'
}`}
title={
isCatalogMissing
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
: isReject
? "V4 reject — render path 비추천"
: undefined
}
>
{/* Rank Badge */}
<div className="absolute top-3 left-3 z-10">
<div className={`px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-tighter flex items-center gap-1 shadow-sm ${
index === 0 ? 'bg-orange-500 text-white' : 'bg-slate-900 text-white'
}`}>
{index === 0 && <Trophy className="w-2 h-2" />}
Rank {index + 1}
</div>
</div>
{/* Preview Image */}
<div className="h-36 bg-slate-200 relative overflow-hidden">
{candidate.thumbnailUrl ? (
<img
src={candidate.thumbnailUrl}
alt={candidate.name}
className={`w-full h-full object-contain bg-white transition-transform duration-500 ${isSelected ? 'scale-105' : 'group-hover:scale-105'}`}
onError={(e) => {
// preview.png 없는 frame — placeholder 로 swap.
const img = e.currentTarget;
img.style.display = "none";
const parent = img.parentElement;
if (parent && !parent.querySelector(".frame-fallback")) {
const ph = document.createElement("div");
ph.className = "frame-fallback w-full h-full flex flex-col items-center justify-center bg-slate-100 text-slate-400";
ph.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-8 h-8 mb-1">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
</svg>
<span style="font-size:9px; font-weight:700; letter-spacing:0.05em; text-transform:uppercase;">no preview</span>
`;
parent.appendChild(ph);
}
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Layout className="w-10 h-10 text-slate-300" />
</div>
)}
{/* Confidence Score Overlay */}
<div className="absolute bottom-3 right-3 px-2 py-1 rounded-lg bg-blue-600/90 backdrop-blur text-white text-[10px] font-black shadow-lg">
{(candidate.score * 100).toFixed(0)}% Match
</div>
</div>
{/* Info Footer */}
<div className="p-4 bg-white">
<span className={`text-[11px] font-black uppercase tracking-tight block truncate mb-1 ${isSelected ? 'text-blue-700' : 'text-slate-800'}`}>
{candidate.name}
</span>
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest truncate">{candidate.id}</span>
<div className="flex items-center gap-1.5 shrink-0">
{/* catalog 미등록 배지 — backend render path 미연결 명시 */}
{isCatalogMissing && (
<span
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-slate-200 text-slate-600"
title="catalog 미등록 — backend 적용 X"
>
no catalog
</span>
)}
{/* V4 label badge */}
{candidate.label && (
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
candidate.label === "use_as_is"
? "bg-emerald-100 text-emerald-700"
: candidate.label === "light_edit"
? "bg-blue-100 text-blue-700"
: candidate.label === "restructure"
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}
title={`V4 label: ${candidate.label}`}
>
{candidate.label}
</span>
)}
{isSelected && (
<div className="flex items-center gap-1 text-[8px] font-black text-emerald-500 uppercase">
<Check className="w-2.5 h-2.5 stroke-[4]" />
Applied
</div>
)}
</div>
</div>
</div>
</button>
</motion.div>
);
})
)}
<div className="h-4" />
</div>
</div>
);
}