833 lines
38 KiB
TypeScript
833 lines
38 KiB
TypeScript
/**
|
||
* 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 (0–100) 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>
|
||
);
|
||
}
|