Files
C.E.L_Slide_test2/Front/client/src/pages/Home.tsx
T

833 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
*/
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { toast } from "sonner";
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
import {
createInitialUserSelection,
selectZone,
selectRegion,
applyLayout,
applyFrame,
getSelectedZone,
getSelectedRegion,
moveSectionToZone,
saveZoneSizes,
saveImageOverride,
deriveUserOverridesKey,
applyPersistedNonFrameOverrides,
remapPersistedFramesToZoneFrames,
} from "../utils/slidePlanUtils";
import {
parseMdxFile,
runPipeline,
loadRun,
computeZonePositions,
formatAiRepairHumanReviewMessage,
type RunMeta,
type PipelineOverrides,
} from "../services/designAgentApi";
import {
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverrides,
} from "../services/userOverridesApi";
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<DesignAgentState>(INITIAL_STATE);
const [rightTab, setRightTab] = useState<RightPanelTab>("frame");
// Phase Z run 산출물 메타 (loadRun 호출 시 set — Step 5 에서 연결).
const [runMeta, setRunMeta] = useState<RunMeta | null>(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<LayoutPresetId | null>(null);
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
// handleFileUpload time. layout / zone_geometries / zone_sections are
// seeded into userSelection immediately (so handleGenerate forwards them
// as CLI args). frames are stashed here because their on-disk key
// (unit_id = section_ids joined by "+") only maps to region.id after
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
// 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<Zone[] | null>(() => {
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<string, string[]> = {};
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);
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
// {} on miss / corrupt / network failure (u5 contract) so the upload
// path never fails on a fresh MDX.
const overridesKey = deriveUserOverridesKey(file.name);
const persisted = await getUserOverrides(overridesKey);
persistedOverridesRef.current = persisted;
setState((p) => ({
...p,
normalizedContent: content,
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
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) {
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
const overrideLayout = state.userSelection.overrides.layout_preset;
if (overrideLayout) {
overrides.layout = overrideLayout;
}
const frames: Record<string, string> = {};
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;
}
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) {
const defaultByZone = new Map<string, string[]>();
sourcePlan.zones.forEach((z) => {
defaultByZone.set(z.zone_id, z.section_ids);
});
const zoneSectionsDiff: Record<string, string[]> = {};
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 {
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
// reads user_overrides.json on pipeline entry. Without this, a user
// who changes an override (300ms debounce window) and immediately
// clicks Generate would race the PUT against /api/run; the u2
// fallback could then load a stale persisted document.
await flushUserOverrides();
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);
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
// the user_overrides.json read at handleFileUpload time. Frames there
// are keyed by unit_id (section_ids joined by "+"); the in-memory
// zone_frames is keyed by region.id. Remap against the new slidePlan
// zones so SlideCanvas's override-vs-default preview indicator shows
// the user's persisted choice without forcing them to re-click.
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
slidePlan,
persistedOverridesRef.current.frames as Record<string, string> | undefined,
);
setState((p) => {
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
// axes (layout / zone_geometries / zone_sections) onto the post-load
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
// drops anything the backend fallback could not round-trip through
// a CLI arg — `zone_geometries` in particular has no slidePlan
// representation, so without this merge the user would see their
// resized zones revert on every Generate.
const base = applyPersistedNonFrameOverrides(
createInitialUserSelection(slidePlan),
persistedOverridesRef.current,
);
return {
...p,
normalizedContent,
slidePlan,
userSelection: {
...base,
overrides: {
...base.overrides,
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
},
},
isLoading: false,
};
});
setRunMeta(runMeta);
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
if (aiReviewMsg) toast.error(aiReviewMsg);
} 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);
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
// section_ids), so we forward the full mutated value; the u4 PUT path
// replaces this axis atomically while preserving the foreign axes.
// p.uploadedFile gate skips persistence before any MDX is loaded —
// the demo-mode initial render path would otherwise PUT to the empty
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
zone_sections: finalSelection.overrides.zone_sections,
});
}
return { ...p, userSelection: finalSelection };
});
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) => {
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
// IMP-52 u7 — persist the selected layout preset id. The on-disk
// `layout` axis is a single string; `applyLayout` validates the
// preset id before mutating the selection, so the value here is
// already the LayoutPresetId we want to round-trip.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { layout: layoutId });
}
return { ...p, userSelection: newSelection };
});
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<string, { x: number; y: number; w: number; h: number }>) => {
setState((p) => {
const mergedGeometries = {
...p.userSelection.overrides.zone_geometries,
...geometries,
};
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
// gestures fire repeatedly during a drag; the 300ms u5 debounce
// collapses them into a single PUT at gesture-end, so we don't
// need to gate on resize-finished here.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
}
return {
...p,
userSelection: {
...p.userSelection,
overrides: {
...p.userSelection.overrides,
zone_geometries: mergedGeometries,
},
},
};
});
setHasPendingChanges(true);
}, []);
// IMP-51 (#79) u10 — wire SlideCanvas's user-content image drag/resize
// emit into the 5th persisted axis. Mirrors handleZoneResize exactly:
// • merge the single (imageId → {x,y,w,h}) tick onto the prior
// in-memory `image_overrides` map via the u11 `saveImageOverride`
// helper so the immutable update path is shared with the test suite,
// • forward the full merged snapshot through `saveUserOverrides`
// (the u3 typed client) under the `image_overrides` key — the 300ms
// debounce defined alongside `zone_geometries` collapses the
// per-mousemove emits into one PUT at gesture-end,
// • flip `hasPendingChanges` so the "선택대로 재생성하기" CTA appears.
// Coordinates are slide-absolute percent (0100) from u8/u9 — passed
// through unchanged so the on-disk schema matches the SlideCanvas
// overlay, the stamper selector (u4), and the render-time CSS
// injector (u7) without any per-zone transform.
const handleImageResize = useCallback(
(imageId: string, geometry: { x: number; y: number; w: number; h: number }) => {
setState((p) => {
const nextSelection = saveImageOverride(p.userSelection, imageId, geometry);
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
image_overrides: nextSelection.overrides.image_overrides,
});
}
return { ...p, userSelection: nextSelection };
});
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<string | undefined>(() => {
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) => {
const newSelection = applyFrame(p.userSelection, region.id, frameId);
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
// uses `unit_id = zone.section_ids.join("+")` (the same convention
// handleGenerate uses when forwarding `overrides.frames` to the
// backend CLI). `zone_frames` is keyed by region.id, so we walk
// the effectiveSlidePlan zones to translate. Only true user
// overrides are persisted — `createInitialUserSelection` pre-fills
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
// (backend default) for every region, so we mirror handleGenerate's
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
// into user_overrides.json. Zones with no sections are skipped.
if (p.uploadedFile && effectiveSlidePlan) {
const framesByUnitId: Record<string, string> = {};
for (const z of effectiveSlidePlan.zones) {
const r = z.internal_regions[0];
if (!r) continue;
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
const unitId = z.section_ids.join("+");
const overrideId = newSelection.overrides.zone_frames?.[r.id];
const defaultFrameId = r.frame_match_strategy.frame_id;
if (
typeof overrideId === "string" &&
overrideId.length > 0 &&
overrideId !== defaultFrameId
) {
framesByUnitId[unitId] = overrideId;
}
}
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { frames: framesByUnitId });
}
return { ...p, userSelection: newSelection };
});
setHasPendingChanges(true);
}, [effectiveSlidePlan, state.userSelection]);
const selectedZone = getSelectedZone(effectiveSlidePlan, state.userSelection);
const selectedRegion = getSelectedRegion(selectedZone, state.userSelection);
return (
<div className="h-screen flex flex-col bg-white overflow-hidden font-sans">
{/* Header */}
<header className="flex-shrink-0 h-12 flex items-center px-4 border-b border-slate-200 bg-white shadow-sm z-30">
<div className="flex items-center gap-2 w-56 flex-shrink-0">
<div className="w-8 h-8 bg-slate-900 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-blue-400 fill-blue-400" />
</div>
<span className="text-sm font-black text-slate-800 tracking-tighter uppercase">Design Agent</span>
</div>
<div className="flex-1 flex items-center justify-center gap-2">
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">Active Slide /</span>
<span className="text-sm font-bold text-slate-700">{state.slidePlan?.title ?? "새 슬라이드"}</span>
{runMeta && (
<>
<span className="text-slate-300">·</span>
<span className="text-[10px] font-mono text-slate-500 px-1.5 py-0.5 bg-slate-100 rounded">
run: {runMeta.run_id}
</span>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wider ${
runMeta.status === "PASS"
? "bg-emerald-100 text-emerald-700"
: runMeta.status === "RENDERED_WITH_VISUAL_REGRESSION"
? "bg-amber-100 text-amber-700"
: runMeta.status === "PARTIAL_COVERAGE"
? "bg-orange-100 text-orange-700"
: "bg-red-100 text-red-700"
}`}
title={`visual_check_passed: ${runMeta.visual_check_passed} / full_mdx_coverage: ${runMeta.full_mdx_coverage}`}
>
{runMeta.status}
</span>
{runMeta.filtered_section_ids.length > 0 && (
<details className="relative">
<summary className="text-[10px] font-bold px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded uppercase tracking-wider cursor-pointer list-none">
Filtered: {runMeta.filtered_section_ids.length}
</summary>
<div className="absolute top-full mt-1 left-0 z-50 bg-white border border-slate-200 rounded shadow-lg p-3 w-96 max-h-96 overflow-y-auto">
{runMeta.filtered_section_reasons.map((r, i) => (
<div key={i} className="mb-2 pb-2 border-b border-slate-100 last:border-0 last:mb-0 last:pb-0 text-[11px]">
<div className="font-mono text-slate-700">{r.section_ids.join(", ")}</div>
<div className="text-slate-500">selection_state: <span className="font-mono">{r.selection_state}</span></div>
{r.merge_type && <div className="text-slate-500">merge_type: <span className="font-mono">{r.merge_type}</span></div>}
{r.template_id && <div className="text-slate-500">template_id: <span className="font-mono">{r.template_id}</span></div>}
{r.v4_label && <div className="text-slate-500">v4_label: <span className="font-mono">{r.v4_label}</span></div>}
{r.phase_z_status && <div className="text-slate-500">phase_z_status: <span className="font-mono">{r.phase_z_status}</span></div>}
{r.score !== null && <div className="text-slate-500">score: <span className="font-mono">{r.score}</span></div>}
{r.source && <div className="text-slate-500">source: <span className="font-mono">{r.source}</span></div>}
{r.position && <div className="text-slate-500">position: <span className="font-mono">{r.position}</span></div>}
<ul className="mt-1 list-disc list-inside text-slate-600">
{r.filter_reasons.map((reason, j) => <li key={j} className="font-mono">{reason}</li>)}
</ul>
</div>
))}
</div>
</details>
)}
</>
)}
</div>
<div className="flex items-center gap-3 w-56 flex-shrink-0 justify-end">
{state.isLoading ? (
<div className="flex items-center gap-1.5 text-blue-500">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span className="text-[11px] font-bold uppercase">Processing...</span>
</div>
) : state.slidePlan ? (
<div className="flex items-center gap-1 text-slate-400">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-[11px] font-bold uppercase tracking-tighter">Synced</span>
</div>
) : null}
<div className="h-4 w-px bg-slate-200" />
<button className="text-slate-300 hover:text-slate-500 transition-colors"><HelpCircle className="w-4 h-4" /></button>
</div>
</header>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Left Panel: MDX Explorer (Drag Source) */}
<aside className="w-64 flex-shrink-0 border-r border-slate-200 flex flex-col overflow-hidden shadow-sm z-20">
<LeftMdxPanel
normalizedContent={state.normalizedContent}
uploadedFile={state.uploadedFile}
isLoading={state.isLoading}
selectedSectionId={state.userSelection.selectedSectionId}
hasSlidePlan={!!state.slidePlan}
hasPendingChanges={hasPendingChanges}
onFileUpload={handleFileUpload}
onGenerate={handleGenerate}
onSectionClick={(sectionId) => {
if (!state.slidePlan) return;
const zone = state.slidePlan.zones.find((z) => z.section_ids.includes(sectionId));
if (zone) handleZoneClick(zone.id);
}}
onSelectSample={handleSelectSample}
selectedSample={selectedSample}
/>
</aside>
{/* Center: Slide Canvas (Drop Target & Resizable) */}
<main className="flex-1 flex flex-col overflow-hidden bg-slate-100">
<SlideCanvas
slidePlan={effectiveSlidePlan}
normalizedContent={state.normalizedContent}
userSelection={state.userSelection}
finalHtmlUrl={runMeta?.final_html_url}
slideOverrideCss={slideOverrideCss}
isPipelineRunning={state.isLoading}
isPendingLayout={!!pendingLayout}
pendingLayoutId={pendingLayout}
onCancelPendingLayout={handleCancelPendingLayout}
onContentEdit={handleContentEdit}
onZoneClick={(zoneId) => {
handleZoneClick(zoneId);
setRightTab("frame");
}}
onSlideClick={() => setRightTab("layout")}
onRegionClick={handleRegionClick}
onFrameSelect={handleFrameSelect}
onSectionDrop={handleSectionDrop}
onLayoutResize={handleLayoutResize}
onZoneResize={handleZoneResize}
imageOverrides={state.userSelection.overrides.image_overrides}
onImageResize={handleImageResize}
/>
</main>
{/* Right Panel: Frame / Layout Inspector */}
<aside className="w-72 flex-shrink-0 border-l border-slate-200 flex flex-col overflow-hidden bg-white shadow-sm z-20">
<div className="flex-shrink-0 flex p-1 bg-slate-50 border-b border-slate-200 m-2 rounded-lg">
<button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "frame" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("frame")}>Frame</button>
<button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "layout" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("layout")}>Layout</button>
</div>
<div className="flex-1 overflow-hidden">
{rightTab === "frame" ? (
<FramePanel
slidePlan={effectiveSlidePlan}
selectedZone={selectedZone}
selectedRegion={selectedRegion}
userSelection={state.userSelection}
onFrameSelect={handleFrameSelect}
onNoDesignToggle={() => {}}
/>
) : (
<LayoutPanel
selectedZone={selectedZone}
userSelection={state.userSelection}
onLayoutSelect={handleLayoutSelect}
onApplyLayout={handleApplyPendingLayout}
availableLayoutIds={runMeta?.layout_candidates}
pipelineSelectedLayoutId={state.slidePlan?.layout_preset}
pendingLayoutId={pendingLayout}
/>
)}
</div>
</aside>
</div>
{/* Footer Actions */}
<footer className="flex-shrink-0 flex items-center justify-between px-6 py-3 bg-white border-t border-slate-200 z-30">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={() => toast.info("연동하기 기능은 준비 중입니다.")} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Link2 className="w-3.5 h-3.5" />Connect</Button>
<Button variant="outline" onClick={() => toast.info("다운로드 기능은 준비 중입니다.")} disabled={!state.slidePlan} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Download className="w-3.5 h-3.5" />Download</Button>
<Button onClick={() => toast.success("슬라이드 설정이 확정되었습니다.")} disabled={!state.slidePlan || state.isLoading} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"><Sparkles className="w-3.5 h-3.5" />Finalize Slide</Button>
</div>
</footer>
</div>
);
}