e70267f738
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
- 음성 인식 (scada-demo/js/voice.js) — 한국어 발화 → 키워드 매핑 → INVYONE_UI.select() · 사이드바 마이크 버튼 + transcript 라벨, 매칭 시 청록 펄스 · Chrome/Edge HTTPS 환경 (운영 siflex.invyone.com OK) - 경고시스템/다중경고 버튼을 음성 인식과 동일 톤 · 🚨 emoji → SVG 삼각형 아이콘, voice-btn 패턴 (다크 솔리드 + 컬러 액센트) · 정적 (반짝 펄스 애니메이션 제거) - client.ts stash pop conflict 정리 (DEV_TENANT_HOST + 도메인 정리 통합) - ui.js 다중 경고 시연 wiring + scada 작업 노트 2건 - 기타 syncthing 보류분 batch (대시보드/레이아웃/로그인 layout 정리) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
694 lines
23 KiB
TypeScript
694 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useCallback, useEffect, useState, forwardRef, type ReactNode } from "react";
|
|
import { Plus, Save, X } from "lucide-react";
|
|
import { useDashboardStore } from "@/stores/dashboardStore";
|
|
import { useControlMode } from "@/components/control/hooks/useControlMode";
|
|
import { deleteDashboardCard } from "@/lib/api/dashMenu";
|
|
import { toast } from "sonner";
|
|
import { DashboardCard } from "./DashboardCard";
|
|
import { EmptyDashboard } from "@/components/layout/EmptyDashboard";
|
|
|
|
/**
|
|
* AnimatedFab — enter/exit 애니메이션을 가진 플로팅 액션 바.
|
|
* show=false 전환 시 closing 클래스를 주고 320ms 후 unmount.
|
|
* Ref: INVYONE Design System / ui_kits/app/dashboard-user.jsx (AnimatedFab).
|
|
*/
|
|
function AnimatedFab({ show, children }: { show: boolean; children: ReactNode }) {
|
|
const [rendered, setRendered] = useState(show);
|
|
const [closing, setClosing] = useState(false);
|
|
useEffect(() => {
|
|
if (show) {
|
|
setRendered(true);
|
|
setClosing(false);
|
|
} else if (rendered) {
|
|
setClosing(true);
|
|
const t = window.setTimeout(() => {
|
|
setRendered(false);
|
|
setClosing(false);
|
|
}, 320);
|
|
return () => window.clearTimeout(t);
|
|
}
|
|
}, [show, rendered]);
|
|
if (!rendered) return null;
|
|
return <div className={`ud-fab${closing ? "closing" : ""}`}>{children}</div>;
|
|
}
|
|
|
|
interface DashboardCanvasProps {
|
|
dashboardName: string;
|
|
onOpenLibrary: () => void;
|
|
onOpenSettings?: (cardId: string) => void;
|
|
controlMode?: boolean;
|
|
}
|
|
|
|
// ═══ 자유 배치 + 반응형을 위한 상수/유틸 ═══
|
|
// 저장 단위: % (0~100). 기존 px 데이터는 로드 시 자동 변환됨.
|
|
const SNAP_THRESHOLD = 8; // px — 스냅 걸리는 임계거리
|
|
const MIN_CARD_W_PX = 220;
|
|
const MIN_CARD_H_PX = 140;
|
|
const INTERACTION_THRESHOLD = 4;
|
|
|
|
type Dir = "" | "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
|
|
|
|
// 값이 px 로 저장된 건지 판별 — % 는 0~100, px 는 150+ 가 일반
|
|
function isValuePx(val: number): boolean {
|
|
return val > 100;
|
|
}
|
|
|
|
function pxToPct(px: number, container: number): number {
|
|
if (!container || container <= 0) return 0;
|
|
return (px / container) * 100;
|
|
}
|
|
|
|
function pctToPx(pct: number, container: number): number {
|
|
return (pct / 100) * container;
|
|
}
|
|
|
|
function round3(n: number): number {
|
|
return Math.round(n * 1000) / 1000;
|
|
}
|
|
|
|
// 카드 좌표를 항상 px 로 해석 (% 면 container 로 환산)
|
|
function readCardPx(card: Record<string, unknown>, cw: number, ch: number) {
|
|
const x = Number(card.position_x ?? card.POSITION_X ?? 0);
|
|
const y = Number(card.position_y ?? card.POSITION_Y ?? 0);
|
|
const w = Number(card.width ?? card.WIDTH ?? 0);
|
|
const h = Number(card.height ?? card.HEIGHT ?? 0);
|
|
return {
|
|
x: isValuePx(x) ? x : pctToPx(x, cw),
|
|
y: isValuePx(y) ? y : pctToPx(y, ch),
|
|
w: isValuePx(w) ? w : pctToPx(w, cw),
|
|
h: isValuePx(h) ? h : pctToPx(h, ch),
|
|
};
|
|
}
|
|
|
|
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(function DashboardCanvas(
|
|
{ dashboardName, onOpenLibrary, onOpenSettings, controlMode: controlActive },
|
|
externalRef,
|
|
) {
|
|
const cards = useDashboardStore((s) => s.cards);
|
|
const editMode = useDashboardStore((s) => s.editMode);
|
|
const setEditMode = useDashboardStore((s) => s.setEditMode);
|
|
const updateCard = useDashboardStore((s) => s.updateCard);
|
|
const removeCard = useDashboardStore((s) => s.removeCard);
|
|
const setCards = useDashboardStore((s) => s.setCards);
|
|
const activeDashboardId = useDashboardStore((s) => s.activeDashboardId);
|
|
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
|
|
const internalRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
|
|
const [canvasSize, setCanvasSize] = useState({ cw: 0, ch: 0 });
|
|
|
|
// 스냅 가이드 라인 (시각 피드백)
|
|
const [guides, setGuides] = useState<{ v: number[]; h: number[] }>({ v: [], h: [] });
|
|
|
|
const dragRef = useRef<{
|
|
cardId: string;
|
|
startX: number;
|
|
startY: number;
|
|
origLeft: number;
|
|
origTop: number; // px
|
|
origW: number;
|
|
origH: number; // px
|
|
liveLeft: number;
|
|
liveTop: number;
|
|
liveW: number;
|
|
liveH: number;
|
|
hasMoved: boolean;
|
|
activated: boolean;
|
|
mode: "drag" | "resize";
|
|
dir: Dir;
|
|
el: HTMLElement;
|
|
} | null>(null);
|
|
|
|
const getCanvasSize = useCallback(() => {
|
|
const cv = canvasRef.current;
|
|
const rect = cv?.getBoundingClientRect();
|
|
return {
|
|
cw: rect?.width || cv?.clientWidth || canvasSize.cw || 1000,
|
|
ch: rect?.height || cv?.clientHeight || canvasSize.ch || 600,
|
|
};
|
|
}, [canvasRef, canvasSize.ch, canvasSize.cw]);
|
|
|
|
useEffect(() => {
|
|
const cv = canvasRef.current;
|
|
if (!cv) return;
|
|
|
|
const syncSize = () => {
|
|
const rect = cv.getBoundingClientRect();
|
|
setCanvasSize({
|
|
cw: rect.width || cv.clientWidth,
|
|
ch: rect.height || cv.clientHeight,
|
|
});
|
|
};
|
|
|
|
syncSize();
|
|
|
|
const ro = new ResizeObserver(() => syncSize());
|
|
ro.observe(cv);
|
|
window.addEventListener("resize", syncSize);
|
|
return () => {
|
|
ro.disconnect();
|
|
window.removeEventListener("resize", syncSize);
|
|
};
|
|
}, [canvasRef]);
|
|
|
|
// 카드 값 정규화 — px → %, 범위 clamp (5~100, x+w≤100, y+h≤100)
|
|
// px 데이터 마이그레이션과 오버플로우/NaN 보정을 동시에 처리.
|
|
// changed=false 면 setCards 안 부르므로 무한루프 방지.
|
|
useEffect(() => {
|
|
if (cards.length === 0) return;
|
|
const { cw, ch } = canvasSize;
|
|
if (cw <= 0 || ch <= 0) return;
|
|
|
|
let changed = false;
|
|
const migrated = cards.map((c) => {
|
|
const x = Number(c.position_x ?? c.POSITION_X ?? 5);
|
|
const y = Number(c.position_y ?? c.POSITION_Y ?? 5);
|
|
const w = Number(c.width ?? c.WIDTH ?? 40);
|
|
const h = Number(c.height ?? c.HEIGHT ?? 35);
|
|
|
|
// 1) px → % (NaN 이면 기본값)
|
|
let nx = Number.isFinite(x) ? (isValuePx(x) ? pxToPct(x, cw) : x) : 5;
|
|
let ny = Number.isFinite(y) ? (isValuePx(y) ? pxToPct(y, ch) : y) : 5;
|
|
let nw = Number.isFinite(w) ? (isValuePx(w) ? pxToPct(w, cw) : w) : 40;
|
|
let nh = Number.isFinite(h) ? (isValuePx(h) ? pxToPct(h, ch) : h) : 35;
|
|
|
|
// 2) 범위 clamp — 카드가 항상 캔버스 안에 들어오도록
|
|
nw = Math.max(5, Math.min(nw, 100));
|
|
nh = Math.max(5, Math.min(nh, 100));
|
|
nx = Math.max(0, Math.min(nx, 100 - nw));
|
|
ny = Math.max(0, Math.min(ny, 100 - nh));
|
|
|
|
const changedThis =
|
|
!Number.isFinite(x) ||
|
|
!Number.isFinite(y) ||
|
|
!Number.isFinite(w) ||
|
|
!Number.isFinite(h) ||
|
|
Math.abs(nx - x) > 0.01 ||
|
|
Math.abs(ny - y) > 0.01 ||
|
|
Math.abs(nw - w) > 0.01 ||
|
|
Math.abs(nh - h) > 0.01;
|
|
if (changedThis) {
|
|
changed = true;
|
|
return { ...c, position_x: nx, position_y: ny, width: nw, height: nh };
|
|
}
|
|
return c;
|
|
});
|
|
if (changed) setCards(migrated);
|
|
}, [cards, canvasSize, setCards]);
|
|
|
|
// 드래그 clamp + 스냅 — 리턴은 px
|
|
const applyDragConstraints = useCallback(
|
|
(cardId: string, newLeft: number, newTop: number, w: number, h: number) => {
|
|
const { cw, ch } = getCanvasSize();
|
|
// 카드가 캔버스보다 크면(마이그레이션 미동작 등 edge) 왼쪽/위 경계로 강제 이동 금지 — origin 유지
|
|
const maxL = Math.max(0, cw - w);
|
|
const maxT = Math.max(0, ch - h);
|
|
let l = w > cw ? newLeft : Math.max(0, Math.min(newLeft, maxL));
|
|
let t = h > ch ? newTop : Math.max(0, Math.min(newTop, maxT));
|
|
|
|
const vGuides: number[] = [];
|
|
const hGuides: number[] = [];
|
|
|
|
// 1) 다른 카드 가장자리 스냅
|
|
for (const o of cards) {
|
|
const oid = o.card_id ?? o.CARD_ID;
|
|
if (oid === cardId) continue;
|
|
const { x: oL, y: oT, w: oW, h: oH } = readCardPx(o, cw, ch);
|
|
const oR = oL + oW;
|
|
const oB = oT + oH;
|
|
|
|
// X: left↔otherRight, right↔otherLeft, left↔otherLeft, right↔otherRight
|
|
if (Math.abs(l - oR) < SNAP_THRESHOLD) {
|
|
l = oR;
|
|
vGuides.push(l);
|
|
} else if (Math.abs(l + w - oL) < SNAP_THRESHOLD) {
|
|
l = oL - w;
|
|
vGuides.push(oL);
|
|
} else if (Math.abs(l - oL) < SNAP_THRESHOLD) {
|
|
l = oL;
|
|
vGuides.push(l);
|
|
} else if (Math.abs(l + w - oR) < SNAP_THRESHOLD) {
|
|
l = oR - w;
|
|
vGuides.push(oR);
|
|
}
|
|
|
|
// Y
|
|
if (Math.abs(t - oB) < SNAP_THRESHOLD) {
|
|
t = oB;
|
|
hGuides.push(t);
|
|
} else if (Math.abs(t + h - oT) < SNAP_THRESHOLD) {
|
|
t = oT - h;
|
|
hGuides.push(oT);
|
|
} else if (Math.abs(t - oT) < SNAP_THRESHOLD) {
|
|
t = oT;
|
|
hGuides.push(t);
|
|
} else if (Math.abs(t + h - oB) < SNAP_THRESHOLD) {
|
|
t = oB - h;
|
|
hGuides.push(oB);
|
|
}
|
|
}
|
|
|
|
// 2) 캔버스 경계 스냅 (0 / cw / ch)
|
|
if (Math.abs(l) < SNAP_THRESHOLD) {
|
|
l = 0;
|
|
vGuides.push(0);
|
|
}
|
|
if (Math.abs(l + w - cw) < SNAP_THRESHOLD) {
|
|
l = cw - w;
|
|
vGuides.push(cw);
|
|
}
|
|
if (Math.abs(t) < SNAP_THRESHOLD) {
|
|
t = 0;
|
|
hGuides.push(0);
|
|
}
|
|
if (Math.abs(t + h - ch) < SNAP_THRESHOLD) {
|
|
t = ch - h;
|
|
hGuides.push(ch);
|
|
}
|
|
|
|
// 3) 최종 clamp — 카드가 캔버스에 맞는 경우만 경계로 clamp, 오버플로우 카드는 그대로 두기
|
|
if (w <= cw) l = Math.max(0, Math.min(l, cw - w));
|
|
if (h <= ch) t = Math.max(0, Math.min(t, ch - h));
|
|
|
|
return { l, t, vGuides, hGuides };
|
|
},
|
|
[cards, getCanvasSize],
|
|
);
|
|
|
|
// 리사이즈 clamp (방향별) — 캔버스 경계를 벗어나지 않게, 최소 크기 보장
|
|
// 오버플로우 카드 edge: 이미 카드가 캔버스보다 크면 추가 확장은 금지, 축소는 자유롭게.
|
|
const applyResizeConstraints = useCallback(
|
|
(cardId: string, origL: number, origT: number, origW: number, origH: number, dx: number, dy: number, dir: Dir) => {
|
|
const { cw, ch } = getCanvasSize();
|
|
let l = origL,
|
|
t = origT,
|
|
w = origW,
|
|
h = origH;
|
|
const vGuides: number[] = [];
|
|
const hGuides: number[] = [];
|
|
|
|
if (dir.includes("e")) {
|
|
// 확장 한계: 캔버스 오른쪽 경계. 이미 넘치고 있으면 현재 origW 를 한계로 (확장 금지, 축소 허용)
|
|
const maxW = Math.max(origW, cw - origL);
|
|
w = Math.max(MIN_CARD_W_PX, Math.min(origW + dx, maxW));
|
|
}
|
|
if (dir.includes("s")) {
|
|
const maxH = Math.max(origH, ch - origT);
|
|
h = Math.max(MIN_CARD_H_PX, Math.min(origH + dy, maxH));
|
|
}
|
|
if (dir.includes("w")) {
|
|
// w 핸들: dx 만큼 left 증가 + width 감소. 왼쪽 경계(0) 허용 범위 내에서.
|
|
const minDx = origL > 0 ? -origL : 0; // 캔버스 안에 있을 때만 왼쪽으로 확장 허용
|
|
const maxShrink = origW - MIN_CARD_W_PX;
|
|
const clampedDx = Math.max(minDx, Math.min(dx, maxShrink));
|
|
l = origL + clampedDx;
|
|
w = origW - clampedDx;
|
|
}
|
|
if (dir.includes("n")) {
|
|
const minDy = origT > 0 ? -origT : 0;
|
|
const maxShrink = origH - MIN_CARD_H_PX;
|
|
const clampedDy = Math.max(minDy, Math.min(dy, maxShrink));
|
|
t = origT + clampedDy;
|
|
h = origH - clampedDy;
|
|
}
|
|
|
|
let left = l;
|
|
let top = t;
|
|
let right = l + w;
|
|
let bottom = t + h;
|
|
|
|
const snapValue = (value: number, targets: number[]) => {
|
|
let snapped = value;
|
|
let matched = false;
|
|
for (const target of targets) {
|
|
if (Math.abs(value - target) < SNAP_THRESHOLD) {
|
|
snapped = target;
|
|
matched = true;
|
|
break;
|
|
}
|
|
}
|
|
return { snapped, matched };
|
|
};
|
|
|
|
const xTargets = [0, cw];
|
|
const yTargets = [0, ch];
|
|
for (const o of cards) {
|
|
const oid = o.card_id ?? o.CARD_ID;
|
|
if (oid === cardId) continue;
|
|
const { x: oL, y: oT, w: oW, h: oH } = readCardPx(o, cw, ch);
|
|
xTargets.push(oL, oL + oW);
|
|
yTargets.push(oT, oT + oH);
|
|
}
|
|
|
|
if (dir.includes("e")) {
|
|
const { snapped, matched } = snapValue(right, xTargets);
|
|
if (matched && snapped - left >= MIN_CARD_W_PX) {
|
|
right = snapped;
|
|
vGuides.push(snapped);
|
|
}
|
|
}
|
|
if (dir.includes("w")) {
|
|
const { snapped, matched } = snapValue(left, xTargets);
|
|
if (matched && right - snapped >= MIN_CARD_W_PX) {
|
|
left = snapped;
|
|
vGuides.push(snapped);
|
|
}
|
|
}
|
|
if (dir.includes("s")) {
|
|
const { snapped, matched } = snapValue(bottom, yTargets);
|
|
if (matched && snapped - top >= MIN_CARD_H_PX) {
|
|
bottom = snapped;
|
|
hGuides.push(snapped);
|
|
}
|
|
}
|
|
if (dir.includes("n")) {
|
|
const { snapped, matched } = snapValue(top, yTargets);
|
|
if (matched && bottom - snapped >= MIN_CARD_H_PX) {
|
|
top = snapped;
|
|
hGuides.push(snapped);
|
|
}
|
|
}
|
|
|
|
l = left;
|
|
t = top;
|
|
w = right - left;
|
|
h = bottom - top;
|
|
|
|
return { l, t, w, h, vGuides, hGuides };
|
|
},
|
|
[cards, getCanvasSize],
|
|
);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!editMode) return;
|
|
const target = e.target as HTMLElement;
|
|
|
|
const resizeEl = target.closest("[data-resize]") as HTMLElement | null;
|
|
const isResize = !!resizeEl;
|
|
if (!isResize) {
|
|
if (
|
|
target.closest("button") ||
|
|
target.closest("input") ||
|
|
target.closest("select") ||
|
|
target.closest("textarea")
|
|
)
|
|
return;
|
|
if (!target.closest(".dash-card")) return;
|
|
}
|
|
|
|
const wrapperEl = target.closest("[data-card-id]") as HTMLElement;
|
|
if (!wrapperEl) return;
|
|
|
|
const cardId = wrapperEl.dataset.cardId;
|
|
if (!cardId) return;
|
|
|
|
e.preventDefault();
|
|
|
|
const dir = (resizeEl?.dataset.resize ?? "") as Dir;
|
|
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
|
const wrapperRect = wrapperEl.getBoundingClientRect();
|
|
const box =
|
|
canvasRect && wrapperRect.width > 0 && wrapperRect.height > 0
|
|
? {
|
|
x: wrapperRect.left - canvasRect.left,
|
|
y: wrapperRect.top - canvasRect.top,
|
|
w: wrapperRect.width,
|
|
h: wrapperRect.height,
|
|
}
|
|
: {
|
|
x: wrapperEl.offsetLeft,
|
|
y: wrapperEl.offsetTop,
|
|
w: wrapperEl.offsetWidth,
|
|
h: wrapperEl.offsetHeight,
|
|
};
|
|
|
|
dragRef.current = {
|
|
cardId,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
origLeft: box.x,
|
|
origTop: box.y,
|
|
origW: box.w,
|
|
origH: box.h,
|
|
liveLeft: box.x,
|
|
liveTop: box.y,
|
|
liveW: box.w,
|
|
liveH: box.h,
|
|
hasMoved: false,
|
|
activated: false,
|
|
mode: isResize ? "resize" : "drag",
|
|
dir: isResize ? dir : "",
|
|
el: wrapperEl,
|
|
};
|
|
},
|
|
[canvasRef, editMode],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const d = dragRef.current;
|
|
if (!d) return;
|
|
|
|
const dx = e.clientX - d.startX;
|
|
const dy = e.clientY - d.startY;
|
|
if (!d.hasMoved && Math.max(Math.abs(dx), Math.abs(dy)) < INTERACTION_THRESHOLD) return;
|
|
d.hasMoved = true;
|
|
if (!d.activated) {
|
|
d.activated = true;
|
|
const cardEl = d.el.querySelector(".dash-card") as HTMLElement | null;
|
|
if (cardEl) cardEl.classList.add(d.mode === "resize" ? "resizing" : "dragging");
|
|
d.el.style.left = d.origLeft + "px";
|
|
d.el.style.top = d.origTop + "px";
|
|
d.el.style.width = d.origW + "px";
|
|
d.el.style.height = d.origH + "px";
|
|
|
|
const cursorMap: Record<string, string> = {
|
|
n: "ns-resize",
|
|
s: "ns-resize",
|
|
e: "ew-resize",
|
|
w: "ew-resize",
|
|
ne: "nesw-resize",
|
|
sw: "nesw-resize",
|
|
nw: "nwse-resize",
|
|
se: "nwse-resize",
|
|
};
|
|
document.body.style.cursor = d.mode === "resize" ? (cursorMap[d.dir] ?? "nwse-resize") : "grabbing";
|
|
document.body.style.userSelect = "none";
|
|
}
|
|
|
|
if (d.mode === "drag") {
|
|
const { l, t, vGuides, hGuides } = applyDragConstraints(
|
|
d.cardId,
|
|
d.origLeft + dx,
|
|
d.origTop + dy,
|
|
d.origW,
|
|
d.origH,
|
|
);
|
|
d.liveLeft = l;
|
|
d.liveTop = t;
|
|
d.liveW = d.origW;
|
|
d.liveH = d.origH;
|
|
d.el.style.left = l + "px";
|
|
d.el.style.top = t + "px";
|
|
setGuides({ v: vGuides, h: hGuides });
|
|
} else {
|
|
const { l, t, w, h, vGuides, hGuides } = applyResizeConstraints(
|
|
d.cardId,
|
|
d.origLeft,
|
|
d.origTop,
|
|
d.origW,
|
|
d.origH,
|
|
dx,
|
|
dy,
|
|
d.dir,
|
|
);
|
|
d.liveLeft = l;
|
|
d.liveTop = t;
|
|
d.liveW = w;
|
|
d.liveH = h;
|
|
d.el.style.left = l + "px";
|
|
d.el.style.top = t + "px";
|
|
d.el.style.width = w + "px";
|
|
d.el.style.height = h + "px";
|
|
setGuides({ v: vGuides, h: hGuides });
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
const d = dragRef.current;
|
|
if (!d) return;
|
|
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
|
|
if (!d.hasMoved) {
|
|
dragRef.current = null;
|
|
setGuides({ v: [], h: [] });
|
|
return;
|
|
}
|
|
|
|
const cardEl = d.el.querySelector(".dash-card") as HTMLElement | null;
|
|
if (cardEl) cardEl.classList.remove("dragging", "resizing");
|
|
|
|
// px → % 변환해서 store 에 저장 (다음 render 에서 % 로 다시 그려짐)
|
|
const { cw, ch } = getCanvasSize();
|
|
if (d.mode === "drag") {
|
|
const finalW = d.el.getBoundingClientRect().width || d.origW;
|
|
const finalH = d.el.getBoundingClientRect().height || d.origH;
|
|
updateCard(d.cardId, {
|
|
position_x: round3(pxToPct(d.liveLeft, cw)),
|
|
position_y: round3(pxToPct(d.liveTop, ch)),
|
|
width: round3(pxToPct(finalW, cw)),
|
|
height: round3(pxToPct(finalH, ch)),
|
|
});
|
|
} else {
|
|
updateCard(d.cardId, {
|
|
position_x: round3(pxToPct(d.liveLeft, cw)),
|
|
position_y: round3(pxToPct(d.liveTop, ch)),
|
|
width: round3(pxToPct(d.liveW, cw)),
|
|
height: round3(pxToPct(d.liveH, ch)),
|
|
});
|
|
}
|
|
|
|
dragRef.current = null;
|
|
setGuides({ v: [], h: [] });
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
}, [applyDragConstraints, applyResizeConstraints, getCanvasSize, updateCard]);
|
|
|
|
const handleRemove = useCallback(
|
|
async (cardId: string) => {
|
|
if (!confirm("이 카드를 삭제하시겠습니까?")) return;
|
|
if (!activeDashboardId) {
|
|
toast.error("활성 대시보드가 없습니다");
|
|
return;
|
|
}
|
|
removeCard(cardId);
|
|
try {
|
|
await deleteDashboardCard(activeDashboardId, cardId);
|
|
toast.success("카드 삭제됨");
|
|
} catch (err) {
|
|
console.error("[Dashboard] 카드 삭제 실패", err);
|
|
toast.error("카드 삭제 실패 — 새로고침 후 다시 시도해주세요");
|
|
}
|
|
},
|
|
[activeDashboardId, removeCard],
|
|
);
|
|
|
|
const handleRequestSave = useCallback(() => {
|
|
window.dispatchEvent(new CustomEvent("dash:save"));
|
|
}, []);
|
|
|
|
const canvasClassName = ["dash-canvas", editMode && "edit-mode", controlActive && "control-mode"]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
|
|
return (
|
|
<div ref={canvasRef} className={canvasClassName} onMouseDown={controlActive ? undefined : handleMouseDown}>
|
|
{cards.length === 0 ? (
|
|
<EmptyDashboard />
|
|
) : (
|
|
cards.map((card) => {
|
|
const id = card.card_id ?? card.CARD_ID;
|
|
const xRaw = Number(card.position_x ?? card.POSITION_X ?? 5);
|
|
const yRaw = Number(card.position_y ?? card.POSITION_Y ?? 5);
|
|
const wRaw = Number(card.width ?? card.WIDTH ?? 40);
|
|
const hRaw = Number(card.height ?? card.HEIGHT ?? 35);
|
|
|
|
// 마이그레이션 전 px 데이터는 px 로 (곧 useEffect 가 % 로 전환), 그 외는 % 로 렌더
|
|
const asPx = isValuePx(xRaw) || isValuePx(yRaw) || isValuePx(wRaw) || isValuePx(hRaw);
|
|
const unit = asPx ? "px" : "%";
|
|
|
|
// % 렌더 시 안전망 clamp (NaN / 오버플로우 방어)
|
|
let x = xRaw,
|
|
y = yRaw,
|
|
w = wRaw,
|
|
h = hRaw;
|
|
if (!asPx) {
|
|
if (!Number.isFinite(x)) x = 5;
|
|
if (!Number.isFinite(y)) y = 5;
|
|
if (!Number.isFinite(w)) w = 40;
|
|
if (!Number.isFinite(h)) h = 35;
|
|
w = Math.max(5, Math.min(w, 100));
|
|
h = Math.max(5, Math.min(h, 100));
|
|
x = Math.max(0, Math.min(x, 100 - w));
|
|
y = Math.max(0, Math.min(y, 100 - h));
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={id}
|
|
data-card-id={id}
|
|
style={{
|
|
position: "absolute",
|
|
left: x + unit,
|
|
top: y + unit,
|
|
width: w + unit,
|
|
height: h + unit,
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<DashboardCard
|
|
card={card}
|
|
editMode={editMode}
|
|
onRemove={handleRemove}
|
|
onOpenSettings={onOpenSettings}
|
|
/>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{/* 스냅 가이드 — 드래그 중 달라붙는 지점 시각 표시 */}
|
|
{guides.v.map((x, i) => (
|
|
<div key={`gv-${i}`} className="dash-snap-guide v" style={{ left: x + "px", top: 0, bottom: 0 }} />
|
|
))}
|
|
{guides.h.map((y, i) => (
|
|
<div key={`gh-${i}`} className="dash-snap-guide h" style={{ top: y + "px", left: 0, right: 0 }} />
|
|
))}
|
|
|
|
{/* 편집 모드 FAB — 캔버스 우하단 */}
|
|
<AnimatedFab show={editMode && !controlActive}>
|
|
<span className="ud-fab-badge">
|
|
<span className="dot" />
|
|
편집 모드 · 카드 {cards.length}개
|
|
</span>
|
|
<span className="ud-fab-sep" />
|
|
<button className="ud-fab-btn" onClick={onOpenLibrary} title="템플릿 추가">
|
|
<Plus size={12} />
|
|
<span>템플릿</span>
|
|
</button>
|
|
<button className="ud-fab-btn primary" onClick={handleRequestSave} title="레이아웃 저장">
|
|
<Save size={12} />
|
|
<span>저장</span>
|
|
</button>
|
|
<button className="ud-fab-btn ghost" onClick={() => setEditMode(false)} title="편집 종료">
|
|
<X size={12} />
|
|
<span>종료</span>
|
|
</button>
|
|
</AnimatedFab>
|
|
|
|
{/* 제어 모드 FAB */}
|
|
<AnimatedFab show={!!controlActive}>
|
|
<span className="ud-fab-badge ctrl">
|
|
<span className="dot" />
|
|
제어 모드 · 실시간 데이터
|
|
</span>
|
|
<span className="ud-fab-sep" />
|
|
<button className="ud-fab-btn ghost" onClick={() => toggleControlMode()} title="제어 종료">
|
|
<X size={12} />
|
|
<span>종료</span>
|
|
</button>
|
|
</AnimatedFab>
|
|
</div>
|
|
);
|
|
});
|