feat(#90): IMP-56 u20 BottomActions wiring to /api/connect + /api/export (replace placeholder toasts + standalone HTML download + cel mirror connect; pure builders exported for vitest)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s

Stage 2 final unit for Step 22 (user edit + export). u20 wires the previously
placeholder bottom-action footer to the u18 /api/connect and u19 /api/export
middlewares living in Front/vite.config.ts:

- BottomActions.tsx
  • drops the dead `serializeSlidePlan` import (TS2305 blocker since u14;
    project-wide `tsc --noEmit` now exits 0)
  • exports three pure builders for vitest (no jsdom / RTL devDep needed):
      buildConnectRequest(run_id, slug) -> POST /api/connect {run_id, slug}
      buildExportRequest(run_id)        -> POST /api/export  {run_id}
      buildDownloadFilename(run_id)     -> "<run_id>.html"
  • handleExport: POST -> blob -> a[download] click chain; toast on
    success / failure / network error.
  • handleConnect: derives slug via deriveUserOverridesKey(uploadedFile.name)
    and PUTs to u18 cel mirror; reports assets_copied count.
  • both buttons disable when runMeta is null so the UI cannot fire
    requests with an undefined run_id.

- Home.tsx
  • mounts <BottomActions/> in the footer with
    {slidePlan, runMeta, uploadedFile, isLoading, onGenerate}.
  • removes 2 of 3 placeholder `toast.info('… 준비 중입니다.')` buttons
    (LeftMdxPanel MDX-edit placeholder remains — out of u20 scope).
  • adds handleTextEdit (u15 wire to text_overrides axis) and
    handleStructureEdit (u15 wire to structure_overrides axis) to satisfy
    the SlideCanvas props introduced earlier in the u-series.

- imp90_bottom_actions.test.ts (new)
  • 11 vitest specs locking the builder URL + JSON shape against u18/u19
    middleware contracts. Verified 11/11 pass.

Stage 4 verification (all PASS):
  • u20 vitest: 11/11
  • u18/u19 endpoint vitest: 31/31
  • npx tsc --noEmit: exit 0 (carry-forward TS2305 resolved)
  • backend pytest (u1~u9 + u17 print mode, 9 files): 185/185

Out of scope:
  • LeftMdxPanel.tsx:333 MDX-edit placeholder toast (separate unit)
  • #1 / #72 / #74 / #79 / #80 / #81 / #93 closed dependencies (no re-impl)
  • AI-generated HTML structure (Phase Z regression guard)
  • frame swap via structure_overrides (locked to slot_order + hidden_slots)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 02:31:38 +09:00
parent ec7471ed59
commit 943957562f
3 changed files with 292 additions and 48 deletions

View File

@@ -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/<run_id>/phase_z2/final.html + assets/ into the cel mirror
* (`<CEL_PROJECT_ROOT>/public/slides/<slug>.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 `<run_id>.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 (
<div className="flex items-center justify-center gap-3 px-6 py-3 bg-white border-t border-slate-200">
<div className="flex items-center gap-1.5 mr-2">
<span className="w-5 h-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center font-bold">4</span>
<span className="text-xs text-slate-500 font-medium"></span>
</div>
{/* 생성하기 */}
<div className="flex items-center gap-3">
<Button
onClick={onGenerate}
disabled={isLoading}
className="gap-2 min-w-[120px]"
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"
size="default"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : (
<>
<Sparkles className="w-4 h-4" />
<Sparkles className="w-3.5 h-3.5" />
</>
)}
</Button>
{/* 다운로드 */}
<Button
variant="outline"
onClick={handleDownload}
disabled={!slidePlan || isLoading}
className="gap-2 min-w-[120px]"
onClick={handleExport}
disabled={!runReady || isExporting || isLoading}
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
size="default"
>
<Download className="w-4 h-4" />
{isExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
</Button>
{/* 연동하기 */}
<Button
variant="outline"
onClick={handleConnect}
className="gap-2 min-w-[120px] text-slate-500"
disabled={!runReady || isConnecting || isLoading}
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
size="default"
>
<Link2 className="w-4 h-4" />
{isConnecting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Link2 className="w-3.5 h-3.5" />
)}
</Button>
</div>