Files
vexplor/frontend/components/pop/management/PopScreenPreview.tsx
SeongHyun Kim 368d641ae8 feat(pop-designer): POP 디자이너 v2.0 - 4가지 디바이스 모드 및 캔버스 UX 개선
- v2 레이아웃 데이터 구조 도입 (4모드별 별도 레이아웃 + 공유 컴포넌트 정의)
  - tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait
  - sections/components를 Record<string, Definition> 객체로 관리
  - v1 → v2 자동 마이그레이션 지원

- 캔버스 UX 개선
  - 줌 기능 (30%~150%, 마우스 휠 + 버튼)
  - 패닝 기능 (중앙 마우스, Space+드래그, 배경 드래그)
  - 2개 캔버스 동시 표시 (가로/세로 모드)

- Delete 키로 섹션/컴포넌트 삭제 기능 추가
  - layout.sections 순회하여 componentIds에서 부모 섹션 찾는 방식

- 미리보기 v2 레이아웃 호환성 수정
  - Object.keys(layout.sections).length 체크로 변경

수정 파일: PopDesigner.tsx, PopCanvas.tsx, SectionGridV2.tsx(신규),
          types/pop-layout.ts, PopPanel.tsx, PopScreenPreview.tsx,
          PopCategoryTree.tsx, screenManagementService.ts
2026-02-03 11:25:00 +09:00

196 lines
6.9 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Smartphone, Tablet, Loader2, ExternalLink, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
// ============================================================
// 타입 정의
// ============================================================
type DeviceType = "mobile" | "tablet";
interface PopScreenPreviewProps {
screen: ScreenDefinition | null;
className?: string;
}
// 디바이스 프레임 크기
// 모바일: 세로(portrait), 태블릿: 가로(landscape) 디폴트
const DEVICE_SIZES = {
mobile: { width: 375, height: 667 }, // iPhone SE 기준 (세로)
tablet: { width: 1024, height: 768 }, // iPad 기준 (가로)
};
// ============================================================
// 메인 컴포넌트
// ============================================================
export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {
const [deviceType, setDeviceType] = useState<DeviceType>("tablet");
const [loading, setLoading] = useState(false);
const [hasLayout, setHasLayout] = useState(false);
const [key, setKey] = useState(0); // iframe 새로고침용
// 레이아웃 존재 여부 확인
useEffect(() => {
if (!screen) {
setHasLayout(false);
return;
}
const checkLayout = async () => {
try {
setLoading(true);
const layout = await screenApi.getLayoutPop(screen.screenId);
// v2 레이아웃: sections는 객체 (Record<string, PopSectionDefinition>)
// v1 레이아웃: sections는 배열
if (layout) {
const isV2 = layout.version === "pop-2.0";
const hasSections = isV2
? layout.sections && Object.keys(layout.sections).length > 0
: layout.sections && Array.isArray(layout.sections) && layout.sections.length > 0;
setHasLayout(hasSections);
} else {
setHasLayout(false);
}
} catch {
setHasLayout(false);
} finally {
setLoading(false);
}
};
checkLayout();
}, [screen]);
// 미리보기 URL
const previewUrl = screen ? `/pop/screens/${screen.screenId}?preview=true&device=${deviceType}` : null;
// 새 탭에서 열기
const openInNewTab = () => {
if (previewUrl) {
const size = DEVICE_SIZES[deviceType];
window.open(previewUrl, "_blank", `width=${size.width + 40},height=${size.height + 80}`);
}
};
// iframe 새로고침
const refreshPreview = () => {
setKey((prev) => prev + 1);
};
const deviceSize = DEVICE_SIZES[deviceType];
// 미리보기 컨테이너에 맞게 스케일 조정
const scale = deviceType === "tablet" ? 0.5 : 0.6;
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
{/* 헤더 */}
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium"></h3>
{screen && (
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{screen.screenName}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* 디바이스 선택 */}
<Tabs value={deviceType} onValueChange={(v) => setDeviceType(v as DeviceType)}>
<TabsList className="h-8">
<TabsTrigger value="mobile" className="h-7 px-3 gap-1.5" title="모바일 (375x667)">
<Smartphone className="h-3.5 w-3.5" />
<span className="text-xs"></span>
</TabsTrigger>
<TabsTrigger value="tablet" className="h-7 px-3 gap-1.5" title="태블릿 (1024x768 가로)">
<Tablet className="h-3.5 w-3.5" />
<span className="text-xs">릿</span>
</TabsTrigger>
</TabsList>
</Tabs>
{screen && hasLayout && (
<>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshPreview}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={openInNewTab}>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
{/* 미리보기 영역 */}
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
{!screen ? (
// 화면 미선택
<div className="text-center text-muted-foreground">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
{deviceType === "mobile" ? (
<Smartphone className="h-8 w-8" />
) : (
<Tablet className="h-8 w-8" />
)}
</div>
<p className="text-sm"> .</p>
</div>
) : loading ? (
// 로딩 중
<div className="text-center text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-3" />
<p className="text-sm"> ...</p>
</div>
) : !hasLayout ? (
// 레이아웃 없음
<div className="text-center text-muted-foreground">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
{deviceType === "mobile" ? (
<Smartphone className="h-8 w-8" />
) : (
<Tablet className="h-8 w-8" />
)}
</div>
<p className="text-sm mb-2">POP .</p>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
) : (
// 디바이스 프레임 + iframe (심플한 테두리)
<div
className="relative border-2 border-gray-300 rounded-lg shadow-lg overflow-hidden"
style={{
width: deviceSize.width * scale,
height: deviceSize.height * scale,
}}
>
<iframe
key={key}
src={previewUrl || ""}
className="w-full h-full border-0"
style={{
width: deviceSize.width,
height: deviceSize.height,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
title="POP Screen Preview"
/>
</div>
)}
</div>
</div>
);
}