u1: SlideCanvas iframe sandbox += allow-scripts (allow-same-origin preserved)
→ embedded-mode script in slide_base.html now applies html.embedded
→ standalone CSS reset deactivates inside iframe; no clipping
u2: designAgentApi.loadRun merges candidate_evidence + v4_all_judgments
+ v4_candidates via Map<template_id|id|frame_id> dedup,
LABEL_PRIORITY (use_as_is<light_edit<restructure<reject) then
confidence desc, capped TOP_N_FRAMES=6
u3: Home.handleGenerate useCallback deps = [uploadedFile, slidePlan,
userSelection, pendingZones, pendingLayout] (5-tuple, stale-closure fix)
u4: tests/manual/imp47a_e2e.md — mdx03 manual e2e spec (5 axes)
Frontend-only. Backend src/ untouched. No template/catalog edits.
Determinism preserved (no LLM in frontend merge logic).
Baseline: pytest -q tests → 623 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
673 lines
30 KiB
TypeScript
673 lines
30 KiB
TypeScript
/**
|
|
* 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<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);
|
|
|
|
// 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);
|
|
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<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;
|
|
}
|
|
|
|
// 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<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 {
|
|
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<string, { x: number; y: number; w: number; h: number }>) => {
|
|
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<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) => ({
|
|
...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 (
|
|
<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}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|