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:
220
Front/client/src/components/FramePanel.tsx
Normal file
220
Front/client/src/components/FramePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user