/** * 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 } 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; runMeta: RunMeta | null; uploadedFile: File | null; isLoading: boolean; onGenerate: () => void; } export default function BottomActions({ slidePlan, runMeta, uploadedFile, isLoading, onGenerate, }: BottomActionsProps) { const [isConnecting, setIsConnecting] = useState(false); const [isExporting, setIsExporting] = useState(false); const runReady = !!runMeta && !!slidePlan; const handleExport = async () => { if (!runMeta) { toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요."); return; } 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 = 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 (
); }