feat: 화면관리 시스템 구현

- 컴포넌트 드래그앤드롭 시스템 완성
- 속성 편집 및 실시간 미리보기 기능
- 버튼 시스템 통합 (유니버설 버튼)
- 격자 시스템 및 해상도 설정 패널
- 상세설정 패널 구현
- 스타일 편집기 최적화
- 라벨 처리 시스템 개선
This commit is contained in:
kjs 2025-09-04 15:20:26 +09:00
parent 78d4d7de23
commit 9bf879e29d
10 changed files with 974 additions and 359 deletions

View File

@ -30,10 +30,37 @@ app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// CORS 설정
app.use(
cors({
origin: config.cors.origin,
origin: function (origin, callback) {
const allowedOrigins = config.cors.origin
.split(",")
.map((url) => url.trim());
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) !== -1) {
return callback(null, true);
} else {
console.log(`CORS rejected origin: ${origin}`);
return callback(
new Error(
"CORS policy does not allow access from the specified Origin."
),
false
);
}
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
allowedHeaders: [
"Content-Type",
"Authorization",
"X-Requested-With",
"Accept",
"Origin",
"Access-Control-Request-Method",
"Access-Control-Request-Headers",
],
preflightContinue: false,
optionsSuccessStatus: 200,
})
);
@ -86,11 +113,13 @@ app.use(errorHandler);
// 서버 시작
const PORT = config.port;
const HOST = config.host;
app.listen(PORT, () => {
logger.info(`🚀 Server is running on port ${PORT}`);
app.listen(PORT, HOST, () => {
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
logger.info(`📊 Environment: ${config.nodeEnv}`);
logger.info(`🔗 Health check: http://localhost:${PORT}/health`);
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
});
export default app;

View File

@ -16,6 +16,7 @@ import {
ArrowLeft,
Cog,
Layout,
Monitor,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -152,6 +153,19 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
D
</Badge>
</Button>
<Button
variant={panelStates.resolution?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("resolution")}
className={cn("flex items-center space-x-2", panelStates.resolution?.isOpen && "bg-blue-600 text-white")}
>
<Monitor className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
E
</Badge>
</Button>
</div>
{/* 우측: 액션 버튼들 */}

View File

@ -8,6 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
@ -30,6 +31,7 @@ import {
import { InteractiveDataTable } from "./InteractiveDataTable";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation";
import { screenApi } from "@/lib/api/screen";
interface InteractiveScreenViewerProps {
component: ComponentData;
@ -53,6 +55,37 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}) => {
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
// 팝업 화면 상태
const [popupScreen, setPopupScreen] = useState<{
screenId: number;
title: string;
size: string;
} | null>(null);
// 팝업 화면 레이아웃 상태
const [popupLayout, setPopupLayout] = useState<ComponentData[]>([]);
const [popupLoading, setPopupLoading] = useState(false);
// 팝업 화면 레이아웃 로드
React.useEffect(() => {
if (popupScreen) {
const loadPopupLayout = async () => {
try {
setPopupLoading(true);
const layout = await screenApi.getLayout(popupScreen.screenId);
setPopupLayout(layout.components || []);
} catch (error) {
console.error("팝업 화면 레이아웃 로드 실패:", error);
setPopupLayout([]);
} finally {
setPopupLoading(false);
}
};
loadPopupLayout();
}
}, [popupScreen]);
// 실제 사용할 폼 데이터 (외부에서 제공된 경우 우선 사용)
const formData = externalFormData || localFormData;
@ -948,8 +981,15 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 팝업 액션
const handlePopupAction = () => {
if (config?.popupTitle && config?.popupContent) {
// 커스텀 모달 대신 기본 alert 사용 (향후 모달 컴포넌트로 교체 가능)
if (config?.popupScreenId) {
// 화면 팝업 열기
setPopupScreen({
screenId: config.popupScreenId,
title: config.popupTitle || "상세 정보",
size: config.popupSize || "md",
});
} else if (config?.popupTitle && config?.popupContent) {
// 텍스트 팝업 표시
alert(`${config.popupTitle}\n\n${config.popupContent}`);
} else {
alert("팝업을 표시합니다.");
@ -1083,18 +1123,63 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
marginBottom: component.style?.labelMarginBottom || "4px",
};
return (
<div className="h-full w-full">
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<div className="block" style={labelStyle}>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
// 팝업 크기 설정
const getPopupMaxWidth = (size: string) => {
switch (size) {
case "sm": return "max-w-md";
case "md": return "max-w-2xl";
case "lg": return "max-w-4xl";
case "xl": return "max-w-6xl";
default: return "max-w-2xl";
}
};
{/* 실제 위젯 */}
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
</div>
return (
<>
<div className="h-full w-full">
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<div className="block" style={labelStyle}>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* 실제 위젯 */}
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
</div>
{/* 팝업 화면 모달 */}
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
<DialogContent className={`${getPopupMaxWidth(popupScreen?.size || "md")} max-h-[80vh] overflow-hidden`}>
<DialogHeader>
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto max-h-[60vh] p-2">
{popupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div>
</div>
) : popupLayout.length > 0 ? (
<div className="space-y-4">
{popupLayout.map((popupComponent) => (
<InteractiveScreenViewer
key={popupComponent.id}
component={popupComponent}
allComponents={popupLayout}
hideLabel={false}
/>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> .</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -11,6 +11,8 @@ import {
Position,
ColumnInfo,
GridSettings,
ScreenResolution,
SCREEN_RESOLUTIONS,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import {
@ -45,6 +47,7 @@ import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel";
import ResolutionPanel from "./panels/ResolutionPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
interface ScreenDesignerProps {
@ -102,6 +105,14 @@ const panelConfigs: PanelConfig[] = [
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "d",
},
{
id: "resolution",
title: "해상도 설정",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400,
shortcutKey: "e", // resolution의 e
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
@ -122,6 +133,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
});
const [isSaving, setIsSaving] = useState(false);
// 해상도 설정 상태
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 클립보드 상태
@ -171,9 +187,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const gridInfo = useMemo(() => {
if (!layout.gridSettings) return null;
// 캔버스 크기 계산
let width = canvasSize.width || window.innerWidth - 100;
let height = canvasSize.height || window.innerHeight - 200;
// 캔버스 크기 계산 (해상도 설정 우선)
let width = screenResolution.width;
let height = screenResolution.height;
// 해상도가 설정되지 않은 경우 기본값 사용
if (!width || !height) {
width = canvasSize.width || window.innerWidth - 100;
height = canvasSize.height || window.innerHeight - 200;
}
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
@ -187,21 +209,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
}, [layout.gridSettings, canvasSize]);
}, [layout.gridSettings, canvasSize, screenResolution]);
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
// 캔버스 크기 계산
let width = window.innerWidth - 100;
let height = window.innerHeight - 200;
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
width = rect.width || width;
height = rect.height || height;
}
// 캔버스 크기는 해상도 크기 사용
const width = screenResolution.width;
const height = screenResolution.height;
const lines = generateGridLines(width, height, {
columns: layout.gridSettings.columns,
@ -217,7 +233,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
];
return allLines;
}, [gridInfo, layout.gridSettings]);
}, [gridInfo, layout.gridSettings, screenResolution]);
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
@ -541,6 +557,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
...response.gridSettings, // 기존 설정이 있으면 덮어쓰기
},
};
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
if (response.screenResolution) {
setScreenResolution(response.screenResolution);
console.log("💾 저장된 해상도 불러옴:", response.screenResolution);
} else {
// 기본 해상도 (Full HD)
const defaultResolution =
SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0];
setScreenResolution(defaultResolution);
console.log("🔧 기본 해상도 적용:", defaultResolution);
}
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
@ -560,9 +589,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const newLayout = { ...layout, gridSettings: newGridSettings };
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정
if (newGridSettings.snapToGrid && canvasSize.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산
const newGridInfo = calculateGridInfo(canvasSize.width, canvasSize.height, {
if (newGridSettings.snapToGrid && screenResolution.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준)
const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
@ -602,7 +631,65 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, canvasSize, saveToHistory],
[layout, screenResolution, saveToHistory],
);
// 해상도 변경 핸들러
const handleResolutionChange = useCallback(
(newResolution: ScreenResolution) => {
setScreenResolution(newResolution);
console.log("📱 해상도 변경:", newResolution);
// 레이아웃에 해상도 정보 즉시 반영
const updatedLayout = { ...layout, screenResolution: newResolution };
// 격자 스냅이 활성화된 경우, 기존 컴포넌트들을 새로운 해상도의 격자에 맞게 조정
if (layout.gridSettings?.snapToGrid && layout.components.length > 0) {
// 새로운 해상도로 격자 정보 재계산
const newGridInfo = calculateGridInfo(newResolution.width, newResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
const gridUtilSettings = {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid,
};
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
}
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns,
};
});
const newLayout = { ...updatedLayout, components: adjustedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
console.log("해상도 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
console.log("새로운 격자 정보:", newGridInfo);
} else {
// 격자 조정이 없는 경우에도 해상도 정보가 포함된 레이아웃 저장
setLayout(updatedLayout);
saveToHistory(updatedLayout);
}
},
[layout, saveToHistory],
);
// 저장
@ -611,7 +698,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
try {
setIsSaving(true);
await screenApi.saveLayout(selectedScreen.screenId, layout);
// 해상도 정보를 포함한 레이아웃 데이터 생성
const layoutWithResolution = {
...layout,
screenResolution: screenResolution,
};
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
toast.success("화면이 저장되었습니다.");
} catch (error) {
console.error("저장 실패:", error);
@ -2194,7 +2286,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
if (layout.components.length > 0 && selectedScreen?.screenId) {
setIsSaving(true);
try {
await screenApi.saveLayout(selectedScreen.screenId, layout);
// 해상도 정보를 포함한 레이아웃 데이터 생성
const layoutWithResolution = {
...layout,
screenResolution: screenResolution,
};
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
toast.success("레이아웃이 저장되었습니다.");
} catch (error) {
console.error("레이아웃 저장 실패:", error);
@ -2259,230 +2356,255 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isSaving={isSaving}
/>
{/* 메인 캔버스 영역 (전체 화면) */}
<div
ref={canvasRef}
className="relative flex-1 overflow-hidden bg-white"
onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
startSelectionDrag(e);
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#d1d5db",
opacity: layout.gridSettings?.gridOpacity || 0.5,
}}
/>
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
const children =
component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : [];
// 드래그 중 시각적 피드백 (다중 선택 지원)
const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id;
const isBeingDragged =
dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
let displayComponent = component;
if (isBeingDragged) {
if (isDraggingThis) {
// 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
displayComponent = {
...component,
position: dragState.currentPosition,
style: {
...component.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
},
};
} else {
// 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트
const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === component.id);
if (originalComponent) {
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
displayComponent = {
...component,
position: {
x: originalComponent.position.x + deltaX,
y: originalComponent.position.y + deltaY,
z: originalComponent.position.z || 1,
} as Position,
style: {
...component.style,
opacity: 0.8,
transition: "none",
zIndex: 8888, // 주 컴포넌트보다 약간 낮게
},
};
}
}
}
return (
<div
key={component.id}
className="absolute"
style={{
left: `${displayComponent.position.x}px`,
top: `${displayComponent.position.y}px`,
width: displayComponent.style?.width || `${displayComponent.size.width}px`,
height: displayComponent.style?.height || `${displayComponent.size.height}px`,
zIndex: displayComponent.position.z || 1,
}}
>
<RealtimePreview
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
}
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
>
{/* 컨테이너 및 그룹의 자식 컴포넌트들 렌더링 */}
{(component.type === "group" || component.type === "container") &&
layout.components
.filter((child) => child.parentId === component.id)
.map((child) => {
// 자식 컴포넌트에도 드래그 피드백 적용
const isChildDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === child.id;
const isChildBeingDragged =
dragState.isDragging &&
dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
let displayChild = child;
if (isChildBeingDragged) {
if (isChildDraggingThis) {
// 주 드래그 자식 컴포넌트
displayChild = {
...child,
position: dragState.currentPosition,
style: {
...child.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
},
};
} else {
// 다른 선택된 자식 컴포넌트들
const originalChildComponent = dragState.draggedComponents.find(
(dragComp) => dragComp.id === child.id,
);
if (originalChildComponent) {
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
displayChild = {
...child,
position: {
x: originalChildComponent.position.x + deltaX,
y: originalChildComponent.position.y + deltaY,
z: originalChildComponent.position.z || 1,
} as Position,
style: {
...child.style,
opacity: 0.8,
transition: "none",
zIndex: 8888,
},
};
}
}
}
return (
<div
key={child.id}
className="absolute"
style={{
left: `${displayChild.position.x - component.position.x}px`,
top: `${displayChild.position.y - component.position.y}px`,
width: `${displayChild.size.width}px`,
height: `${displayChild.size.height}px`, // 순수 컴포넌트 높이만 사용
zIndex: displayChild.position.z || 1,
}}
>
<RealtimePreview
component={displayChild}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
}
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
/>
</div>
);
})}
</RealtimePreview>
</div>
);
})}
{/* 드래그 선택 영역 */}
{selectionDrag.isSelecting && (
<div
className="pointer-events-none absolute"
style={{
left: `${Math.min(selectionDrag.startPoint.x, selectionDrag.currentPoint.x)}px`,
top: `${Math.min(selectionDrag.startPoint.y, selectionDrag.currentPoint.y)}px`,
width: `${Math.abs(selectionDrag.currentPoint.x - selectionDrag.startPoint.x)}px`,
height: `${Math.abs(selectionDrag.currentPoint.y - selectionDrag.startPoint.y)}px`,
border: "2px dashed #3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.05)", // 매우 투명한 배경 (5%)
borderRadius: "4px",
}}
/>
)}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> / 릿 </p>
<p className="mt-2 text-xs">단축키: T(), M(릿), P(), S(), R(), D()</p>
<p className="mt-1 text-xs">
편집: Ctrl+C(), Ctrl+V(), Ctrl+S(), Ctrl+Z(), Delete()
</p>
<p className="mt-1 text-xs text-amber-600">
</p>
</div>
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) */}
<div className="relative flex-1 overflow-auto bg-gray-100 p-8">
{/* 해상도 정보 표시 */}
<div className="mb-4 flex items-center justify-center">
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
<span className="text-sm font-medium text-gray-700">
{screenResolution.name} ({screenResolution.width} × {screenResolution.height})
</span>
</div>
)}
</div>
{/* 실제 작업 캔버스 (해상도 크기) */}
<div
className="mx-auto bg-white shadow-lg"
style={{ width: screenResolution.width, height: screenResolution.height }}
>
<div
ref={canvasRef}
className="relative h-full w-full overflow-hidden bg-white"
onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
startSelectionDrag(e);
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#d1d5db",
opacity: layout.gridSettings?.gridOpacity || 0.5,
}}
/>
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
const children =
component.type === "group"
? layout.components.filter((child) => child.parentId === component.id)
: [];
// 드래그 중 시각적 피드백 (다중 선택 지원)
const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id;
const isBeingDragged =
dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
let displayComponent = component;
if (isBeingDragged) {
if (isDraggingThis) {
// 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
displayComponent = {
...component,
position: dragState.currentPosition,
style: {
...component.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
},
};
} else {
// 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트
const originalComponent = dragState.draggedComponents.find(
(dragComp) => dragComp.id === component.id,
);
if (originalComponent) {
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
displayComponent = {
...component,
position: {
x: originalComponent.position.x + deltaX,
y: originalComponent.position.y + deltaY,
z: originalComponent.position.z || 1,
} as Position,
style: {
...component.style,
opacity: 0.8,
transition: "none",
zIndex: 8888, // 주 컴포넌트보다 약간 낮게
},
};
}
}
}
return (
<div
key={component.id}
className="absolute"
style={{
left: `${displayComponent.position.x}px`,
top: `${displayComponent.position.y}px`,
width: displayComponent.style?.width || `${displayComponent.size.width}px`,
height: displayComponent.style?.height || `${displayComponent.size.height}px`,
zIndex: displayComponent.position.z || 1,
}}
>
<RealtimePreview
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
}
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
>
{/* 컨테이너 및 그룹의 자식 컴포넌트들 렌더링 */}
{(component.type === "group" || component.type === "container") &&
layout.components
.filter((child) => child.parentId === component.id)
.map((child) => {
// 자식 컴포넌트에도 드래그 피드백 적용
const isChildDraggingThis =
dragState.isDragging && dragState.draggedComponent?.id === child.id;
const isChildBeingDragged =
dragState.isDragging &&
dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
let displayChild = child;
if (isChildBeingDragged) {
if (isChildDraggingThis) {
// 주 드래그 자식 컴포넌트
displayChild = {
...child,
position: dragState.currentPosition,
style: {
...child.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
},
};
} else {
// 다른 선택된 자식 컴포넌트들
const originalChildComponent = dragState.draggedComponents.find(
(dragComp) => dragComp.id === child.id,
);
if (originalChildComponent) {
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
displayChild = {
...child,
position: {
x: originalChildComponent.position.x + deltaX,
y: originalChildComponent.position.y + deltaY,
z: originalChildComponent.position.z || 1,
} as Position,
style: {
...child.style,
opacity: 0.8,
transition: "none",
zIndex: 8888,
},
};
}
}
}
return (
<div
key={child.id}
className="absolute"
style={{
left: `${displayChild.position.x - component.position.x}px`,
top: `${displayChild.position.y - component.position.y}px`,
width: `${displayChild.size.width}px`,
height: `${displayChild.size.height}px`, // 순수 컴포넌트 높이만 사용
zIndex: displayChild.position.z || 1,
}}
>
<RealtimePreview
component={displayChild}
isSelected={
selectedComponent?.id === child.id ||
groupState.selectedComponents.includes(child.id)
}
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
/>
</div>
);
})}
</RealtimePreview>
</div>
);
})}
{/* 드래그 선택 영역 */}
{selectionDrag.isSelecting && (
<div
className="pointer-events-none absolute"
style={{
left: `${Math.min(selectionDrag.startPoint.x, selectionDrag.currentPoint.x)}px`,
top: `${Math.min(selectionDrag.startPoint.y, selectionDrag.currentPoint.y)}px`,
width: `${Math.abs(selectionDrag.currentPoint.x - selectionDrag.startPoint.x)}px`,
height: `${Math.abs(selectionDrag.currentPoint.y - selectionDrag.startPoint.y)}px`,
border: "2px dashed #3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.05)", // 매우 투명한 배경 (5%)
borderRadius: "4px",
}}
/>
)}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> / 릿 </p>
<p className="mt-2 text-xs">
단축키: T(), M(릿), P(), S(), R(), D(), E()
</p>
<p className="mt-1 text-xs">
편집: Ctrl+C(), Ctrl+V(), Ctrl+S(), Ctrl+Z(), Delete()
</p>
<p className="mt-1 text-xs text-amber-600">
</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* 플로팅 패널들 */}
@ -2636,6 +2758,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
updateGridSettings(defaultSettings);
}}
screenResolution={screenResolution}
/>
</FloatingPanel>
@ -2657,6 +2780,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
/>
</FloatingPanel>
<FloatingPanel
id="resolution"
title="해상도 설정"
isOpen={panelStates.resolution?.isOpen || false}
onClose={() => closePanel("resolution")}
position="right"
width={320}
height={400}
autoHeight={true}
>
<div className="p-4">
<ResolutionPanel currentResolution={screenResolution} onResolutionChange={handleResolutionChange} />
</div>
</FloatingPanel>
{/* 그룹 생성 툴바 (필요시) */}
{false && groupState.selectedComponents.length > 1 && (
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 transform">

View File

@ -22,7 +22,8 @@ import {
Settings,
AlertTriangle,
} from "lucide-react";
import { ButtonActionType, ButtonTypeConfig, WidgetComponent } from "@/types/screen";
import { ButtonActionType, ButtonTypeConfig, WidgetComponent, ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
interface ButtonConfigPanelProps {
component: WidgetComponent;
@ -61,6 +62,30 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
};
});
// 화면 목록 상태
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
// 화면 목록 로드 함수
const loadScreens = async () => {
try {
setScreensLoading(true);
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
setScreens(response.data);
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setScreensLoading(false);
}
};
// 팝업 액션 타입일 때 화면 목록 로드
useEffect(() => {
if (localConfig.actionType === "popup") {
loadScreens();
}
}, [localConfig.actionType]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {};
@ -370,6 +395,33 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</Label>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.popupScreenId?.toString() || "none"}
onValueChange={(value) =>
updateConfig({
popupScreenId: value === "none" ? undefined : parseInt(value),
})
}
disabled={screensLoading}
>
<SelectTrigger className="h-8">
<SelectValue placeholder={screensLoading ? "로딩 중..." : "화면을 선택하세요"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{screens.map((screen) => (
<SelectItem key={screen.screenId} value={screen.screenId.toString()}>
{screen.screenName} ({screen.screenCode})
</SelectItem>
))}
</SelectContent>
</Select>
{localConfig.popupScreenId && (
<p className="text-xs text-gray-500"> </p>
)}
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
@ -396,15 +448,18 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Textarea
value={localConfig.popupContent || ""}
onChange={(e) => updateConfig({ popupContent: e.target.value })}
placeholder="여기에 팝업 내용을 입력하세요."
className="h-16 resize-none text-xs"
/>
</div>
{!localConfig.popupScreenId && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Textarea
value={localConfig.popupContent || ""}
onChange={(e) => updateConfig({ popupContent: e.target.value })}
placeholder="여기에 팝업 내용을 입력하세요."
className="h-16 resize-none text-xs"
/>
<p className="text-xs text-gray-500"> </p>
</div>
)}
</div>
</div>
)}

View File

@ -37,6 +37,25 @@ interface DetailSettingsPanelProps {
}
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ selectedComponent, onUpdateProperty }) => {
// 입력 가능한 웹타입들 정의
const inputableWebTypes = [
"text",
"number",
"decimal",
"date",
"datetime",
"select",
"dropdown",
"textarea",
"email",
"tel",
"code",
"entity",
"file",
"checkbox",
"radio",
];
// 웹타입별 상세 설정 렌더링 함수
const renderWebTypeConfig = React.useCallback(
(widget: WidgetComponent) => {
@ -225,86 +244,88 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
</div>
<div className="mt-1 text-xs text-gray-500">: {widget.columnName}</div>
{/* 입력 타입 설정 */}
<div className="mt-3 space-y-2">
<label className="text-sm font-medium text-gray-700"> </label>
<Select
value={widget.inputType || "direct"}
onValueChange={(value: "direct" | "auto") => {
onUpdateProperty(widget.id, "inputType", value);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct"></SelectItem>
<SelectItem value="auto"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
{widget.inputType === "auto"
? "시스템에서 자동으로 값을 생성합니다 (읽기 전용)"
: "사용자가 직접 값을 입력할 수 있습니다"}
</p>
{/* 입력 타입 설정 - 입력 가능한 웹타입에만 표시 */}
{inputableWebTypes.includes(widget.widgetType || "") && (
<div className="mt-3 space-y-2">
<label className="text-sm font-medium text-gray-700"> </label>
<Select
value={widget.inputType || "direct"}
onValueChange={(value: "direct" | "auto") => {
onUpdateProperty(widget.id, "inputType", value);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct"></SelectItem>
<SelectItem value="auto"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
{widget.inputType === "auto"
? "시스템에서 자동으로 값을 생성합니다 (읽기 전용)"
: "사용자가 직접 값을 입력할 수 있습니다"}
</p>
{/* 자동 값 타입 설정 (자동입력일 때만 표시) */}
{widget.inputType === "auto" && (
<div className="mt-3 space-y-2">
<label className="text-sm font-medium text-gray-700"> </label>
<Select
value={widget.autoValueType || "current_datetime"}
onValueChange={(
value:
| "current_datetime"
| "current_date"
| "current_time"
| "current_user"
| "uuid"
| "sequence"
| "user_defined",
) => {
onUpdateProperty(widget.id, "autoValueType", value);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current_datetime"> </SelectItem>
<SelectItem value="current_date"> </SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="current_user"> </SelectItem>
<SelectItem value="uuid">UUID</SelectItem>
<SelectItem value="sequence">퀀</SelectItem>
<SelectItem value="user_defined"> </SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
{(() => {
switch (widget.autoValueType || "current_datetime") {
case "current_datetime":
return "현재 날짜와 시간을 자동으로 입력합니다";
case "current_date":
return "현재 날짜를 자동으로 입력합니다";
case "current_time":
return "현재 시간을 자동으로 입력합니다";
case "current_user":
return "현재 로그인한 사용자 정보를 입력합니다";
case "uuid":
return "고유한 UUID를 생성합니다";
case "sequence":
return "순차적인 번호를 생성합니다";
case "user_defined":
return "사용자가 정의한 규칙에 따라 값을 생성합니다";
default:
return "";
}
})()}
</p>
</div>
)}
</div>
{/* 자동 값 타입 설정 (자동입력일 때만 표시) */}
{widget.inputType === "auto" && (
<div className="mt-3 space-y-2">
<label className="text-sm font-medium text-gray-700"> </label>
<Select
value={widget.autoValueType || "current_datetime"}
onValueChange={(
value:
| "current_datetime"
| "current_date"
| "current_time"
| "current_user"
| "uuid"
| "sequence"
| "user_defined",
) => {
onUpdateProperty(widget.id, "autoValueType", value);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current_datetime"> </SelectItem>
<SelectItem value="current_date"> </SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="current_user"> </SelectItem>
<SelectItem value="uuid">UUID</SelectItem>
<SelectItem value="sequence">퀀</SelectItem>
<SelectItem value="user_defined"> </SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
{(() => {
switch (widget.autoValueType || "current_datetime") {
case "current_datetime":
return "현재 날짜와 시간을 자동으로 입력합니다";
case "current_date":
return "현재 날짜를 자동으로 입력합니다";
case "current_time":
return "현재 시간을 자동으로 입력합니다";
case "current_user":
return "현재 로그인한 사용자 정보를 입력합니다";
case "uuid":
return "고유한 UUID를 생성합니다";
case "sequence":
return "순차적인 번호를 생성합니다";
case "user_defined":
return "사용자가 정의한 규칙에 따라 값을 생성합니다";
default:
return "";
}
})()}
</p>
</div>
)}
</div>
)}
</div>
{/* 상세 설정 영역 */}

View File

@ -8,15 +8,22 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
import { GridSettings } from "@/types/screen";
import { GridSettings, ScreenResolution } from "@/types/screen";
import { calculateGridInfo } from "@/lib/utils/gridUtils";
interface GridPanelProps {
gridSettings: GridSettings;
onGridSettingsChange: (settings: GridSettings) => void;
onResetGrid: () => void;
screenResolution?: ScreenResolution; // 해상도 정보 추가
}
export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettingsChange, onResetGrid }) => {
export const GridPanel: React.FC<GridPanelProps> = ({
gridSettings,
onGridSettingsChange,
onResetGrid,
screenResolution,
}) => {
const updateSetting = (key: keyof GridSettings, value: any) => {
onGridSettingsChange({
...gridSettings,
@ -24,6 +31,25 @@ export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettin
});
};
// 실제 격자 정보 계산
const actualGridInfo = screenResolution
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: gridSettings.columns,
gap: gridSettings.gap,
padding: gridSettings.padding,
snapToGrid: gridSettings.snapToGrid || false,
})
: null;
// 실제 표시되는 컬럼 수 계산 (항상 설정된 개수를 표시하되, 너비가 너무 작으면 경고)
const actualColumns = gridSettings.columns;
// 컬럼이 너무 작은지 확인
const isColumnsTooSmall =
screenResolution && actualGridInfo
? actualGridInfo.columnWidth < 30 // 30px 미만이면 너무 작다고 판단
: false;
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
@ -74,6 +100,19 @@ export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettin
/>
</div>
</div>
{/* 해상도 정보 */}
{screenResolution && (
<div className="mt-3 rounded bg-blue-50 p-3">
<h4 className="text-xs font-medium text-blue-900"> </h4>
<p className="mt-1 text-xs text-blue-700">
{screenResolution.width} × {screenResolution.height}
</p>
{actualGridInfo && (
<p className="mt-1 text-xs text-blue-600"> : {Math.round(actualGridInfo.columnWidth)}px</p>
)}
</div>
)}
</div>
{/* 설정 영역 */}
@ -85,6 +124,11 @@ export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettin
<div>
<Label htmlFor="columns" className="mb-2 block text-sm font-medium">
: {gridSettings.columns}
{isColumnsTooSmall && (
<span className="ml-2 text-xs text-orange-600">
( : {Math.round(actualGridInfo!.columnWidth)}px)
</span>
)}
</Label>
<Slider
id="columns"

View File

@ -0,0 +1,194 @@
"use client";
import React, { useState } from "react";
import { Monitor, Tablet, Smartphone, Settings } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
interface ResolutionPanelProps {
currentResolution: ScreenResolution;
onResolutionChange: (resolution: ScreenResolution) => void;
}
const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, onResolutionChange }) => {
const [customWidth, setCustomWidth] = useState(currentResolution.width.toString());
const [customHeight, setCustomHeight] = useState(currentResolution.height.toString());
const [selectedPreset, setSelectedPreset] = useState<string>(
SCREEN_RESOLUTIONS.find((r) => r.width === currentResolution.width && r.height === currentResolution.height)
?.name || "custom",
);
const handlePresetChange = (presetName: string) => {
setSelectedPreset(presetName);
if (presetName === "custom") {
return;
}
const preset = SCREEN_RESOLUTIONS.find((r) => r.name === presetName);
if (preset) {
setCustomWidth(preset.width.toString());
setCustomHeight(preset.height.toString());
onResolutionChange(preset);
}
};
const handleCustomResolution = () => {
const width = parseInt(customWidth);
const height = parseInt(customHeight);
if (width > 0 && height > 0) {
const customResolution: ScreenResolution = {
width,
height,
name: `사용자 정의 (${width}×${height})`,
category: "custom",
};
onResolutionChange(customResolution);
setSelectedPreset("custom");
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case "desktop":
return <Monitor className="h-4 w-4" />;
case "tablet":
return <Tablet className="h-4 w-4" />;
case "mobile":
return <Smartphone className="h-4 w-4" />;
default:
return <Settings className="h-4 w-4" />;
}
};
const getCategoryColor = (category: string) => {
switch (category) {
case "desktop":
return "text-blue-600";
case "tablet":
return "text-green-600";
case "mobile":
return "text-purple-600";
default:
return "text-gray-600";
}
};
return (
<div className="space-y-4">
{/* 현재 해상도 표시 */}
<div className="rounded-lg border bg-gray-50 p-3">
<div className="flex items-center space-x-2">
{getCategoryIcon(currentResolution.category)}
<span className="text-sm font-medium">{currentResolution.name}</span>
</div>
<div className="mt-1 text-xs text-gray-500">
{currentResolution.width} × {currentResolution.height}
</div>
</div>
{/* 프리셋 선택 */}
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Select value={selectedPreset} onValueChange={handlePresetChange}>
<SelectTrigger>
<SelectValue placeholder="해상도를 선택하세요" />
</SelectTrigger>
<SelectContent>
{/* Desktop */}
<div className="px-2 py-1 text-xs font-medium text-gray-500"></div>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
<SelectItem key={resolution.name} value={resolution.name}>
<div className="flex items-center space-x-2">
<Monitor className="h-4 w-4 text-blue-600" />
<span>{resolution.name}</span>
</div>
</SelectItem>
))}
{/* Tablet */}
<div className="px-2 py-1 text-xs font-medium text-gray-500">릿</div>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
<SelectItem key={resolution.name} value={resolution.name}>
<div className="flex items-center space-x-2">
<Tablet className="h-4 w-4 text-green-600" />
<span>{resolution.name}</span>
</div>
</SelectItem>
))}
{/* Mobile */}
<div className="px-2 py-1 text-xs font-medium text-gray-500"></div>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
<SelectItem key={resolution.name} value={resolution.name}>
<div className="flex items-center space-x-2">
<Smartphone className="h-4 w-4 text-purple-600" />
<span>{resolution.name}</span>
</div>
</SelectItem>
))}
{/* Custom */}
<div className="px-2 py-1 text-xs font-medium text-gray-500"> </div>
<SelectItem value="custom">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<span> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 사용자 정의 해상도 */}
{selectedPreset === "custom" && (
<div className="space-y-3 rounded-lg border bg-gray-50 p-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-gray-600"> (px)</Label>
<Input
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(e.target.value)}
placeholder="1920"
min="1"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-600"> (px)</Label>
<Input
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
placeholder="1080"
min="1"
/>
</div>
</div>
<Button onClick={handleCustomResolution} size="sm" className="w-full">
</Button>
</div>
)}
{/* 해상도 정보 */}
<div className="space-y-2 text-xs text-gray-500">
<div className="flex items-center justify-between">
<span> :</span>
<span>{(currentResolution.width / currentResolution.height).toFixed(2)}:1</span>
</div>
<div className="flex items-center justify-between">
<span> :</span>
<span>{(currentResolution.width * currentResolution.height).toLocaleString()}</span>
</div>
</div>
</div>
);
};
export default ResolutionPanel;

View File

@ -31,7 +31,7 @@ export function calculateGridInfo(
const columnWidth = (availableWidth - totalGaps) / columns;
return {
columnWidth: Math.max(columnWidth, 50), // 최소 50px
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
totalWidth: containerWidth,
totalHeight: containerHeight,
};
@ -172,13 +172,19 @@ export function generateGridLines(
// 세로 격자선 (컬럼 경계)
const verticalLines: number[] = [];
for (let i = 0; i <= columns; i++) {
const x = padding + i * (columnWidth + gap) - gap / 2;
if (x >= padding && x <= containerWidth - padding) {
verticalLines.push(x);
}
// 좌측 경계선
verticalLines.push(padding);
// 각 컬럼의 오른쪽 경계선들 (컬럼 사이의 격자선)
for (let i = 1; i < columns; i++) {
const x = padding + i * columnWidth + i * gap;
verticalLines.push(x);
}
// 우측 경계선
verticalLines.push(containerWidth - padding);
// 가로 격자선 (20px 단위)
const horizontalLines: number[] = [];
for (let y = padding; y < containerHeight; y += 20) {

View File

@ -343,6 +343,7 @@ export type ComponentData =
export interface LayoutData {
components: ComponentData[];
gridSettings?: GridSettings;
screenResolution?: ScreenResolution;
}
// 그리드 설정
@ -640,6 +641,7 @@ export interface ButtonTypeConfig {
popupTitle?: string;
popupContent?: string;
popupSize?: "sm" | "md" | "lg" | "xl";
popupScreenId?: number; // 팝업으로 열 화면 ID
// 네비게이션 관련 설정
navigateUrl?: string;
@ -654,6 +656,33 @@ export interface ButtonTypeConfig {
borderColor?: string;
}
// 화면 해상도 설정
export interface ScreenResolution {
width: number;
height: number;
name: string;
category: "desktop" | "tablet" | "mobile" | "custom";
}
// 미리 정의된 해상도 프리셋
export const SCREEN_RESOLUTIONS: ScreenResolution[] = [
// Desktop
{ width: 1920, height: 1080, name: "Full HD (1920×1080)", category: "desktop" },
{ width: 1366, height: 768, name: "HD (1366×768)", category: "desktop" },
{ width: 1440, height: 900, name: "WXGA+ (1440×900)", category: "desktop" },
{ width: 1280, height: 1024, name: "SXGA (1280×1024)", category: "desktop" },
// Tablet
{ width: 1024, height: 768, name: "iPad (1024×768)", category: "tablet" },
{ width: 768, height: 1024, name: "iPad Portrait (768×1024)", category: "tablet" },
{ width: 1112, height: 834, name: "iPad Pro 10.5 (1112×834)", category: "tablet" },
// Mobile
{ width: 375, height: 667, name: "iPhone SE (375×667)", category: "mobile" },
{ width: 414, height: 896, name: "iPhone 11 (414×896)", category: "mobile" },
{ width: 360, height: 640, name: "Android (360×640)", category: "mobile" },
];
// 웹타입별 설정 유니온 타입
export type WebTypeConfig =
| DateTypeConfig