/** * Home - 메인 페이지 (Zone-Centric 슬라이드 빌더) */ import { useState, useCallback, useMemo, useEffect } from "react"; import { toast } from "sonner"; import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent"; import { createInitialUserSelection, selectZone, selectRegion, applyLayout, applyFrame, getSelectedZone, getSelectedRegion, moveSectionToZone, saveZoneSizes, } from "../utils/slidePlanUtils"; import { parseMdxFile, runPipeline, loadRun, computeZonePositions, type RunMeta, type PipelineOverrides, } from "../services/designAgentApi"; import LeftMdxPanel from "../components/LeftMdxPanel"; import SlideCanvas from "../components/SlideCanvas"; import LayoutPanel from "../components/LayoutPanel"; import FramePanel from "../components/FramePanel"; import { Sparkles, Download, Link2, Loader2, CheckCircle2, HelpCircle, } from "lucide-react"; import { Button } from "@/components/ui/button"; const INITIAL_STATE: DesignAgentState = { uploadedFile: null, normalizedContent: null, slidePlan: null, userSelection: createInitialUserSelection(), isLoading: false, error: null, }; type RightPanelTab = "layout" | "frame"; export default function Home() { const [state, setState] = useState(INITIAL_STATE); const [rightTab, setRightTab] = useState("frame"); // Phase Z run 산출물 메타 (loadRun 호출 시 set — Step 5 에서 연결). const [runMeta, setRunMeta] = useState(null); // Phase 1 : 사용자가 frame / section / layout / zone resize 등 override 를 // 변경했을 때 true. 좌측 하단 버튼이 "선택대로 재생성하기" 로 전환. // handleGenerate (재생성) 시 false 로 reset. const [hasPendingChanges, setHasPendingChanges] = useState(false); // Phase 2 : 사용자가 LayoutPanel 의 다른 layout 카드 → "적용하기" 누르면 // 빈 layout 모드로 전환 (final.html iframe 숨기고 빈 zone 만 표시 → 사용자가 // section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시). const [pendingLayout, setPendingLayout] = useState(null); // pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan. // 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect / // getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용. // pendingLayout 활성 시 빈 layout 의 zones 계산 (정규화 좌표). // section_ids 는 userSelection.overrides.zone_sections 에서만 — default 무시 // (사용자가 직접 채워야 함). frame_candidates 는 normal mode 의 union 사용. const pendingZones = useMemo(() => { if (!pendingLayout) return null; const positions = computeZonePositions(pendingLayout); return positions.map((p, idx) => { const sectionIds = state.userSelection.overrides.zone_sections?.[p.name] ?? []; // 그 zone 에 할당된 section 의 frame_candidates 를 자동 결정 결과 (slidePlan) // 에서 lookup. section_id 매칭되는 zone 의 internal_regions[0].frame_candidates 사용. // 없으면 첫 번째 zone 의 후보 fallback (시각용 — 정확한 매칭은 Step D 의 // backend forwarding 후에 의미 있음). const sourceZone = sectionIds.length > 0 ? state.slidePlan?.zones.find((z) => sectionIds.some((sid) => z.section_ids.includes(sid)) ) : undefined; const frameCandidates = sourceZone?.internal_regions[0]?.frame_candidates ?? state.slidePlan?.zones[0]?.internal_regions[0]?.frame_candidates ?? []; return { id: `pending-zone-${idx}`, zone_id: p.name, section_ids: sectionIds, position: p.geometry, internal_regions: [ { id: `pending-region-${idx}`, region_id: "region-single", role: "primary" as const, content_type: "text_block" as const, ratio_estimate: 1, content_unit_ids: [], frame_match_strategy: { kind: "frame_match" as const, frame_id: null, display_strategy: "inline_full" as const, }, frame_candidates: frameCandidates, }, ], region_layout_type: "region-single", } as Zone; }); }, [pendingLayout, state.userSelection.overrides.zone_sections, state.slidePlan]); // Layout 카드의 "적용하기" 클릭 → pendingLayout 모드 진입. // 기존 zone_sections 를 새 layout 의 positions 순서로 자동 carry over — // horizontal-2 (top, bottom) → vertical-2 (left, right) 같은 case 에서 사용자가 // 매번 drag drop 다시 안 해도 되게. 새 layout zone count 가 적으면 마지막 zone 에 합침. const handleApplyPendingLayout = useCallback((layoutId: LayoutPresetId) => { setState((p) => { const newPositions = computeZonePositions(layoutId).map((pos) => pos.name); const oldZones = p.slidePlan?.zones ?? []; const carriedZoneSections: Record = {}; oldZones.forEach((zone, idx) => { const targetPos = idx < newPositions.length ? newPositions[idx] : newPositions[newPositions.length - 1]; // 적은 zone count: 마지막에 합침 if (!targetPos) return; if (!carriedZoneSections[targetPos]) { carriedZoneSections[targetPos] = []; } carriedZoneSections[targetPos].push(...zone.section_ids); }); return { ...p, userSelection: { ...p.userSelection, overrides: { ...p.userSelection.overrides, layout_preset: layoutId, zone_sections: carriedZoneSections, }, selectedZoneId: null, selectedRegionId: null, }, }; }); setPendingLayout(layoutId); setHasPendingChanges(true); setRightTab("frame"); }, []); // pending 모드 취소 → 평소 (final.html iframe) 모드 복귀. const handleCancelPendingLayout = useCallback(() => { setPendingLayout(null); setState((p) => ({ ...p, userSelection: createInitialUserSelection(p.slidePlan), })); setHasPendingChanges(false); }, []); // ── 파일 업로드 → MDX 분석만 (파이프라인 X) ── // 업로드 시점에는 좌측 패널의 섹션 트리만 채움. 실제 Phase Z 파이프라인은 // 하단 "슬라이드 플랜 생성하기" 버튼 (handleGenerate) 에서 실행. const handleFileUpload = useCallback(async (file: File) => { setState((p) => ({ ...p, uploadedFile: file, isLoading: true, normalizedContent: null, slidePlan: null, })); setRunMeta(null); try { const content = await parseMdxFile(file); setState((p) => ({ ...p, normalizedContent: content, isLoading: false })); toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`); } catch (err) { console.error(err); toast.error( `MDX 분석 오류: ${err instanceof Error ? err.message : String(err)}` ); setState((p) => ({ ...p, isLoading: false })); } }, []); // 2026-05-14 — 좌측 패널의 03/04/05 fix list 클릭 또는 URL `?mdx=04` 변경 시 // 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger. const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null); // 2026-05-14 — mdx 별 slide-level CSS override (catalog/template 무변, frontend layer only). // SlideCanvas 의 iframe onLoad 에서 동적 inject. 사용자 룰 : "보고용 슬라이드 결과물 단위" // 변경. mdx04 의 default (rank 1 = process_product_two_way) 일 때만 적용 — 사용자 frame // override 후 (rank 2 = bim_dx_comparison_table 등) 다른 frame 시 무적용. const MDX04_DEFAULT_OVERRIDE_CSS = ` .slide-body { grid-template-rows: 0.38fr 0.60fr !important; gap: 1.5% !important; } .f29b__cell .text-line + .text-line { margin-top: 1px !important; } .f29b__cell:nth-child(n+3) { padding-top: 3px !important; margin-top: 2px !important; } `.trim(); const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => { try { const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`); if (!res.ok) return; const text = await res.text(); const filenameHeader = res.headers.get("X-Mdx-Filename"); const filename = filenameHeader ? decodeURIComponent(filenameHeader) : `${which}_demo.mdx`; const file = new File([text], filename, { type: "text/markdown" }); setSelectedSample(which); handleFileUpload(file); // 슬라이드 생성은 사용자가 "슬라이드 플랜 생성하기" 클릭 시 handleGenerate 에서. } catch { // backend 미작동 } // handleFileUpload 는 useCallback 으로 안정 — eslint dep 우회 // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 페이지 첫 로드 시 데모용 mdx 자동 로드 — 상대방에게 mdx 파일 공유 안 해도 되게. // URL query `?mdx=04` / `?mdx=05` 로 다른 sample 선택 가능. default = 03. // 사용자가 다른 파일을 직접 업로드하면 그것이 override 됨. useEffect(() => { const which = (new URLSearchParams(window.location.search).get("mdx") as "03" | "04" | "05" | null) || "03"; if (!["03", "04", "05"].includes(which)) return; handleSelectSample(which); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── 슬라이드 플랜 생성 → Phase Z 파이프라인 실행 → 산출물 로드 ── const handleGenerate = useCallback(async () => { if (!state.uploadedFile) { toast.error("MDX 파일을 먼저 업로드하세요."); return; } // mdx04 도 mdx03 와 동일하게 정상 backend pipeline path (demo mode 제거). // Step D : userSelection.overrides → backend 가 받는 schema 로 변환. // - layout : userSelection.overrides.layout_preset (default 와 다를 때만) // - frames : zone.section_ids → unit_id ("+".join). region.id 별 zone_frames lookup. // pendingZones 의 region 도 동일 — 그 region 의 zone 의 sections 가 unit_id 결정. const overrides: PipelineOverrides = {}; const sourcePlan = effectiveSlidePlan; if (sourcePlan && state.slidePlan) { const defaultLayout = state.slidePlan.layout_preset; const overrideLayout = state.userSelection.overrides.layout_preset; if (overrideLayout && overrideLayout !== defaultLayout) { overrides.layout = overrideLayout; } const frames: Record = {}; const skippedNoCatalog: string[] = []; sourcePlan.zones.forEach((zone) => { const region = zone.internal_regions[0]; if (!region) return; const overrideFrameId = state.userSelection.overrides.zone_frames?.[region.id]; const defaultFrameId = region.frame_match_strategy.frame_id; if ( overrideFrameId && overrideFrameId !== defaultFrameId && zone.section_ids.length > 0 ) { // catalog 미등록 frame 은 backend 가 어차피 skip — frontend 에서 미리 거름 // (불필요한 round trip + 사용자 헷갈림 방지). 후보 list 에서 catalog flag lookup. const candidateMatch = region.frame_candidates?.find( (c) => c.id === overrideFrameId ); if (candidateMatch?.catalogRegistered === false) { skippedNoCatalog.push(`${zone.zone_id}=${overrideFrameId}`); return; } const unitId = zone.section_ids.join("+"); frames[unitId] = overrideFrameId; } }); if (Object.keys(frames).length > 0) overrides.frames = frames; if (skippedNoCatalog.length > 0) { toast.error( `catalog 미등록 frame ${skippedNoCatalog.length} 개는 backend 가 적용 못 함 — render path 미연결: ${skippedNoCatalog.join(", ")}` ); } // zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 / // vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율. const zoneGeometries = state.userSelection.overrides.zone_geometries; if (zoneGeometries && Object.keys(zoneGeometries).length > 0) { overrides.zoneGeometries = zoneGeometries; } // IMP-08 B-3 : zoneSections forward only when the user diverged from // the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection` // seeds `zone_sections` with the default placement, so a literal copy // would pollute backend assignment-source provenance even on a fresh // re-render. Diff against `sourcePlan.zones[].section_ids` per zone and // only emit zones whose section list differs. const userZoneSections = state.userSelection.overrides.zone_sections; if (userZoneSections) { const defaultByZone = new Map(); sourcePlan.zones.forEach((z) => { defaultByZone.set(z.zone_id, z.section_ids); }); const zoneSectionsDiff: Record = {}; for (const [zoneId, sids] of Object.entries(userZoneSections)) { if (!Array.isArray(sids)) continue; const cleaned = sids.filter((s) => typeof s === "string" && s.trim()); const defaults = defaultByZone.get(zoneId) ?? []; const sameAsDefault = cleaned.length === defaults.length && cleaned.every((sid, i) => sid === defaults[i]); if (!sameAsDefault) { zoneSectionsDiff[zoneId] = cleaned; } } if (Object.keys(zoneSectionsDiff).length > 0) { overrides.zoneSections = zoneSectionsDiff; } } } setState((p) => ({ ...p, isLoading: true })); setHasPendingChanges(false); // 재생성 트리거 시 override pending flag reset setPendingLayout(null); // pending layout 모드 종료 const overrideSummary = Object.keys(overrides).length > 0 ? `(overrides: ${[ overrides.layout && `layout=${overrides.layout}`, overrides.frames && `frames=${Object.keys(overrides.frames).length}`, overrides.zoneSections && `zoneSections=${Object.keys(overrides.zoneSections).length}`, ] .filter(Boolean) .join(", ")})` : ""; toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`); try { const result = await runPipeline(state.uploadedFile, overrides); if (!result.success || !result.final_html_exists) { const detail = result.stderr?.trim().split("\n").slice(-3).join(" | ") || result.error || `exit_code=${result.exit_code}`; toast.error(`파이프라인 실패: ${detail}`); setState((p) => ({ ...p, isLoading: false })); return; } const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id); setState((p) => ({ ...p, normalizedContent, slidePlan, userSelection: createInitialUserSelection(slidePlan), isLoading: false, })); setRunMeta(runMeta); toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`); } catch (err) { console.error(err); toast.error( `파이프라인 실행 오류: ${err instanceof Error ? err.message : String(err)}` ); setState((p) => ({ ...p, isLoading: false })); } }, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout]); // ── 섹션 드래그 앤 드롭 (Zone으로 재배치) ── const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => { setState((p) => { const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId); return { ...p, userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택 }; }); setRightTab("frame"); setHasPendingChanges(true); }, []); // ── Zone 클릭 ── const handleZoneClick = useCallback((zoneId: string) => { setState((p) => ({ ...p, userSelection: selectZone(p.userSelection, zoneId) })); setRightTab("frame"); }, []); // ── Region 클릭 ── const handleRegionClick = useCallback((regionId: string) => { setState((p) => ({ ...p, userSelection: selectRegion(p.userSelection, regionId) })); setRightTab("frame"); }, []); // ── Layout 선택 ── const handleLayoutSelect = useCallback((layoutId: string) => { setState((p) => ({ ...p, userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId) })); setHasPendingChanges(true); }, []); const handleLayoutResize = useCallback((groupId: string, sizes: number[]) => { setState((p) => ({ ...p, userSelection: saveZoneSizes(p.userSelection, groupId, sizes) })); setHasPendingChanges(true); }, []); const handleZoneResize = useCallback((geometries: Record) => { setState((p) => ({ ...p, userSelection: { ...p.userSelection, overrides: { ...p.userSelection.overrides, zone_geometries: { ...p.userSelection.overrides.zone_geometries, ...geometries } } } })); setHasPendingChanges(true); }, []); // 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 — // SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다 // re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림). const handleContentEdit = useCallback(() => { setHasPendingChanges(true); }, []); // pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan. // 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 → // pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가 // SlideCanvas overlay 의 region.id 와 매칭됨. const effectiveSlidePlan = useMemo(() => { if (pendingZones && state.slidePlan) { return { ...state.slidePlan, zones: pendingZones, layout_preset: pendingLayout! }; } return state.slidePlan; }, [pendingZones, state.slidePlan, pendingLayout]); // 2026-05-14 — slide-level CSS override 계산. mdx04 default (rank 1 = process_product_two_way) // 일 때만 적용 (catalog 무변, slide 결과물에만 inject). 사용자 frame override 후 다른 // frame 시 무적용 (rank 2 의 frame visual 유지). const slideOverrideCss = useMemo(() => { if (selectedSample !== "04") return undefined; const zone04_2 = state.slidePlan?.zones.find((z) => z.zone_id === "bottom"); const frameId = zone04_2?.internal_regions[0]?.frame_match_strategy.frame_id; if (frameId !== "process_product_two_way") return undefined; return MDX04_DEFAULT_OVERRIDE_CSS; }, [selectedSample, state.slidePlan]); // ── Frame 선택 ── const handleFrameSelect = useCallback((frameId: string) => { const zone = getSelectedZone(effectiveSlidePlan, state.userSelection); const region = getSelectedRegion(zone, state.userSelection) || zone?.internal_regions?.[0]; if (!region) { toast.error("디자인을 적용할 구역을 먼저 선택해주세요."); return; } setState((p) => ({ ...p, userSelection: applyFrame(p.userSelection, region.id, frameId) })); setHasPendingChanges(true); }, [effectiveSlidePlan, state.userSelection]); const selectedZone = getSelectedZone(effectiveSlidePlan, state.userSelection); const selectedRegion = getSelectedRegion(selectedZone, state.userSelection); return (
{/* Header */}
Design Agent
Active Slide / {state.slidePlan?.title ?? "새 슬라이드"} {runMeta && ( <> · run: {runMeta.run_id} {runMeta.status} {runMeta.filtered_section_ids.length > 0 && (
Filtered: {runMeta.filtered_section_ids.length}
{runMeta.filtered_section_reasons.map((r, i) => (
{r.section_ids.join(", ")}
selection_state: {r.selection_state}
{r.merge_type &&
merge_type: {r.merge_type}
} {r.template_id &&
template_id: {r.template_id}
} {r.v4_label &&
v4_label: {r.v4_label}
} {r.phase_z_status &&
phase_z_status: {r.phase_z_status}
} {r.score !== null &&
score: {r.score}
} {r.source &&
source: {r.source}
} {r.position &&
position: {r.position}
}
    {r.filter_reasons.map((reason, j) =>
  • {reason}
  • )}
))}
)} )}
{state.isLoading ? (
Processing...
) : state.slidePlan ? (
Synced
) : null}
{/* Main Content Area */}
{/* Left Panel: MDX Explorer (Drag Source) */} {/* Center: Slide Canvas (Drop Target & Resizable) */}
{ handleZoneClick(zoneId); setRightTab("frame"); }} onSlideClick={() => setRightTab("layout")} onRegionClick={handleRegionClick} onFrameSelect={handleFrameSelect} onSectionDrop={handleSectionDrop} onLayoutResize={handleLayoutResize} onZoneResize={handleZoneResize} />
{/* Right Panel: Frame / Layout Inspector */}
{/* Footer Actions */}
); }