feat: 화면관리 시스템 구현
- 컴포넌트 드래그앤드롭 시스템 완성 - 속성 편집 및 실시간 미리보기 기능 - 버튼 시스템 통합 (유니버설 버튼) - 격자 시스템 및 해상도 설정 패널 - 상세설정 패널 구현 - 스타일 편집기 최적화 - 라벨 처리 시스템 개선
This commit is contained in:
parent
78d4d7de23
commit
9bf879e29d
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 우측: 액션 버튼들 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 상세 설정 영역 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue