feat(frontend): add Front/ — Vite/React frontend with backend pipeline integration

Mirror of design_agent_front/design-agent/ for shipping alongside backend.

Vite plugin (vitePluginPhaseZApi) endpoints :
  - POST /api/run   — spawn `python -m src.phase_z2_pipeline` with overrides
  - GET  /api/sample-mdx?mdx=03/04/05 — fixed sample MDX
  - GET  /frame-preview/{n} — figma preview thumbnails
  - GET  /data/runs/{run_id}/{path} — pipeline artifacts (final.html, step*.json, ...)

Env toggle forward (보고용) :
  PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT / PHASE_Z_MAX_RANK=32

Components :
  - LeftMdxPanel (03/04/05 fix list + section tree)
  - SlideCanvas (iframe + slideOverrideCss prop for inline CSS inject)
  - FramePanel (label priority + confidence sort)
  - LayoutPanel

README with mermaid diagrams covering the 5-step demo flow.
node_modules / dist / .manus-logs / .env excluded via .gitignore.
This commit is contained in:
2026-05-14 14:45:30 +09:00
parent 52ccb7fc8b
commit 0f0d3fa91f
99 changed files with 20280 additions and 0 deletions

View File

@@ -0,0 +1,616 @@
/**
* 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;
}
}
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}`,
]
.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]);
// ── 섹션 드래그 앤 드롭 (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>
</>
)}
</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>
);
}

View File

@@ -0,0 +1,49 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { AlertCircle, Home } from "lucide-react";
import { useLocation } from "wouter";
export default function NotFound() {
const [, setLocation] = useLocation();
const handleGoHome = () => {
setLocation("/");
};
return (
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
<Card className="w-full max-w-lg mx-4 shadow-lg border-0 bg-white/80 backdrop-blur-sm">
<CardContent className="pt-8 pb-8 text-center">
<div className="flex justify-center mb-6">
<div className="relative">
<div className="absolute inset-0 bg-red-100 rounded-full animate-pulse" />
<AlertCircle className="relative h-16 w-16 text-red-500" />
</div>
</div>
<h1 className="text-4xl font-bold text-slate-900 mb-2">404</h1>
<h2 className="text-xl font-semibold text-slate-700 mb-4">
Page Not Found
</h2>
<p className="text-slate-600 mb-8 leading-relaxed">
Sorry, the page you are looking for doesn't exist.
<br />
It may have been moved or deleted.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
onClick={handleGoHome}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
>
<Home className="w-4 h-4 mr-2" />
Go Home
</Button>
</div>
</CardContent>
</Card>
</div>
);
}