대시보드 캔버스 변경 #133
|
|
@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react";
|
|||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, QueryResult, Position } from "./types";
|
||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
import { GRID_CONFIG, magneticSnap, snapSizeToGrid } from "./gridUtils";
|
||||
|
||||
// 위젯 동적 임포트
|
||||
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
|
||||
|
|
@ -135,6 +135,8 @@ interface CanvasElementProps {
|
|||
cellSize: number;
|
||||
subGridSize: number;
|
||||
canvasWidth?: number;
|
||||
verticalGuidelines: number[];
|
||||
horizontalGuidelines: number[];
|
||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
|
||||
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void;
|
||||
|
|
@ -159,6 +161,8 @@ export function CanvasElement({
|
|||
cellSize,
|
||||
subGridSize,
|
||||
canvasWidth = 1560,
|
||||
verticalGuidelines,
|
||||
horizontalGuidelines,
|
||||
onUpdate,
|
||||
onUpdateMultiple,
|
||||
onMultiDragStart,
|
||||
|
|
@ -307,7 +311,7 @@ export function CanvasElement({
|
|||
const deltaX = e.clientX - dragStartRef.current.x;
|
||||
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
|
||||
|
||||
// 임시 위치 계산
|
||||
// 임시 위치 계산 (드래그 중에는 부드럽게 이동)
|
||||
let rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||
const rawY = Math.max(0, dragStart.elementY + deltaY);
|
||||
|
||||
|
|
@ -315,15 +319,12 @@ export function CanvasElement({
|
|||
const maxX = canvasWidth - element.size.width;
|
||||
rawX = Math.min(rawX, maxX);
|
||||
|
||||
// 드래그 중 실시간 스냅 (서브그리드만 사용)
|
||||
const snappedX = Math.round(rawX / subGridSize) * subGridSize;
|
||||
const snappedY = Math.round(rawY / subGridSize) * subGridSize;
|
||||
|
||||
setTempPosition({ x: snappedX, y: snappedY });
|
||||
// 드래그 중에는 스냅 없이 부드럽게 이동
|
||||
setTempPosition({ x: rawX, y: rawY });
|
||||
|
||||
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
|
||||
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
|
||||
onMultiDragMove(element, { x: snappedX, y: snappedY });
|
||||
onMultiDragMove(element, { x: rawX, y: rawY });
|
||||
}
|
||||
} else if (isResizing) {
|
||||
const deltaX = e.clientX - resizeStart.x;
|
||||
|
|
@ -367,15 +368,13 @@ export function CanvasElement({
|
|||
const maxWidth = canvasWidth - newX;
|
||||
newWidth = Math.min(newWidth, maxWidth);
|
||||
|
||||
// 리사이즈 중 실시간 스냅 (서브그리드만 사용)
|
||||
const snappedX = Math.round(newX / subGridSize) * subGridSize;
|
||||
const snappedY = Math.round(newY / subGridSize) * subGridSize;
|
||||
const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize;
|
||||
const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize;
|
||||
// 리사이즈 중에는 스냅 없이 부드럽게 조절
|
||||
const boundedX = Math.max(0, Math.min(newX, canvasWidth - newWidth));
|
||||
const boundedY = Math.max(0, newY);
|
||||
|
||||
// 임시 크기/위치 저장 (스냅됨)
|
||||
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
|
||||
setTempSize({ width: snappedWidth, height: snappedHeight });
|
||||
// 임시 크기/위치 저장 (부드러운 이동)
|
||||
setTempPosition({ x: boundedX, y: boundedY });
|
||||
setTempSize({ width: newWidth, height: newHeight });
|
||||
}
|
||||
},
|
||||
[
|
||||
|
|
@ -386,7 +385,8 @@ export function CanvasElement({
|
|||
element,
|
||||
canvasWidth,
|
||||
cellSize,
|
||||
subGridSize,
|
||||
verticalGuidelines,
|
||||
horizontalGuidelines,
|
||||
selectedElements,
|
||||
allElements,
|
||||
onUpdateMultiple,
|
||||
|
|
@ -398,10 +398,9 @@ export function CanvasElement({
|
|||
// 마우스 업 처리 (이미 스냅된 위치 사용)
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isDragging && tempPosition) {
|
||||
// tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨
|
||||
// 다시 스냅하지 않고 그대로 사용!
|
||||
let finalX = tempPosition.x;
|
||||
const finalY = tempPosition.y;
|
||||
// 마우스를 놓을 때 그리드에 스냅
|
||||
let finalX = magneticSnap(tempPosition.x, verticalGuidelines);
|
||||
const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
|
||||
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
|
||||
const maxX = canvasWidth - element.size.width;
|
||||
|
|
@ -459,20 +458,19 @@ export function CanvasElement({
|
|||
}
|
||||
|
||||
if (isResizing && tempPosition && tempSize) {
|
||||
// tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨
|
||||
// 다시 스냅하지 않고 그대로 사용!
|
||||
const finalX = tempPosition.x;
|
||||
const finalY = tempPosition.y;
|
||||
let finalWidth = tempSize.width;
|
||||
const finalHeight = tempSize.height;
|
||||
// 마우스를 놓을 때 그리드에 스냅
|
||||
const finalX = magneticSnap(tempPosition.x, verticalGuidelines);
|
||||
const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
|
||||
const finalWidth = snapSizeToGrid(tempSize.width, canvasWidth || 1560);
|
||||
const finalHeight = snapSizeToGrid(tempSize.height, canvasWidth || 1560);
|
||||
|
||||
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
|
||||
const maxWidth = canvasWidth - finalX;
|
||||
finalWidth = Math.min(finalWidth, maxWidth);
|
||||
const boundedWidth = Math.min(finalWidth, maxWidth);
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: finalX, y: finalY },
|
||||
size: { width: finalWidth, height: finalHeight },
|
||||
size: { width: boundedWidth, height: finalHeight },
|
||||
});
|
||||
|
||||
setTempPosition(null);
|
||||
|
|
@ -504,6 +502,8 @@ export function CanvasElement({
|
|||
allElements,
|
||||
dragStart.elementX,
|
||||
dragStart.elementY,
|
||||
verticalGuidelines,
|
||||
horizontalGuidelines,
|
||||
]);
|
||||
|
||||
// 🔥 자동 스크롤 루프 (requestAnimationFrame 사용)
|
||||
|
|
@ -891,12 +891,7 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "list" ? (
|
||||
// 리스트 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ListWidget
|
||||
element={element}
|
||||
onConfigUpdate={(newConfig) => {
|
||||
onUpdate(element.id, { listConfig: newConfig as any });
|
||||
}}
|
||||
/>
|
||||
<ListWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
|
||||
// 야드 관리 3D 위젯 렌더링
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@
|
|||
import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
||||
import { CanvasElement } from "./CanvasElement";
|
||||
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
|
||||
import {
|
||||
GRID_CONFIG,
|
||||
snapToGrid,
|
||||
calculateGridConfig,
|
||||
calculateVerticalGuidelines,
|
||||
calculateHorizontalGuidelines,
|
||||
calculateBoxSize,
|
||||
magneticSnap,
|
||||
} from "./gridUtils";
|
||||
import { resolveAllCollisions } from "./collisionUtils";
|
||||
|
||||
interface DashboardCanvasProps {
|
||||
|
|
@ -40,14 +48,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
onSelectElement,
|
||||
onSelectMultiple,
|
||||
onConfigureElement,
|
||||
backgroundColor = "#f9fafb",
|
||||
backgroundColor = "transparent",
|
||||
canvasWidth = 1560,
|
||||
canvasHeight = 768,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
|
||||
// 🔥 선택 박스 상태
|
||||
const [selectionBox, setSelectionBox] = useState<{
|
||||
startX: number;
|
||||
|
|
@ -58,10 +66,10 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그
|
||||
const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그
|
||||
|
||||
|
||||
// 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백)
|
||||
const [multiDragOffsets, setMultiDragOffsets] = useState<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
|
||||
// 🔥 선택 박스 드래그 중 자동 스크롤
|
||||
const lastMouseYForSelectionRef = React.useRef<number>(window.innerHeight / 2);
|
||||
const selectionAutoScrollFrameRef = React.useRef<number | null>(null);
|
||||
|
|
@ -70,6 +78,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
|
||||
const cellSize = gridConfig.CELL_SIZE;
|
||||
|
||||
// 🔥 그리드 박스 시스템 - 12개 박스가 캔버스 너비에 꽉 차게
|
||||
const verticalGuidelines = useMemo(() => calculateVerticalGuidelines(canvasWidth), [canvasWidth]);
|
||||
const horizontalGuidelines = useMemo(
|
||||
() => calculateHorizontalGuidelines(canvasHeight, canvasWidth),
|
||||
[canvasHeight, canvasWidth],
|
||||
);
|
||||
const boxSize = useMemo(() => calculateBoxSize(canvasWidth), [canvasWidth]);
|
||||
|
||||
// 충돌 방지 기능이 포함된 업데이트 핸들러
|
||||
const handleUpdateWithCollisionDetection = useCallback(
|
||||
(id: string, updates: Partial<DashboardElement>) => {
|
||||
|
|
@ -177,23 +193,13 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
||||
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||
|
||||
// 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
||||
const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기
|
||||
const magneticThreshold = 15;
|
||||
// 자석 스냅 적용
|
||||
let snappedX = magneticSnap(rawX, verticalGuidelines);
|
||||
let snappedY = magneticSnap(rawY, horizontalGuidelines);
|
||||
|
||||
// X 좌표 스냅
|
||||
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
|
||||
const distToGridX = Math.abs(rawX - nearestGridX);
|
||||
let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
|
||||
|
||||
// Y 좌표 스냅
|
||||
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
|
||||
const distToGridY = Math.abs(rawY - nearestGridY);
|
||||
const snappedY =
|
||||
distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
|
||||
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
|
||||
const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한 (최소 2칸 너비 보장)
|
||||
const minElementWidth = cellSize * 2 + GRID_CONFIG.GAP;
|
||||
const maxX = canvasWidth - minElementWidth;
|
||||
snappedX = Math.max(0, Math.min(snappedX, maxX));
|
||||
|
||||
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
|
||||
|
|
@ -201,7 +207,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 드롭 데이터 파싱 오류 무시
|
||||
}
|
||||
},
|
||||
[ref, onCreateElement, canvasWidth, cellSize],
|
||||
[ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines],
|
||||
);
|
||||
|
||||
// 🔥 선택 박스 드래그 시작
|
||||
|
|
@ -210,14 +216,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우)
|
||||
const target = e.target as HTMLElement;
|
||||
const isWidget = target.closest("[data-element-id]");
|
||||
|
||||
|
||||
if (isWidget) {
|
||||
// console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// console.log("✅ 빈 공간 클릭 - 선택 박스 시작");
|
||||
|
||||
|
||||
if (!ref || typeof ref === "function") return;
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
|
@ -274,20 +280,20 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
|
||||
// 겹치는 영역의 넓이
|
||||
const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop);
|
||||
|
||||
|
||||
// 요소의 전체 넓이
|
||||
const elementArea = el.size.width * el.size.height;
|
||||
|
||||
// 70% 이상 겹치면 선택
|
||||
const overlapPercentage = overlapArea / elementArea;
|
||||
|
||||
|
||||
// console.log(`📦 요소 ${el.id}:`, {
|
||||
// position: el.position,
|
||||
// size: el.size,
|
||||
// overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%",
|
||||
// selected: overlapPercentage >= 0.7,
|
||||
// });
|
||||
|
||||
|
||||
return overlapPercentage >= 0.7;
|
||||
})
|
||||
.map((el) => el.id);
|
||||
|
|
@ -327,9 +333,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
if (!isSelecting) {
|
||||
const deltaX = Math.abs(x - selectionBox.startX);
|
||||
const deltaY = Math.abs(y - selectionBox.startY);
|
||||
|
||||
|
||||
// console.log("📏 이동 거리:", { deltaX, deltaY });
|
||||
|
||||
|
||||
// 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분)
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
// console.log("🎯 선택 박스 활성화 (5px 이상 이동)");
|
||||
|
|
@ -374,10 +380,10 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const autoScrollLoop = (currentTime: number) => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const lastMouseY = lastMouseYForSelectionRef.current;
|
||||
|
||||
|
||||
let shouldScroll = false;
|
||||
let scrollDirection = 0;
|
||||
|
||||
|
||||
if (lastMouseY < scrollThreshold) {
|
||||
shouldScroll = true;
|
||||
scrollDirection = -scrollSpeed;
|
||||
|
|
@ -387,9 +393,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
scrollDirection = scrollSpeed;
|
||||
// console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold });
|
||||
}
|
||||
|
||||
|
||||
const deltaTime = currentTime - lastTime;
|
||||
|
||||
|
||||
if (shouldScroll && deltaTime >= 10) {
|
||||
window.scrollBy(0, scrollDirection);
|
||||
// console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime });
|
||||
|
|
@ -418,7 +424,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (e.target === e.currentTarget) {
|
||||
// console.log("✅ 빈 공간 클릭 - 선택 해제");
|
||||
onSelectElement(null);
|
||||
|
|
@ -433,7 +439,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 동적 그리드 크기 계산
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
|
||||
|
||||
|
||||
// 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용)
|
||||
const subGridSize = gridConfig.SUB_GRID_SIZE;
|
||||
|
||||
|
|
@ -443,7 +449,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 🔥 선택 박스 스타일 계산
|
||||
const selectionBoxStyle = useMemo(() => {
|
||||
if (!selectionBox) return null;
|
||||
|
||||
|
||||
const minX = Math.min(selectionBox.startX, selectionBox.endX);
|
||||
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
|
||||
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
||||
|
|
@ -460,19 +466,11 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative w-full rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
className={`relative w-full ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
style={{
|
||||
backgroundColor,
|
||||
height: `${canvasHeight}px`,
|
||||
minHeight: `${canvasHeight}px`,
|
||||
// 서브그리드 배경 (세밀한 점선)
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${subGridSize}px ${subGridSize}px`,
|
||||
backgroundPosition: "0 0",
|
||||
backgroundRepeat: "repeat",
|
||||
cursor: isSelecting ? "crosshair" : "default",
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
|
|
@ -494,6 +492,24 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
}}
|
||||
/>
|
||||
))} */}
|
||||
{/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게, 마지막 행 제외) */}
|
||||
{verticalGuidelines.map((x, xIdx) =>
|
||||
horizontalGuidelines.slice(0, -1).map((y, yIdx) => (
|
||||
<div
|
||||
key={`grid-box-${xIdx}-${yIdx}`}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${boxSize}px`,
|
||||
height: `${boxSize}px`,
|
||||
backgroundColor: "#ffffff",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
{/* 배치된 요소들 렌더링 */}
|
||||
{elements.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
|
|
@ -513,6 +529,8 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
cellSize={cellSize}
|
||||
subGridSize={subGridSize}
|
||||
canvasWidth={canvasWidth}
|
||||
verticalGuidelines={verticalGuidelines}
|
||||
horizontalGuidelines={horizontalGuidelines}
|
||||
onUpdate={handleUpdateWithCollisionDetection}
|
||||
onUpdateMultiple={(updates) => {
|
||||
// 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기)
|
||||
|
|
@ -552,10 +570,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
}}
|
||||
onRemove={onRemoveElement}
|
||||
onSelect={onSelectElement}
|
||||
onConfigure={onConfigureElement}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
{/* 🔥 선택 박스 렌더링 */}
|
||||
{selectionBox && selectionBoxStyle && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@ import { DashboardTopMenu } from "./DashboardTopMenu";
|
|||
import { ElementConfigSidebar } from "./ElementConfigSidebar";
|
||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils";
|
||||
import {
|
||||
GRID_CONFIG,
|
||||
snapToGrid,
|
||||
snapSizeToGrid,
|
||||
calculateCellSize,
|
||||
calculateGridConfig,
|
||||
calculateBoxSize,
|
||||
} from "./gridUtils";
|
||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
|
|
@ -47,7 +54,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("transparent");
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 저장 모달 상태
|
||||
|
|
@ -65,7 +72,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
|
||||
// 화면 해상도 자동 감지
|
||||
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
|
||||
const [resolution, setResolution] = useState<Resolution>(screenResolution);
|
||||
const [resolution, setResolution] = useState<Resolution>(() => {
|
||||
// 새 대시보드인 경우 (dashboardId 없음) 화면 해상도 감지값 사용
|
||||
// 기존 대시보드 편집인 경우 FHD로 시작 (로드 시 덮어씀)
|
||||
return initialDashboardId ? "fhd" : detectScreenResolution();
|
||||
});
|
||||
|
||||
// resolution 변경 감지 및 요소 자동 조정
|
||||
const handleResolutionChange = useCallback(
|
||||
|
|
@ -89,8 +100,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
// 그리드에 스냅 (X, Y, 너비, 높이 모두)
|
||||
const snappedX = snapToGrid(scaledX, newCellSize);
|
||||
const snappedY = snapToGrid(el.position.y, newCellSize);
|
||||
const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize);
|
||||
const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize);
|
||||
const snappedWidth = snapSizeToGrid(scaledWidth, newConfig.width);
|
||||
const snappedHeight = snapSizeToGrid(el.size.height, newConfig.width);
|
||||
|
||||
return {
|
||||
...el,
|
||||
|
|
@ -136,8 +147,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
// 대시보드 ID가 props로 전달되면 로드
|
||||
React.useEffect(() => {
|
||||
if (initialDashboardId) {
|
||||
console.log("📝 기존 대시보드 편집 모드");
|
||||
loadDashboard(initialDashboardId);
|
||||
} else {
|
||||
console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialDashboardId]);
|
||||
|
||||
// 대시보드 데이터 로드
|
||||
|
|
@ -164,23 +179,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
settings,
|
||||
resolution: settings?.resolution,
|
||||
backgroundColor: settings?.backgroundColor,
|
||||
currentResolution: resolution,
|
||||
});
|
||||
|
||||
if (settings?.resolution) {
|
||||
setResolution(settings.resolution);
|
||||
console.log("✅ Resolution 설정됨:", settings.resolution);
|
||||
} else {
|
||||
console.log("⚠️ Resolution 없음, 기본값 유지:", resolution);
|
||||
}
|
||||
|
||||
// 배경색 설정
|
||||
if (settings?.backgroundColor) {
|
||||
setCanvasBackgroundColor(settings.backgroundColor);
|
||||
console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor);
|
||||
} else {
|
||||
console.log("⚠️ BackgroundColor 없음, 기본값 유지:", canvasBackgroundColor);
|
||||
}
|
||||
|
||||
// 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함)
|
||||
const loadedResolution = settings?.resolution || "fhd";
|
||||
setResolution(loadedResolution);
|
||||
console.log("✅ Resolution 설정됨:", loadedResolution);
|
||||
|
||||
// 요소들 설정
|
||||
if (dashboard.elements && dashboard.elements.length > 0) {
|
||||
setElements(dashboard.elements);
|
||||
|
|
@ -215,22 +226,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
return;
|
||||
}
|
||||
|
||||
// 기본 크기 설정 (서브그리드 기준)
|
||||
const gridConfig = calculateGridConfig(canvasConfig.width);
|
||||
const subGridSize = gridConfig.SUB_GRID_SIZE;
|
||||
|
||||
// 서브그리드 기준 기본 크기 (픽셀)
|
||||
let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
|
||||
let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
|
||||
// 기본 크기 설정 (그리드 박스 단위)
|
||||
const boxSize = calculateBoxSize(canvasConfig.width);
|
||||
|
||||
// 그리드 박스 단위 기본 크기
|
||||
let boxesWidth = 3; // 기본 위젯: 박스 3개
|
||||
let boxesHeight = 3; // 기본 위젯: 박스 3개
|
||||
|
||||
if (type === "chart") {
|
||||
defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸
|
||||
defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸
|
||||
boxesWidth = 4; // 차트: 박스 4개
|
||||
boxesHeight = 3; // 차트: 박스 3개
|
||||
} else if (type === "widget" && subtype === "calendar") {
|
||||
defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸
|
||||
defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸
|
||||
boxesWidth = 3; // 달력: 박스 3개
|
||||
boxesHeight = 4; // 달력: 박스 4개
|
||||
}
|
||||
|
||||
// 박스 개수를 픽셀로 변환 (마지막 간격 제거)
|
||||
const defaultWidth = boxesWidth * boxSize + (boxesWidth - 1) * GRID_CONFIG.GRID_BOX_GAP;
|
||||
const defaultHeight = boxesHeight * boxSize + (boxesHeight - 1) * GRID_CONFIG.GRID_BOX_GAP;
|
||||
|
||||
// 크기 유효성 검사
|
||||
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
|
||||
// console.error("Invalid size calculated:", {
|
||||
|
|
@ -422,8 +436,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
id: el.id,
|
||||
type: el.type,
|
||||
subtype: el.subtype,
|
||||
position: el.position,
|
||||
size: el.size,
|
||||
// 위치와 크기는 정수로 반올림 (DB integer 타입)
|
||||
position: {
|
||||
x: Math.round(el.position.x),
|
||||
y: Math.round(el.position.y),
|
||||
},
|
||||
size: {
|
||||
width: Math.round(el.size.width),
|
||||
height: Math.round(el.size.height),
|
||||
},
|
||||
title: el.title,
|
||||
customTitle: el.customTitle,
|
||||
showHeader: el.showHeader,
|
||||
|
|
@ -449,6 +470,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
},
|
||||
};
|
||||
|
||||
console.log("💾 대시보드 업데이트 요청:", {
|
||||
dashboardId,
|
||||
updateData,
|
||||
elementsCount: elementsData.length,
|
||||
});
|
||||
|
||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
|
||||
} else {
|
||||
// 새 대시보드 생성
|
||||
|
|
@ -509,7 +536,18 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
// 성공 모달 표시
|
||||
setSuccessModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error("❌ 대시보드 저장 실패:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
|
||||
// 상세한 에러 정보 로깅
|
||||
if (error instanceof Error) {
|
||||
console.error("Error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
});
|
||||
}
|
||||
|
||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -550,7 +588,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
||||
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
|
||||
<div
|
||||
className="relative shadow-2xl"
|
||||
className="relative"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
|
|
|
|||
|
|
@ -54,14 +54,55 @@ interface ResolutionSelectorProps {
|
|||
export function detectScreenResolution(): Resolution {
|
||||
if (typeof window === "undefined") return "fhd";
|
||||
|
||||
const width = window.screen.width;
|
||||
const height = window.screen.height;
|
||||
// 1. 브라우저 뷰포트 크기 (실제 사용 가능한 공간)
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// 화면 해상도에 따라 적절한 캔버스 해상도 반환
|
||||
if (width >= 3840 || height >= 2160) return "uhd";
|
||||
if (width >= 2560 || height >= 1440) return "qhd";
|
||||
if (width >= 1920 || height >= 1080) return "fhd";
|
||||
return "hd";
|
||||
// 2. 화면 해상도 + devicePixelRatio (Retina 디스플레이 대응)
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
const physicalWidth = window.screen.width;
|
||||
const physicalHeight = window.screen.height;
|
||||
const logicalWidth = physicalWidth / pixelRatio;
|
||||
const logicalHeight = physicalHeight / pixelRatio;
|
||||
|
||||
let detectedResolution: Resolution;
|
||||
|
||||
// 뷰포트와 논리적 해상도 중 더 큰 값을 기준으로 결정
|
||||
// (크램쉘 모드나 특수한 경우에도 대응)
|
||||
const effectiveWidth = Math.max(viewportWidth, logicalWidth);
|
||||
const effectiveHeight = Math.max(viewportHeight, logicalHeight);
|
||||
|
||||
// 캔버스가 여유있게 들어갈 수 있는 크기로 결정
|
||||
// 여유 공간: 좌우 패딩, 사이드바 등을 고려하여 약 400-500px 여유
|
||||
if (effectiveWidth >= 3400) {
|
||||
// UHD 캔버스 2940px + 여유 460px
|
||||
detectedResolution = "uhd";
|
||||
} else if (effectiveWidth >= 2400) {
|
||||
// QHD 캔버스 1960px + 여유 440px
|
||||
detectedResolution = "qhd";
|
||||
} else if (effectiveWidth >= 1900) {
|
||||
// FHD 캔버스 1560px + 여유 340px
|
||||
detectedResolution = "fhd";
|
||||
} else {
|
||||
// HD 캔버스 1160px 이하
|
||||
detectedResolution = "hd";
|
||||
}
|
||||
|
||||
console.log("🖥️ 화면 해상도 자동 감지:", {
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
physicalWidth,
|
||||
physicalHeight,
|
||||
pixelRatio,
|
||||
logicalWidth: Math.round(logicalWidth),
|
||||
logicalHeight: Math.round(logicalHeight),
|
||||
effectiveWidth: Math.round(effectiveWidth),
|
||||
effectiveHeight: Math.round(effectiveHeight),
|
||||
detectedResolution,
|
||||
canvasSize: RESOLUTIONS[detectedResolution],
|
||||
});
|
||||
|
||||
return detectedResolution;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ export const GRID_CONFIG = {
|
|||
SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
|
||||
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
|
||||
SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용)
|
||||
// 가이드라인 시스템
|
||||
GUIDELINE_SPACING: 12, // 가이드라인 간격 (px)
|
||||
SNAP_DISTANCE: 10, // 자석 스냅 거리 (px)
|
||||
GUIDELINE_COLOR: "rgba(59, 130, 246, 0.3)", // 가이드라인 색상
|
||||
ROW_HEIGHT: 96, // 각 행의 높이 (12px * 8 = 96px)
|
||||
GRID_BOX_SIZE: 40, // 그리드 박스 크기 (px) - [ ] 한 칸의 크기
|
||||
GRID_BOX_GAP: 12, // 그리드 박스 간 간격 (px)
|
||||
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
|
||||
} as const;
|
||||
|
||||
|
|
@ -47,9 +54,11 @@ export function calculateGridConfig(canvasWidth: number) {
|
|||
|
||||
/**
|
||||
* 실제 그리드 셀 크기 계산 (gap 포함)
|
||||
* @param canvasWidth - 캔버스 너비
|
||||
*/
|
||||
export const getCellWithGap = () => {
|
||||
return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
export const getCellWithGap = (canvasWidth: number = 1560) => {
|
||||
const boxSize = calculateBoxSize(canvasWidth);
|
||||
return boxSize + GRID_CONFIG.GRID_BOX_GAP;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -63,14 +72,14 @@ export const getCanvasWidth = () => {
|
|||
/**
|
||||
* 좌표를 서브 그리드에 스냅 (세밀한 조정 가능)
|
||||
* @param value - 스냅할 좌표값
|
||||
* @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px)
|
||||
* @param subGridSize - 서브 그리드 크기 (선택사항)
|
||||
* @returns 스냅된 좌표값
|
||||
*/
|
||||
export const snapToGrid = (value: number, subGridSize?: number): number => {
|
||||
// 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드)
|
||||
const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3);
|
||||
|
||||
// 서브 그리드 단위로 스냅
|
||||
// 서브 그리드 크기가 지정되지 않으면 기본 박스 크기 사용
|
||||
const snapSize = subGridSize ?? calculateBoxSize(1560);
|
||||
|
||||
// 그리드 단위로 스냅
|
||||
const gridIndex = Math.round(value / snapSize);
|
||||
return gridIndex * snapSize;
|
||||
};
|
||||
|
|
@ -81,8 +90,9 @@ export const snapToGrid = (value: number, subGridSize?: number): number => {
|
|||
* @param cellSize - 셀 크기 (선택사항)
|
||||
* @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값)
|
||||
*/
|
||||
export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
|
||||
const snapped = snapToGrid(value, cellSize);
|
||||
export const snapToGridWithThreshold = (value: number, cellSize?: number): number => {
|
||||
const snapSize = cellSize ?? calculateBoxSize(1560);
|
||||
const snapped = snapToGrid(value, snapSize);
|
||||
const distance = Math.abs(value - snapped);
|
||||
|
||||
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
|
||||
|
|
@ -95,15 +105,7 @@ export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_C
|
|||
* @param cellSize - 셀 크기 (선택사항)
|
||||
* @returns 스냅된 크기
|
||||
*/
|
||||
export const snapSizeToGrid = (
|
||||
size: number,
|
||||
minCells: number = 2,
|
||||
cellSize: number = GRID_CONFIG.CELL_SIZE,
|
||||
): number => {
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const cells = Math.max(minCells, Math.round(size / cellWithGap));
|
||||
return cells * cellWithGap - GRID_CONFIG.GAP;
|
||||
};
|
||||
// 기존 snapSizeToGrid 제거 - 새 버전은 269번 줄에 있음
|
||||
|
||||
/**
|
||||
* 위치와 크기를 모두 그리드에 스냅
|
||||
|
|
@ -135,9 +137,10 @@ export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canva
|
|||
let snappedX = snapToGrid(bounds.position.x);
|
||||
let snappedY = snapToGrid(bounds.position.y);
|
||||
|
||||
// 크기 스냅
|
||||
const snappedWidth = snapSizeToGrid(bounds.size.width);
|
||||
const snappedHeight = snapSizeToGrid(bounds.size.height);
|
||||
// 크기 스냅 (canvasWidth 기본값 1560)
|
||||
const width = canvasWidth || 1560;
|
||||
const snappedWidth = snapSizeToGrid(bounds.size.width, width);
|
||||
const snappedHeight = snapSizeToGrid(bounds.size.height, width);
|
||||
|
||||
// 캔버스 경계 체크
|
||||
if (canvasWidth) {
|
||||
|
|
@ -198,3 +201,75 @@ export const getNearbyGridLines = (value: number): number[] => {
|
|||
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
|
||||
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
|
||||
};
|
||||
|
||||
// 박스 크기 계산 (캔버스 너비에 맞게)
|
||||
export function calculateBoxSize(canvasWidth: number): number {
|
||||
const totalGaps = 11 * GRID_CONFIG.GRID_BOX_GAP; // 12개 박스 사이 간격 11개
|
||||
const availableWidth = canvasWidth - totalGaps;
|
||||
return availableWidth / 12;
|
||||
}
|
||||
|
||||
// 수직 그리드 박스 좌표 계산 (12개, 너비에 꽉 차게)
|
||||
export function calculateVerticalGuidelines(canvasWidth: number): number[] {
|
||||
const lines: number[] = [];
|
||||
const boxSize = calculateBoxSize(canvasWidth);
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const x = i * (boxSize + GRID_CONFIG.GRID_BOX_GAP);
|
||||
lines.push(x);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// 수평 그리드 박스 좌표 계산 (캔버스 너비 기준으로 정사각형 유지)
|
||||
export function calculateHorizontalGuidelines(canvasHeight: number, canvasWidth: number): number[] {
|
||||
const lines: number[] = [];
|
||||
const boxSize = calculateBoxSize(canvasWidth); // 수직과 동일한 박스 크기 사용
|
||||
const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP;
|
||||
|
||||
for (let y = 0; y <= canvasHeight; y += cellSize) {
|
||||
lines.push(y);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// 가장 가까운 가이드라인 찾기
|
||||
export function findNearestGuideline(
|
||||
value: number,
|
||||
guidelines: number[],
|
||||
): {
|
||||
nearest: number;
|
||||
distance: number;
|
||||
} {
|
||||
let nearest = guidelines[0];
|
||||
let minDistance = Math.abs(value - guidelines[0]);
|
||||
|
||||
for (const guideline of guidelines) {
|
||||
const distance = Math.abs(value - guideline);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearest = guideline;
|
||||
}
|
||||
}
|
||||
|
||||
return { nearest, distance: minDistance };
|
||||
}
|
||||
|
||||
// 강제 스냅 (항상 가장 가까운 가이드라인에 스냅)
|
||||
export function magneticSnap(value: number, guidelines: number[]): number {
|
||||
const { nearest } = findNearestGuideline(value, guidelines);
|
||||
return nearest; // 거리 체크 없이 무조건 스냅
|
||||
}
|
||||
|
||||
// 크기를 그리드 박스 단위로 스냅 (박스 크기의 배수로만 가능)
|
||||
export function snapSizeToGrid(size: number, canvasWidth: number): number {
|
||||
const boxSize = calculateBoxSize(canvasWidth);
|
||||
const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; // 박스 + 간격
|
||||
|
||||
// 최소 1개 박스 크기
|
||||
const minBoxes = 1;
|
||||
const boxes = Math.max(minBoxes, Math.round(size / cellSize));
|
||||
|
||||
// 박스 개수에서 마지막 간격 제거
|
||||
return boxes * boxSize + (boxes - 1) * GRID_CONFIG.GRID_BOX_GAP;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -328,26 +328,28 @@ export function DashboardViewer({
|
|||
</div>
|
||||
) : (
|
||||
// 데스크톱: 기존 고정 캔버스 레이아웃
|
||||
<div className="flex min-h-screen items-start justify-center bg-gray-100 p-8">
|
||||
<div
|
||||
className="relative rounded-lg"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
>
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={false}
|
||||
/>
|
||||
))}
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="mx-auto" style={{ width: `${canvasConfig.width}px` }}>
|
||||
<div
|
||||
className="relative rounded-lg"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
>
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue