2c0a97f2ba
- components/builder/* 폐기 (12-grid 미완성 빌더 14개 파일)
- components/template-builder/TemplateBuilder.tsx 신규
(자유배치 + 3뷰 + Zustand 스토어 + 드래그/리사이즈/히스토리/격자)
- admin/builder/page.tsx 진입점 전환 (BuilderLayout → TemplateBuilder)
- 타입 정리: FreePosition / TemplateComponent / ViewConfig / Card /
Dashboard / CardConnection 추가, 레거시(GridPosition/TemplateKind/
DEFAULT_COMPONENT_LAYOUTS/CANVAS_KEYWORDS) @deprecated 표기
- v2-* 마이그레이션 1차:
· 완전: v2-table-list (ResizeObserver), v2-table-search-widget (@container)
· 경량: button/input/select/date/text-display/card-display/aggregation-widget
(withContainerQuery HOC)
- 다크 모드 대응: Tailwind dark: variant 21패턴 71곳 치환
- /test-card-responsive PoC 검증 페이지
세션 후반 버그 픽스 (phase1-log §7):
- test-card-responsive (main) 그룹 밖 이동 (AppLayout 탭 시스템 회피)
- useRegistryPalette default_size {width,height}/{w,h} 포맷 정규화
- dark: variant 중복 체인 정리
검증: (A) 반응형 메커니즘, (B) TemplateBuilder UI 통과
(C) 기존 VEX 화면은 마이그레이션 미완 상태라 Phase 2 이후 개별 진행
스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md
로그: notes/gbpark/2026-04-10-card-engine-phase1-log.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
194 lines
6.4 KiB
TypeScript
194 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
import { useRef, useCallback, useEffect, forwardRef } from 'react';
|
|
import { useDashboardStore } from '@/stores/dashboardStore';
|
|
import { DashboardCard } from './DashboardCard';
|
|
import { DashboardEmpty } from './DashboardEmpty';
|
|
|
|
interface DashboardCanvasProps {
|
|
dashboardName: string;
|
|
onOpenLibrary: () => void;
|
|
onOpenSettings?: (cardId: string) => void;
|
|
controlMode?: boolean;
|
|
}
|
|
|
|
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 updateCard = useDashboardStore((s) => s.updateCard);
|
|
const removeCard = useDashboardStore((s) => s.removeCard);
|
|
const internalRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
|
|
const dragRef = useRef<{
|
|
cardId: string;
|
|
startX: number; startY: number;
|
|
origLeft: number; origTop: number;
|
|
origW: number; origH: number;
|
|
mode: 'drag' | 'resize';
|
|
el: HTMLElement;
|
|
} | null>(null);
|
|
|
|
// 캔버스 경계 clamp
|
|
const clamp = useCallback((l: number, t: number, w: number, h: number) => {
|
|
const cv = canvasRef.current;
|
|
if (!cv) return { l, t, w, h };
|
|
const cw = cv.clientWidth;
|
|
const ch = cv.clientHeight;
|
|
w = Math.min(w, cw);
|
|
h = Math.min(h, ch);
|
|
l = Math.max(0, Math.min(l, cw - w));
|
|
t = Math.max(0, Math.min(t, ch - h));
|
|
return { l, t, w, h };
|
|
}, []);
|
|
|
|
// 마우스 다운 → 드래그/리사이즈 시작
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
if (!editMode) return;
|
|
const target = e.target as HTMLElement;
|
|
|
|
// 버튼/입력/select 클릭은 무시 (단, 리사이즈 핸들은 통과)
|
|
const isResize = target.closest('[data-resize]') !== null;
|
|
if (!isResize) {
|
|
if (target.closest('button') || target.closest('input') || target.closest('select') || target.closest('textarea')) return;
|
|
}
|
|
|
|
// ★ wrapper div(data-card-id 가진 것)를 찾아야 함 — .dash-card는 그 안의 div
|
|
const wrapperEl = target.closest('[data-card-id]') as HTMLElement;
|
|
if (!wrapperEl) return;
|
|
|
|
const cardId = wrapperEl.dataset.cardId;
|
|
if (!cardId) return;
|
|
|
|
e.preventDefault();
|
|
|
|
dragRef.current = {
|
|
cardId,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
origLeft: wrapperEl.offsetLeft,
|
|
origTop: wrapperEl.offsetTop,
|
|
origW: wrapperEl.offsetWidth,
|
|
origH: wrapperEl.offsetHeight,
|
|
mode: isResize ? 'resize' : 'drag',
|
|
el: wrapperEl,
|
|
};
|
|
|
|
// 시각 피드백은 내부 .dash-card에 적용
|
|
const cardEl = wrapperEl.querySelector('.dash-card') as HTMLElement | null;
|
|
if (cardEl) cardEl.classList.add(isResize ? 'resizing' : 'dragging');
|
|
document.body.style.cursor = isResize ? 'nwse-resize' : 'grabbing';
|
|
document.body.style.userSelect = 'none';
|
|
}, [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.mode === 'drag') {
|
|
const c = clamp(d.origLeft + dx, d.origTop + dy, d.origW, d.origH);
|
|
d.el.style.left = c.l + 'px';
|
|
d.el.style.top = c.t + 'px';
|
|
} else {
|
|
const nw = Math.max(220, d.origW + dx);
|
|
const nh = Math.max(140, d.origH + dy);
|
|
const c = clamp(d.origLeft, d.origTop, nw, nh);
|
|
d.el.style.width = c.w + 'px';
|
|
d.el.style.height = c.h + 'px';
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
const d = dragRef.current;
|
|
if (!d) return;
|
|
|
|
// 시각 피드백 제거 (.dash-card)
|
|
const cardEl = d.el.querySelector('.dash-card') as HTMLElement | null;
|
|
if (cardEl) cardEl.classList.remove('dragging', 'resizing');
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
|
|
// 최종 위치를 store에 반영
|
|
updateCard(d.cardId, {
|
|
position_x: d.el.offsetLeft,
|
|
position_y: d.el.offsetTop,
|
|
width: d.el.offsetWidth,
|
|
height: d.el.offsetHeight,
|
|
});
|
|
|
|
dragRef.current = null;
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [clamp, updateCard]);
|
|
|
|
const handleToggleCollapse = useCallback((cardId: string) => {
|
|
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
|
|
if (!card) return;
|
|
const wasCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
|
|
updateCard(cardId, { is_collapsed: !wasCollapsed });
|
|
}, [cards, updateCard]);
|
|
|
|
const handleRemove = useCallback((cardId: string) => {
|
|
if (!confirm('이 카드를 삭제하시겠습니까?')) return;
|
|
removeCard(cardId);
|
|
}, [removeCard]);
|
|
|
|
return (
|
|
<div
|
|
ref={canvasRef}
|
|
className={`dash-canvas${editMode ? ' edit-mode' : ''}${controlActive ? ' control-mode' : ''}`}
|
|
onMouseDown={controlActive ? undefined : handleMouseDown}
|
|
>
|
|
{cards.length === 0 ? (
|
|
<DashboardEmpty dashboardName={dashboardName} onOpenLibrary={onOpenLibrary} />
|
|
) : (
|
|
cards.map((card) => {
|
|
const id = card.card_id ?? card.CARD_ID;
|
|
const x = Number(card.position_x ?? card.POSITION_X ?? 50);
|
|
const y = Number(card.position_y ?? card.POSITION_Y ?? 50);
|
|
const w = Number(card.width ?? card.WIDTH ?? 600);
|
|
const h = Number(card.height ?? card.HEIGHT ?? 400);
|
|
|
|
return (
|
|
<div
|
|
key={id}
|
|
data-card-id={id}
|
|
style={{
|
|
position: 'absolute',
|
|
left: x + 'px',
|
|
top: y + 'px',
|
|
width: w + 'px',
|
|
height: h + 'px',
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<DashboardCard
|
|
card={card}
|
|
editMode={editMode}
|
|
onRemove={handleRemove}
|
|
onToggleCollapse={handleToggleCollapse}
|
|
onOpenSettings={onOpenSettings}
|
|
/>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
});
|