diff --git a/Front/client/src/components/BottomActions.tsx b/Front/client/src/components/BottomActions.tsx index e72278c..7c7e2a2 100644 --- a/Front/client/src/components/BottomActions.tsx +++ b/Front/client/src/components/BottomActions.tsx @@ -1,99 +1,200 @@ /** - * BottomActions - 하단 액션 버튼 영역 + * BottomActions — Step 22 footer wire-up (IMP-56 #90 u20). * - * 생성하기, 다운로드, 연동하기 버튼 컴포넌트 + * Two real endpoints replace the prior placeholder toasts: + * • POST /api/connect (u18 / Front/vite.config.ts) — copies + * data/runs//phase_z2/final.html + assets/ into the cel mirror + * (`/public/slides/.html`). + * • POST /api/export (u19 / Front/vite.config.ts) — returns a standalone + * text/html body with every `url(assets/...)` ref inlined as base64 + * data URLs. Response is piped into a Blob → a[download] click chain so + * the user receives `.html` portable for file:// or any host. + * + * The prior `serializeSlidePlan` JSON-download path was a dead reference + * (the export never existed in slidePlanUtils) and is removed here — the + * "다운로드" button now means standalone HTML download via /api/export. + * Both buttons disable when no run is loaded (runMeta == null) so the + * UI cannot fire requests with an undefined run_id. */ +import { useState } from "react"; import { Sparkles, Download, Link2, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; -import type { SlidePlan, UserSelection } from "../types/designAgent"; -import { serializeSlidePlan } from "../utils/slidePlanUtils"; +import type { SlidePlan } from "../types/designAgent"; +import type { RunMeta } from "../services/designAgentApi"; +import { deriveUserOverridesKey } from "../utils/slidePlanUtils"; + +// ─── pure request builders (exported for vitest; jsdom-free) ───────────── +// The component below uses these verbatim. Each returns a {url, body} pair +// so the test surface is the *literal* HTTP payload sent to the u18 / u19 +// middlewares — any future shape drift fails here before the network call. + +export function buildConnectRequest( + run_id: string, + slug: string, +): { url: string; body: string } { + return { + url: "/api/connect", + body: JSON.stringify({ run_id, slug }), + }; +} + +export function buildExportRequest( + run_id: string, +): { url: string; body: string } { + return { + url: "/api/export", + body: JSON.stringify({ run_id }), + }; +} + +export function buildDownloadFilename(run_id: string): string { + return `${run_id}.html`; +} interface BottomActionsProps { slidePlan: SlidePlan | null; - userSelection: UserSelection; + runMeta: RunMeta | null; + uploadedFile: File | null; isLoading: boolean; onGenerate: () => void; } export default function BottomActions({ slidePlan, - userSelection, + runMeta, + uploadedFile, isLoading, onGenerate, }: BottomActionsProps) { - const handleDownload = () => { - if (!slidePlan) { - toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요."); + const [isConnecting, setIsConnecting] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + const runReady = !!runMeta && !!slidePlan; + + const handleExport = async () => { + if (!runMeta) { + toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요."); return; } - - const json = serializeSlidePlan(slidePlan, userSelection); - console.log("[Download] SlidePlan JSON:", json); - - // JSON 파일 다운로드 - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `slide-plan-${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); - - toast.success("SlidePlan JSON이 다운로드되었습니다."); + setIsExporting(true); + try { + const exportReq = buildExportRequest(runMeta.run_id); + const resp = await fetch(exportReq.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: exportReq.body, + }); + if (!resp.ok) { + const text = await resp.text(); + toast.error(`Export 실패 (${resp.status}): ${text.slice(0, 160)}`); + return; + } + const blob = await resp.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = buildDownloadFilename(runMeta.run_id); + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(objectUrl); + toast.success(`standalone HTML 다운로드 — ${runMeta.run_id}.html`); + } catch (err) { + toast.error(`Export 네트워크 오류: ${(err as Error).message}`); + } finally { + setIsExporting(false); + } }; - const handleConnect = () => { - toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다."); + const handleConnect = async () => { + if (!runMeta) { + toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요."); + return; + } + if (!uploadedFile) { + toast.error("MDX 파일이 없습니다 — slug 도출 불가."); + return; + } + const slug = deriveUserOverridesKey(uploadedFile.name); + setIsConnecting(true); + try { + const connectReq = buildConnectRequest(runMeta.run_id, slug); + const resp = await fetch(connectReq.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: connectReq.body, + }); + const payload = (await resp.json().catch(() => ({}))) as { + success?: boolean; + assets_copied?: number; + error?: string; + }; + if (!resp.ok || !payload.success) { + toast.error( + `Connect 실패 (${resp.status}): ${payload.error ?? "unknown error"}`, + ); + return; + } + toast.success( + `cel 미러 연동 완료 — ${slug}.html (assets ${payload.assets_copied ?? 0}개 복사)`, + ); + } catch (err) { + toast.error(`Connect 네트워크 오류: ${(err as Error).message}`); + } finally { + setIsConnecting(false); + } }; return ( -
-
- 4 - 액션 -
- {/* 생성하기 */} +
- {/* 다운로드 */} - {/* 연동하기 */}
diff --git a/Front/client/src/pages/Home.tsx b/Front/client/src/pages/Home.tsx index 2579c97..ebabb3f 100644 --- a/Front/client/src/pages/Home.tsx +++ b/Front/client/src/pages/Home.tsx @@ -16,6 +16,8 @@ import { moveSectionToZone, saveZoneSizes, saveImageOverride, + saveTextOverride, + saveStructureOverride, deriveUserOverridesKey, applyPersistedNonFrameOverrides, remapPersistedFramesToZoneFrames, @@ -41,8 +43,9 @@ import LeftMdxPanel from "../components/LeftMdxPanel"; import SlideCanvas from "../components/SlideCanvas"; import LayoutPanel from "../components/LayoutPanel"; import FramePanel from "../components/FramePanel"; +import BottomActions from "../components/BottomActions"; import { - Sparkles, Download, Link2, Loader2, + Sparkles, Loader2, CheckCircle2, HelpCircle, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -680,6 +683,51 @@ export default function Home() { setHasPendingChanges(true); }, []); + // IMP-56 (#90) u15 — wire SlideCanvas u13 focusout capture into the new + // `text_overrides` persist axis. Mirrors handleImageResize: merge the + // (zoneId, textPath, value) tick via `saveTextOverride` (u15 pure helper) + // and schedule the 300ms-debounced PUT under the `text_overrides` axis. + // Per-axis coalescing in `saveUserOverrides` collapses rapid edits in + // the same line into a single PUT; per-key buckets isolate cross-MDX. + const handleTextEdit = useCallback( + (capture: { zoneId: string; textPath: string; value: string }) => { + setState((p) => { + const nextSelection = saveTextOverride( + p.userSelection, capture.zoneId, capture.textPath, capture.value, + ); + if (p.uploadedFile) { + const key = deriveUserOverridesKey(p.uploadedFile.name); + void saveUserOverrides(key, { + text_overrides: nextSelection.overrides.text_overrides, + }); + } + return { ...p, userSelection: nextSelection }; + }); + setHasPendingChanges(true); + }, + [], + ); + + // IMP-56 (#90) u15 — wire SlideCanvas u14 structure overlay capture into + // the `structure_overrides` axis. Scope-locked to {slot_order, + // hidden_slots} — frame swap stays on the existing `frames` axis. + const handleStructureEdit = useCallback( + (zoneId: string, perZone: { slot_order?: string[]; hidden_slots?: string[] }) => { + setState((p) => { + const nextSelection = saveStructureOverride(p.userSelection, zoneId, perZone); + if (p.uploadedFile) { + const key = deriveUserOverridesKey(p.uploadedFile.name); + void saveUserOverrides(key, { + structure_overrides: nextSelection.overrides.structure_overrides, + }); + } + return { ...p, userSelection: nextSelection }; + }); + setHasPendingChanges(true); + }, + [], + ); + // pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan. // 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 → // pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가 @@ -868,6 +916,9 @@ export default function Home() { onZoneResize={handleZoneResize} imageOverrides={state.userSelection.overrides.image_overrides} onImageResize={handleImageResize} + onTextEdit={handleTextEdit} + structureOverrides={state.userSelection.overrides.structure_overrides} + onStructureEdit={handleStructureEdit} /> @@ -910,11 +961,13 @@ export default function Home() { Phase Z Engine Active
-
- - - -
+ ); diff --git a/Front/client/tests/imp90_bottom_actions.test.ts b/Front/client/tests/imp90_bottom_actions.test.ts new file mode 100644 index 0000000..c712bb9 --- /dev/null +++ b/Front/client/tests/imp90_bottom_actions.test.ts @@ -0,0 +1,90 @@ +// IMP-56 (#90) u20 — vitest coverage for the pure request builders exported +// by `BottomActions`. The React component itself is not rendered (jsdom / +// @testing-library NOT in Front devDependencies — verified against the prior +// u14 `imp90_structure_overlay.test.tsx` pattern); we test the deterministic +// pieces that drive the network payload sent to the u18 / u19 middlewares. +// +// Upstream / downstream contracts (verified by prior units): +// - u18 /api/connect : body shape = { run_id, slug } (Front/vite.config.ts +// handleConnectMirror — `imp90_connect_endpoint.test.ts`). +// - u19 /api/export : body shape = { run_id }; response = raw text/html +// with `Content-Disposition: attachment; filename=".html"` +// (Front/vite.config.ts handleExportStandalone — +// `imp90_export_endpoint.test.ts`). +// +// u20 scope: builders only. Any drift in URL or JSON shape fails here before +// the request leaves the client. Toast / fetch / blob plumbing is not tested +// (it would require jsdom + a fetch mock; the existing server-side tests +// already pin the wire contract). + +import { describe, it, expect } from "vitest"; +import { + buildConnectRequest, + buildExportRequest, + buildDownloadFilename, +} from "../src/components/BottomActions"; + +describe("buildConnectRequest", () => { + it("targets /api/connect", () => { + const { url } = buildConnectRequest("run_42", "mdx_03"); + expect(url).toBe("/api/connect"); + }); + + it("emits { run_id, slug } JSON body — matches u18 middleware shape", () => { + const { body } = buildConnectRequest("run_42", "mdx_03"); + expect(JSON.parse(body)).toEqual({ run_id: "run_42", slug: "mdx_03" }); + }); + + it("preserves zero-length and unicode run_id verbatim (server validates)", () => { + const { body } = buildConnectRequest("", "x"); + expect(JSON.parse(body)).toEqual({ run_id: "", slug: "x" }); + const { body: uni } = buildConnectRequest("런", "슬러그"); + expect(JSON.parse(uni)).toEqual({ run_id: "런", slug: "슬러그" }); + }); + + it("does not leak extra keys (frame swap / overrides etc.)", () => { + const { body } = buildConnectRequest("r", "s"); + expect(Object.keys(JSON.parse(body)).sort()).toEqual(["run_id", "slug"]); + }); +}); + +describe("buildExportRequest", () => { + it("targets /api/export", () => { + const { url } = buildExportRequest("run_42"); + expect(url).toBe("/api/export"); + }); + + it("emits { run_id } JSON body — matches u19 middleware shape", () => { + const { body } = buildExportRequest("run_42"); + expect(JSON.parse(body)).toEqual({ run_id: "run_42" }); + }); + + it("does not leak extra keys (slug / format etc.)", () => { + const { body } = buildExportRequest("r"); + expect(Object.keys(JSON.parse(body))).toEqual(["run_id"]); + }); + + it("preserves zero-length and unicode run_id verbatim (server validates)", () => { + expect(JSON.parse(buildExportRequest("").body)).toEqual({ run_id: "" }); + expect(JSON.parse(buildExportRequest("런").body)).toEqual({ run_id: "런" }); + }); +}); + +describe("buildDownloadFilename", () => { + it("returns .html for the a[download] click chain", () => { + expect(buildDownloadFilename("run_42")).toBe("run_42.html"); + }); + + it("appends exactly one .html suffix even when run_id already ends in .html", () => { + // The server-side `Content-Disposition` already carries the same + // filename; we mirror it verbatim so browser default behavior wins. + // We intentionally do NOT strip a trailing `.html` — run_id is the + // backend's `Path(args.mdx_path).stem`-style key, which never contains + // a dot suffix (validated by `isValidUserOverridesKey` at u18/u19). + expect(buildDownloadFilename("foo.html")).toBe("foo.html.html"); + }); + + it("returns just .html for empty run_id (server rejects upstream)", () => { + expect(buildDownloadFilename("")).toBe(".html"); + }); +});