임시저장

This commit is contained in:
DDD1542
2026-04-20 10:00:18 +09:00
parent b3ad787179
commit aab894b553
9 changed files with 2718 additions and 682 deletions
+14 -36
View File
@@ -1,6 +1,6 @@
'use client';
import { Edit3, Save, Plus, Zap } from 'lucide-react';
import { Save, Plus, Zap } from 'lucide-react';
import { useDashboardStore } from '@/stores/dashboardStore';
import { useControlMode } from '@/components/control/hooks/useControlMode';
@@ -18,18 +18,10 @@ export function DashboardToolbar({
onSaveLayout,
}: DashboardToolbarProps) {
const editMode = useDashboardStore((s) => s.editMode);
const toggleEditMode = useDashboardStore((s) => s.toggleEditMode);
const setEditMode = useDashboardStore((s) => s.setEditMode);
const controlActive = useControlMode((s) => s.active);
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
const handleToggleControl = () => {
if (!controlActive) {
// 제어 모드 진입 시 편집 모드 끄기
setEditMode(false);
}
toggleControlMode();
};
// 편집/제어 모드가 모두 꺼져 있으면 툴바 자체를 렌더링하지 않음 — 헤더 버튼으로 토글
if (!editMode && !controlActive) return null;
return (
<div className="dash-toolbar">
@@ -38,36 +30,22 @@ export function DashboardToolbar({
<span className="dash-cv-meta">{`템플릿 ${cardCount}`}</span>
</div>
<div className="dash-toolbar-r">
{/* ⚡ 제어 모드 토글 */}
<button
className={`dash-btn${controlActive ? ' control-on' : ''}`}
onClick={handleToggleControl}
title={controlActive ? '제어 모드 끄기' : '제어 모드 — 데이터 흐름 시각화'}
>
<Zap size={13} />
<span>{controlActive ? '제어 ✓' : '제어'}</span>
</button>
{!controlActive && (
{controlActive && (
<span className="dash-btn control-on" style={{ pointerEvents: 'none' }}>
<Zap size={13} />
<span> </span>
</span>
)}
{editMode && !controlActive && (
<>
<button
className={`dash-btn${editMode ? ' on' : ''}`}
onClick={toggleEditMode}
title={editMode ? '편집 모드 끄기' : '편집 모드 켜기'}
>
<Edit3 size={13} />
<span>{editMode ? '편집 중' : '편집'}</span>
</button>
<button className="dash-btn primary" onClick={onOpenLibrary}>
<Plus size={13} />
<span>릿 </span>
</button>
{editMode && (
<button className="dash-btn" onClick={onSaveLayout}>
<Save size={13} />
<span></span>
</button>
)}
<button className="dash-btn" onClick={onSaveLayout}>
<Save size={13} />
<span></span>
</button>
</>
)}
</div>
File diff suppressed because it is too large Load Diff
+34 -25
View File
@@ -22,8 +22,10 @@ import {
Monitor,
Plus,
Edit3,
Zap,
} from "lucide-react";
import { useDashboardStore } from "@/stores/dashboardStore";
import { useControlMode } from "@/components/control/hooks/useControlMode";
import { insertDashboard } from "@/lib/api/dashMenu";
import { CreateDashboardModal } from "@/components/dash/CreateDashboardModal";
import { useMenu } from "@/contexts/MenuContext";
@@ -254,14 +256,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const { theme, setTheme: rawSetTheme } = useTheme();
// 대시보드 생성/편집 모드 / 템플릿 추가 (전역 헤더 버튼)
const dashEditMode = useDashboardStore((s) => s.editMode);
const toggleDashEditMode = useDashboardStore((s) => s.toggleEditMode);
// 대시보드 생성 (전역) + 제어/편집 (대시보드 페이지에서만 조건부 노출)
const dashCreateOpen = useDashboardStore((s) => s.createOpen);
const openDashCreate = useDashboardStore((s) => s.openCreate);
const closeDashCreate = useDashboardStore((s) => s.closeCreate);
const dashActiveId = useDashboardStore((s) => s.activeDashboardId);
const openDashLib = useDashboardStore((s) => s.openLib);
const dashEditMode = useDashboardStore((s) => s.editMode);
const toggleDashEditMode = useDashboardStore((s) => s.toggleEditMode);
const setDashEditMode = useDashboardStore((s) => s.setEditMode);
const dashControlActive = useControlMode((s) => s.active);
const toggleDashControlMode = useControlMode((s) => s.toggleControlMode);
const [dashCreateSubmitting, setDashCreateSubmitting] = useState(false);
const handleDashCreateSubmit = useCallback(async (payload: { name: string; icon: string; is_personal: boolean }) => {
@@ -834,7 +837,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
<div className="v5-hdr-glow" />
<div className="v5-hdr-r">
{/* 대시보드 생성 + 템플릿 추가 + 편집 모드 (Light/Dark 토글 왼쪽) */}
{/* 대시보드 생성(전역) + 제어/편집(대시보드 페이지 전용) — 평소엔 페이지 내부 툴바 없음, 이 버튼 눌러야 툴바 등장 */}
<button
className="v5-dash-btn"
onClick={openDashCreate}
@@ -843,24 +846,30 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<Plus size={13} />
<span></span>
</button>
<button
className="v5-dash-btn"
onClick={openDashLib}
disabled={!dashActiveId}
title={dashActiveId ? "템플릿 라이브러리에서 카드 추가" : "대시보드를 먼저 선택하세요"}
>
<Plus size={13} />
<span>릿 </span>
</button>
<button
className={`v5-dash-btn${dashEditMode ? " on" : ""}`}
onClick={toggleDashEditMode}
disabled={!dashActiveId}
title={dashActiveId ? (dashEditMode ? "편집 모드 끄기" : "편집 모드 켜기") : "대시보드 화면에서만 사용할 수 있습니다"}
>
<Edit3 size={13} />
<span>{dashEditMode ? "편집 중" : "편집"}</span>
</button>
{pathname && !isAdminMode && /^\/\d+$/.test(pathname) && (
<>
<button
className={`v5-dash-btn${dashControlActive ? " on" : ""}`}
onClick={() => {
if (!dashControlActive) setDashEditMode(false);
toggleDashControlMode();
}}
title={dashControlActive ? "제어 모드 끄기" : "제어 모드 — 데이터 흐름 시각화"}
>
<Zap size={13} />
<span>{dashControlActive ? "제어 ✓" : "제어"}</span>
</button>
<button
className={`v5-dash-btn${dashEditMode ? " on" : ""}`}
onClick={toggleDashEditMode}
disabled={dashControlActive}
title={dashControlActive ? "제어 모드 중에는 편집 불가" : (dashEditMode ? "편집 모드 끄기" : "편집 모드 켜기")}
>
<Edit3 size={13} />
<span>{dashEditMode ? "편집 중" : "편집"}</span>
</button>
</>
)}
{/* Theme pill */}
<div className="v5-pill">
@@ -999,7 +1008,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{/* Content area */}
{/* ★ INVYONE 신규 페이지는 children 직접 렌더, 기존 VEX 페이지는 탭 시스템 */}
<main className="v5-content flex min-w-0 flex-1 flex-col overflow-hidden">
{pathname && (
{pathname && !isAdminMode && (
pathname.startsWith('/dashboard') ||
pathname.startsWith('/dash') ||
pathname.startsWith('/admin/builder') ||
@@ -128,6 +128,11 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
const variantStyle = VARIANT_PRESETS[variant] ?? VARIANT_PRESETS.primary;
const sizeStyle = SIZE_PRESETS[sizeKey] ?? SIZE_PRESETS.md;
// 디자인 모드에서는 wrapper 박스 크기(리사이즈한 값)를 그대로 채워야
// 디자이너 캔버스에서 박스 크기 = 시각 크기가 된다. 런타임(대시보드 카드)
// 에서는 content size(sizePreset) 로 렌더되어야 wrapper 가 박스를 키워놨
// 어도 버튼이 과도하게 팽창하지 않는다.
const fillWrapper = isDesignMode === true;
const buttonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
@@ -146,8 +151,7 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
transition: "opacity 0.1s, transform 0.05s",
userSelect: "none",
whiteSpace: "nowrap",
width: "100%",
height: "100%",
...(fillWrapper ? { width: "100%", height: "100%" } : {}),
...(component as any).style,
...style,
};
+453
View File
@@ -0,0 +1,453 @@
/**
* v1 → v2 템플릿 저장 포맷 마이그레이션
* ============================================================================
*
* DB 의 TEMPLATES.VIEWS jsonb 를 v2 스키마 (% 좌표 + role + responsivePolicy)
* 로 변환한다. 런타임에 로드 시 version 필드 확인 후 v1 이면 즉석 변환.
*
* role / responsivePolicy 추론은 마이그레이션 1회 규칙이다. 일단 현재 렌더
* 결과와 비슷하게 나오도록 좌표 기반 추론을 사용하되, v2 포맷으로 저장된
* 이후엔 디자이너가 명시 값으로 덮어쓰는 것을 전제로 한다.
*
* 설계 문서: notes/gbpark/2026-04-20-template-storage-model-v2.md
* ============================================================================
*/
import type {
BlockV2,
CanvasV2,
ResponsivePolicy,
TemplateViewsV2,
ViewV2,
BlockRole,
} from '@/types/invyone-component';
// ─────────────────────────────────────────────────────────────────────────────
// 1. v1 파싱 헬퍼
// ─────────────────────────────────────────────────────────────────────────────
/** v1 저장본의 다양한 legacy componentId 를 통합 ID 로 정규화. */
const LEGACY_TO_UNIFIED: Record<string, string> = {
'v2-divider-line': 'divider',
'divider-line': 'divider',
'v2-split-line': 'divider',
'v2-text-display': 'title',
'text-display': 'title',
'v2-button-primary': 'button',
'button-primary': 'button',
'v2-table-search-widget': 'search',
'table-search-widget': 'search',
'v2-input': 'input',
'v2-select': 'input',
'v2-date': 'input',
'text-input': 'input',
'number-input': 'input',
'date-input': 'input',
'select-basic': 'input',
'checkbox-basic': 'input',
'textarea-basic': 'input',
'slider-basic': 'input',
'radio-basic': 'input',
'toggle-switch': 'input',
'v2-aggregation-widget': 'stats',
'aggregation-widget': 'stats',
'v2-status-count': 'stats',
'v2-card-display': 'stats',
'card-display': 'stats',
'v2-table-list': 'table',
'table-list': 'table',
'v2-table-grouped': 'table',
'v2-pivot-grid': 'table',
'v2-tabs-widget': 'container',
'v2-section-card': 'container',
'v2-section-paper': 'container',
'v2-repeat-container': 'container',
'v2-repeater': 'container',
'section-card': 'container',
'section-paper': 'container',
'accordion-basic': 'container',
};
const FULL_WIDTH_IDS = new Set([
'table',
'container',
'search',
'accordion',
'tabs',
'split-panel',
]);
const MAIN_CONTENT_IDS = new Set([
...FULL_WIDTH_IDS,
'stats',
'form',
'title',
]);
function extractIdFromUrl(url?: unknown): string {
if (typeof url !== 'string' || !url) return '';
const parts = url.split('/').filter(Boolean);
return parts[parts.length - 1] ?? '';
}
function extractComponents(viewData: unknown): any[] {
if (!viewData) return [];
if (Array.isArray(viewData)) return viewData;
if (typeof viewData === 'object' && viewData !== null) {
const v = viewData as any;
if (Array.isArray(v.components)) return v.components;
if (Array.isArray(v.blocks)) return v.blocks;
}
return [];
}
function unifiedComponentId(c: any): string {
const raw = String(
c?.componentId ??
c?.componentType ??
c?.type ??
extractIdFromUrl(c?.url) ??
c?.overrides?.type ??
'',
);
return LEGACY_TO_UNIFIED[raw] ?? raw;
}
function readPos(c: any): {
left: number;
top: number;
width: number;
height: number;
} {
const p = c?.position ?? {};
const s = c?.size ?? {};
return {
left: Number(p.left ?? p.x ?? 0) || 0,
top: Number(p.top ?? p.y ?? 0) || 0,
width: Number(p.width ?? s.width ?? 200) || 200,
height: Number(p.height ?? s.height ?? 100) || 100,
};
}
function readConfig(c: any): Record<string, any> {
const a = c?.config;
if (a && typeof a === 'object' && !Array.isArray(a)) return a;
const b = c?.componentConfig;
if (b && typeof b === 'object' && !Array.isArray(b)) return b;
const d = c?.overrides;
if (d && typeof d === 'object' && !Array.isArray(d)) return d;
return {};
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. role / policy 추론 (마이그레이션 1회용)
// ─────────────────────────────────────────────────────────────────────────────
interface MainBbox {
top: number;
bottom: number;
left: number;
right: number;
}
/**
* 레거시 컴포넌트의 role 을 추론.
* - button 은 항상 action: 원본 좌표가 main band 내부에 걸쳐있어도
* "action bar" 의미로 취급한다. 이렇게 해야 TemplateRenderer 의
* content-size 정책이 버튼에 일관되게 적용된다.
* - input 은 좌표 기반 추정: main band 내부면 companion (필터 인풋 등),
* 바깥이면 action.
* - divider 는 companion.
*/
function inferRole(cid: string, pos: {
top: number;
height: number;
}, mains: MainBbox[]): BlockRole {
if (MAIN_CONTENT_IDS.has(cid)) return 'main';
if (cid === 'divider') return 'companion';
if (cid === 'button') return 'action';
if (cid === 'input') {
const centerY = pos.top + pos.height / 2;
for (const m of mains) {
if (centerY >= m.top && centerY <= m.bottom) return 'companion';
}
return 'action';
}
return 'main';
}
function inferPolicy(cid: string): ResponsivePolicy {
if (FULL_WIDTH_IDS.has(cid)) return 'scroll';
if (cid === 'stats' || cid === 'form') return 'reflow';
if (
cid === 'button' ||
cid === 'input' ||
cid === 'title' ||
cid === 'divider'
) {
return 'fixed';
}
return 'fixed';
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. 뷰 변환
// ─────────────────────────────────────────────────────────────────────────────
interface BandBbox {
id: string;
top: number;
bottom: number;
}
function migrateView(
raw: unknown,
canvas: { width: number; height: number },
): ViewV2 {
const rawBlocks = (extractComponents(raw) as any[]).filter(
(c) => c && typeof c === 'object',
);
// 1차: 정규화 정보 수집
interface Prelim {
raw: any;
i: number;
cid: string;
pos: ReturnType<typeof readPos>;
config: Record<string, any>;
role: BlockRole;
}
const mains: MainBbox[] = [];
const prelim: Prelim[] = rawBlocks.map((c, i) => {
const cid = unifiedComponentId(c);
const pos = readPos(c);
const config = readConfig(c);
// role 은 main 수집 후에 확정해야 companion 판정 가능
return { raw: c, i, cid, pos, config, role: 'main' as BlockRole };
});
// main bbox 수집 (role 추론 재료)
for (const p of prelim) {
if (MAIN_CONTENT_IDS.has(p.cid)) {
mains.push({
top: p.pos.top,
bottom: p.pos.top + p.pos.height,
left: p.pos.left,
right: p.pos.left + p.pos.width,
});
}
}
// role 확정
for (const p of prelim) {
p.role = inferRole(p.cid, p.pos, mains);
}
// 2차: main 블록 y-overlap clustering → bandId 생성
// (normalize 단계의 1회 추론. 런타임에서는 이 bandId 를 그대로 사용)
const mainPrelims = prelim
.filter((p) => p.role === 'main')
.sort((a, b) => a.pos.top - b.pos.top);
const bandIdOf = new Map<Prelim, string>();
const bandBboxes: BandBbox[] = [];
let bandIdx = 0;
for (const m of mainPrelims) {
const myTop = m.pos.top;
const myBot = m.pos.top + m.pos.height;
// 기존 band 중 세로 overlap 이 충분한 것 찾기 (작은 쪽 h 의 30% 이상)
let matched: BandBbox | null = null;
for (const bb of bandBboxes) {
const overlap =
Math.min(bb.bottom, myBot) - Math.max(bb.top, myTop);
const smallerH = Math.min(bb.bottom - bb.top, myBot - myTop);
if (smallerH > 0 && overlap / smallerH > 0.3) {
matched = bb;
break;
}
}
if (!matched) {
matched = { id: `band-${bandIdx++}`, top: myTop, bottom: myBot };
bandBboxes.push(matched);
} else {
matched.top = Math.min(matched.top, myTop);
matched.bottom = Math.max(matched.bottom, myBot);
}
bandIdOf.set(m, matched.id);
}
// 3차: companion/action/overlay 는 가장 가까운 main band 에 합류
// - centerY 가 band 내부면 그 band
// - 아니면 가장 가까운 (top/bottom 거리 최소) band
// - main band 가 전혀 없으면 bandId 비워둠 (런타임 fallback 에 위임)
for (const p of prelim) {
if (bandIdOf.has(p)) continue;
if (bandBboxes.length === 0) continue;
const centerY = p.pos.top + p.pos.height / 2;
let target: BandBbox | null = null;
let minDist = Infinity;
for (const bb of bandBboxes) {
if (centerY >= bb.top && centerY <= bb.bottom) {
target = bb;
break;
}
const dist = Math.min(
Math.abs(centerY - bb.top),
Math.abs(centerY - bb.bottom),
);
if (dist < minDist) {
minDist = dist;
target = bb;
}
}
if (target) bandIdOf.set(p, target.id);
}
// 4차: BlockV2 생성
const blocks: BlockV2[] = prelim.map((p) => {
const bandId = bandIdOf.get(p);
return {
id: String(p.raw.id ?? `blk_${p.i}`),
componentId: p.cid,
xPct: canvas.width > 0 ? p.pos.left / canvas.width : 0,
yPct: canvas.height > 0 ? p.pos.top / canvas.height : 0,
wPct: canvas.width > 0 ? p.pos.width / canvas.width : 0,
hPct: canvas.height > 0 ? p.pos.height / canvas.height : 0,
role: p.role,
responsivePolicy: inferPolicy(p.cid),
...(bandId ? { bandId } : {}),
config: p.config,
};
});
return { blocks };
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. Public API
// ─────────────────────────────────────────────────────────────────────────────
export function isV2Views(views: unknown): views is TemplateViewsV2 {
return (
!!views &&
typeof views === 'object' &&
(views as any).version === 2
);
}
/**
* 런타임 정규화.
* 이미 v2 포맷으로 저장된 stale 데이터가 최신 규칙을 반영하지 못해 화면이
* 틀어지는 문제 방지. 두 단계로 동작:
*
* [Stage 1] componentId / responsivePolicy 보정
* - LEGACY_TO_UNIFIED 로 componentId 를 통합 ID 로 교체
* - unified ID 기준으로 inferPolicy 재적용
*
* [Stage 2] button/input role 좌표 기반 재분류 (view 단위)
* - main 블록의 yPct 범위를 모은 뒤
* - button/input 의 centerY 가 어떤 main 범위 안이면 companion
* (= 같은 가로 라인에 배치), 밖이면 action (= 별도 액션 라인)
* - 디자이너가 명시 main/overlay 로 둔 경우는 그대로 보존
*
* 분류가 바뀌면 bandId 는 리셋 → 런타임 buildBands fallback 에 위임.
*/
const COORD_DEPENDENT_ROLES = new Set(['button', 'input']);
function normalizeRoles(v: TemplateViewsV2): TemplateViewsV2 {
const patchView = (view: ViewV2 | undefined): ViewV2 | undefined => {
if (!view) return view;
// Stage 1: componentId / policy 보정. role 은 일단 보존.
const stage1: BlockV2[] = view.blocks.map((b) => {
const unified = LEGACY_TO_UNIFIED[b.componentId] ?? b.componentId;
const idChanged = unified !== b.componentId;
const nextPolicy = inferPolicy(unified);
const policyChanged = b.responsivePolicy !== nextPolicy;
if (!idChanged && !policyChanged) return b;
const { bandId: _drop, ...rest } = b;
return {
...rest,
componentId: unified,
responsivePolicy: nextPolicy,
};
});
// Stage 2: 좌표 기반 role 재분류 (button/input 만).
// main 블록 y 범위 수집 — 디자이너가 명시한 role='main' 을 신뢰.
const mainRanges = stage1
.filter((b) => b.role === 'main')
.map((b) => ({ top: b.yPct, bottom: b.yPct + b.hPct }));
const stage2: BlockV2[] = stage1.map((b) => {
if (!COORD_DEPENDENT_ROLES.has(b.componentId)) return b;
if (b.role === 'main' || b.role === 'overlay') return b;
const centerY = b.yPct + b.hPct / 2;
const inMain = mainRanges.some(
(r) => centerY >= r.top && centerY <= r.bottom,
);
const nextRole: BlockRole = inMain ? 'companion' : 'action';
if (b.role === nextRole) return b;
const { bandId: _drop, ...rest } = b;
return { ...rest, role: nextRole };
});
return { ...view, blocks: stage2 };
};
return {
...v,
list: patchView(v.list)!,
...(v.create ? { create: patchView(v.create)! } : {}),
...(v.edit ? { edit: patchView(v.edit)! } : {}),
};
}
export function migrateV1toV2(v1Views: unknown): TemplateViewsV2 {
const root = (v1Views ?? {}) as any;
const sr = root.screenResolution ?? root.designSize ?? {};
const canvas: CanvasV2 = {
baseWidth: Number(sr.width) || 1920,
baseHeight: Number(sr.height) || 1080,
aspectPolicy: 'preserve',
};
const intCanvas = { width: canvas.baseWidth, height: canvas.baseHeight };
const list = migrateView(root.list, intCanvas);
const create = root.create ? migrateView(root.create, intCanvas) : undefined;
const edit = root.edit ? migrateView(root.edit, intCanvas) : undefined;
return {
version: 2,
canvas,
list,
...(create ? { create } : {}),
...(edit ? { edit } : {}),
};
}
/**
* 로드 시점 정규화 헬퍼 — v1 이든 v2 이든 받아서 항상 v2 로 반환.
* 기존 v2 저장본이라도 현재 role 규칙(button→action 등)에 맞춰 재정규화한다.
*/
export function ensureV2Views(views: unknown): TemplateViewsV2 {
const v2 = isV2Views(views) ? views : migrateV1toV2(views);
return normalizeRoles(v2);
}
/**
* template 객체의 views 필드를 v2 로 정규화한 사본을 반환.
* template 자체는 변형하지 않는다.
*/
export function ensureTemplateV2(template: any): any {
if (!template) return template;
const views = template.views ?? template.VIEWS;
if (!views) return template;
const v2 = ensureV2Views(views);
return { ...template, views: v2 };
}
+115
View File
@@ -907,6 +907,121 @@ export interface CardConnection {
to: { cardId: string; port: string };
}
// ─────────────────────────────────────────────────────────────────────────────
// 7. 템플릿 저장 모델 v2 — % 좌표 + semantic role (2026-04-20)
// ─────────────────────────────────────────────────────────────────────────────
//
// 설계: notes/gbpark/2026-04-20-template-storage-model-v2.md
//
// 목표: 런타임 추측 제거. 디자이너가 의도(role, responsivePolicy)를 명시하고
// 런타임은 그대로 렌더한다. v1 의 FIXED_SMALL_IDS/FULL_WIDTH_IDS 하드코딩,
// centerY overlap 판정, top tolerance clustering, action line clamp 등
// 좌표 기반 추론 전부 폐기 대상.
//
// DB 저장 경로는 기존과 동일 (TEMPLATES.VIEWS jsonb). views 최상위에
// `version: 2` 가 있으면 v2 포맷, 없으면 v1 로 해석해서 런타임에 migrate.
// ─────────────────────────────────────────────────────────────────────────────
/**
* 블록의 의미상 역할 — 런타임 렌더 전략을 결정한다.
* 좌표나 크기로 추측하지 않고 디자이너가 명시.
*/
export type BlockRole =
| 'main' // 페이지의 주 콘텐츠 (카드/테이블/폼)
| 'action' // main band 앞/뒤 별도 액션 라인 (저장/수정 바 등)
| 'companion' // main 과 같은 라인에 가로로 공존 (인디케이터, 뱃지 등)
| 'overlay'; // main 위에 absolute 로 띄우는 floating 요소
/**
* 카드(컨테이너) 폭이 변할 때 블록이 어떻게 반응할지 — 디자이너가 명시.
* v1 의 `ResponsiveConfig.mode` 와 동의어지만, v2 에서는 enum 단일 필드.
*/
export type ResponsivePolicy =
| 'fixed' // 원본 크기 유지 (단일 버튼/입력/타이틀/구분선)
| 'scroll' // 내부 overflow (테이블/컨테이너)
| 'reflow' // auto-fit grid (stats 카드 그룹)
| 'wrap'; // flex-wrap (버튼 그룹)
/**
* fixed / overlay 모드에서 축소 시 앵커 지점.
* 9분할 기준 (좌/중/우 × 상/중/하).
*/
export type BlockAnchor =
| 'top-left' | 'top-center' | 'top-right'
| 'center-left' | 'center' | 'center-right'
| 'bottom-left' | 'bottom-center' | 'bottom-right';
/**
* v2 블록 — 한 뷰 안에 배치된 컴포넌트 인스턴스.
*
* 좌표는 정규화된 % 값 (0~1, 기준 캔버스 대비). 디자이너는 px 로 편집하고
* 저장/로드 경계에서만 정규화/역정규화한다.
*/
export interface BlockV2 {
/** 인스턴스 ID */
id: string;
/** 컴포넌트 종류 — 통합 ID (table, stats, button, …) */
componentId: string;
// ─── 좌표 (기준 캔버스 대비 0.0 ~ 1.0) ───
xPct: number;
yPct: number;
wPct: number;
hPct: number;
// ─── 디자이너 의도 ───
/** 의미상 역할 — 런타임 렌더 전략 결정 */
role: BlockRole;
/** 반응 정책 */
responsivePolicy: ResponsivePolicy;
/** 앵커 (선택 — 미지정 시 fixed 모드 기본값) */
anchor?: BlockAnchor;
/** 소속 band ID (선택 — companion/action 이 어느 main band 에 붙는지 명시) */
bandId?: string;
/** 컴포넌트 타입별 설정 */
config: Record<string, any>;
}
/**
* v2 캔버스 메타 — 정규화 기준 + 축소 정책.
*/
export interface CanvasV2 {
/** 정규화 기준 폭 (저장 시의 디자이너 캔버스 폭) */
baseWidth: number;
/** 정규화 기준 높이 */
baseHeight: number;
/**
* 카드 폭이 canvas 폭보다 좁아질 때 세로 축소 정책.
* - 'preserve': 세로도 비례 축소 (aspect ratio 유지)
* - 'free' : 세로는 content 기반, 가로만 비례 축소
*/
aspectPolicy: 'preserve' | 'free';
}
/**
* v2 뷰 — 한 뷰(list/create/edit) 의 블록 모음.
*/
export interface ViewV2 {
blocks: BlockV2[];
}
/**
* v2 템플릿 views 최상위 — DB 의 TEMPLATES.VIEWS jsonb 포맷.
* version 필드로 v1 과 구분한다.
*/
export interface TemplateViewsV2 {
version: 2;
canvas: CanvasV2;
list: ViewV2;
create?: ViewV2;
edit?: ViewV2;
}
// ─────────────────────────────────────────────────────────────────────────────
// 7 (cont.) — 기존 Dashboard 타입 (v1)
// ─────────────────────────────────────────────────────────────────────────────
/**
* 사이드바 메뉴 항목 = 카드 컬렉션.
*
@@ -0,0 +1,859 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>INVYONE 화면 디자이너 — 섹션 기반 재설계 (v5)</title>
<style>
:root {
--v5-primary-rgb:162,155,254;
--v5-cyan-rgb:85,239,196;
--v5-pink-rgb:253,121,168;
--v5-red-rgb:255,107,107;
--v5-amber-rgb:255,234,167;
--v5-bg:#06050e; --v5-bg-subtle:#0c0b18;
--v5-surface:rgba(17,16,42,0.5); --v5-surface-solid:#11102a;
--v5-surface-hover:rgba(25,24,64,0.6);
--v5-text:#eae8f4; --v5-text-sec:#8d8ba8; --v5-text-muted:#5a587a;
--v5-primary:rgb(var(--v5-primary-rgb)); --v5-primary-light:#c8c4ff;
--v5-primary-glow:rgba(var(--v5-primary-rgb),0.25);
--v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.15);
--v5-pink:rgb(var(--v5-pink-rgb));
--v5-red:rgb(var(--v5-red-rgb)); --v5-amber:rgb(var(--v5-amber-rgb));
--v5-border:rgba(var(--v5-primary-rgb),0.1);
--v5-border-subtle:rgba(255,255,255,0.04);
--v5-glass:rgba(17,16,42,0.45);
--v5-glass-strong:rgba(17,16,42,0.65);
--v5-glass-border:rgba(var(--v5-primary-rgb),0.12);
--v5-glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.1);
--v5-glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.18);
--v5-glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.22);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
font-family: -apple-system, "Segoe UI", "Pretendard", sans-serif;
font-size: 12px;
color: var(--v5-text);
background: var(--v5-bg);
overflow: hidden;
}
/* ===== 우주 배경 ===== */
.cosmic-bg {
position: fixed; inset: 0; z-index: -1; overflow: hidden;
background: radial-gradient(ellipse at 20% 30%, rgba(108,92,231,0.08) 0%, transparent 40%),
radial-gradient(ellipse at 80% 70%, rgba(0,206,201,0.06) 0%, transparent 40%),
var(--v5-bg);
}
.cosmic-bg::before, .cosmic-bg::after {
content: ""; position: absolute; inset: 0;
background-image:
radial-gradient(1px 1px at 10% 20%, rgba(255,255,255,0.4), transparent),
radial-gradient(1px 1px at 30% 70%, rgba(255,255,255,0.3), transparent),
radial-gradient(1px 1px at 60% 40%, rgba(255,255,255,0.35), transparent),
radial-gradient(1px 1px at 80% 80%, rgba(255,255,255,0.3), transparent),
radial-gradient(1.5px 1.5px at 50% 15%, rgba(162,155,254,0.5), transparent),
radial-gradient(1.5px 1.5px at 90% 30%, rgba(85,239,196,0.4), transparent);
background-size: 800px 800px;
animation: starDrift 60s linear infinite;
}
.cosmic-bg::after { background-size: 400px 400px; opacity: 0.4; animation-duration: 40s; animation-direction: reverse; }
@keyframes starDrift { from{transform:translate(0,0)} to{transform:translate(-800px,-800px)} }
/* ===== 전체 레이아웃 ===== */
.app {
display: grid;
grid-template-rows: 42px 1fr 28px;
grid-template-columns: 240px 1fr 280px;
grid-template-areas:
"header header header"
"palette canvas props"
"footer footer footer";
height: 100vh; width: 100vw;
}
/* ===== 상단 헤더 ===== */
.header {
grid-area: header;
display: flex; align-items: center; gap: 12px;
padding: 0 14px;
background: var(--v5-glass);
backdrop-filter: blur(20px) saturate(1.4);
border-bottom: 1px solid var(--v5-glass-border);
box-shadow: var(--v5-glow-sm);
}
.logo {
font-size: 13px; font-weight: 700; letter-spacing: 0.5px;
background: linear-gradient(135deg, var(--v5-primary-light), var(--v5-cyan));
-webkit-background-clip: text; background-clip: text;
color: transparent;
}
.header .divider { width: 1px; height: 18px; background: var(--v5-border); }
.breadcrumb {
display: flex; align-items: center; gap: 6px;
color: var(--v5-text-sec); font-size: 11px;
}
.breadcrumb .crumb.active { color: var(--v5-text); }
.breadcrumb .sep { color: var(--v5-text-muted); }
.header .spacer { flex: 1; }
.header-actions { display: flex; gap: 6px; }
.v5-btn {
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 10px;
background: transparent;
border: 1px solid var(--v5-border);
border-radius: 5px;
color: var(--v5-text);
font-size: 11px; font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.v5-btn:hover {
background: var(--v5-surface-hover);
border-color: rgba(var(--v5-primary-rgb), 0.3);
box-shadow: var(--v5-glow-sm);
}
.v5-btn.primary {
background: linear-gradient(135deg, var(--v5-primary), #7268e8);
border-color: transparent;
color: white;
box-shadow: 0 2px 8px rgba(var(--v5-primary-rgb),0.3);
}
.v5-btn.primary:hover { box-shadow: 0 4px 16px rgba(var(--v5-primary-rgb),0.5); }
.v5-btn.ghost { border-color: transparent; color: var(--v5-text-sec); }
.v5-btn.icon { width: 26px; padding: 0; justify-content: center; }
/* ===== 좌측 팔레트 ===== */
.palette {
grid-area: palette;
background: var(--v5-glass);
backdrop-filter: blur(20px);
border-right: 1px solid var(--v5-glass-border);
padding: 10px;
overflow-y: auto;
}
.panel-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
color: var(--v5-text-muted);
letter-spacing: 0.6px;
margin: 10px 0 6px;
padding-left: 4px;
}
.panel-title:first-child { margin-top: 0; }
.palette-group { margin-bottom: 8px; }
.palette-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 9px;
margin-bottom: 3px;
background: var(--v5-surface);
border: 1px solid var(--v5-border-subtle);
border-radius: 6px;
cursor: grab;
transition: all 0.15s ease;
font-size: 11px;
}
.palette-item:hover {
background: var(--v5-surface-hover);
border-color: rgba(var(--v5-primary-rgb), 0.3);
transform: translateX(2px);
}
.palette-item:active { cursor: grabbing; }
.palette-item .ico {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
background: rgba(var(--v5-primary-rgb), 0.15);
color: var(--v5-primary-light);
font-size: 12px;
}
.palette-item.section .ico { background: rgba(var(--v5-cyan-rgb), 0.15); color: var(--v5-cyan); }
.palette-item .meta { flex: 1; }
.palette-item .name { color: var(--v5-text); font-weight: 500; }
.palette-item .desc { color: var(--v5-text-muted); font-size: 10px; margin-top: 1px; }
/* ===== 중앙 캔버스 ===== */
.canvas-wrap {
grid-area: canvas;
display: flex; flex-direction: column;
position: relative;
overflow: hidden;
}
.canvas-toolbar {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px;
background: var(--v5-glass);
border-bottom: 1px solid var(--v5-glass-border);
}
.viewport-switch {
display: flex; gap: 2px;
background: var(--v5-surface);
padding: 3px;
border-radius: 6px;
border: 1px solid var(--v5-border);
}
.viewport-switch button {
height: 22px; padding: 0 10px;
background: transparent; border: none;
color: var(--v5-text-sec);
font-size: 10px; font-weight: 500;
border-radius: 4px; cursor: pointer;
transition: all 0.15s;
}
.viewport-switch button.active {
background: var(--v5-primary);
color: white;
box-shadow: var(--v5-glow-sm);
}
.size-label {
font-size: 10px; color: var(--v5-text-sec);
font-variant-numeric: tabular-nums;
padding: 3px 8px;
background: var(--v5-surface);
border: 1px solid var(--v5-border);
border-radius: 4px;
}
.size-label b { color: var(--v5-primary-light); font-weight: 600; }
.mode-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px;
background: rgba(var(--v5-cyan-rgb), 0.1);
border: 1px solid rgba(var(--v5-cyan-rgb), 0.3);
border-radius: 4px;
font-size: 10px; color: var(--v5-cyan);
font-weight: 600; letter-spacing: 0.3px;
}
.mode-badge .dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--v5-cyan);
box-shadow: 0 0 6px var(--v5-cyan);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
.canvas {
flex: 1;
position: relative;
overflow: auto;
padding: 30px;
background:
radial-gradient(circle at 50% 50%, rgba(var(--v5-primary-rgb), 0.03) 0%, transparent 60%);
}
/* ===== 캔버스 내 디자인 영역 (컨테이너) ===== */
.design-container {
margin: 0 auto;
background: var(--v5-surface-solid);
border: 1px solid var(--v5-glass-border);
border-radius: 10px;
min-height: 600px;
box-shadow: var(--v5-glow-md);
container-type: inline-size;
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
width: 1200px;
overflow: hidden;
position: relative;
}
.design-container[data-size="desktop"] { width: 1200px; }
.design-container[data-size="quad"] { width: 600px; }
.design-container[data-size="hex"] { width: 400px; }
.design-container[data-size="oct"] { width: 300px; }
/* 디자인 컨테이너 헤더 (가짜 브라우저 바) */
.container-chrome {
height: 22px;
background: linear-gradient(180deg, rgba(var(--v5-primary-rgb),0.06), transparent);
border-bottom: 1px solid var(--v5-border-subtle);
display: flex; align-items: center;
padding: 0 10px;
gap: 5px;
}
.container-chrome .cdot { width: 7px; height: 7px; border-radius: 50%; background: var(--v5-text-muted); opacity: 0.4; }
.container-chrome .url {
margin-left: 8px; font-size: 9px; color: var(--v5-text-muted);
font-family: "SF Mono", Monaco, monospace;
}
/* ===== 섹션 ===== */
.section {
margin: 14px;
padding: 2px;
border: 1.5px dashed rgba(var(--v5-primary-rgb), 0.25);
border-radius: 8px;
position: relative;
transition: all 0.2s;
}
.section:hover {
border-color: rgba(var(--v5-primary-rgb), 0.5);
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.08);
}
.section.selected {
border-style: solid;
border-color: var(--v5-primary);
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.18);
}
.section-label {
position: absolute;
top: -10px; left: 10px;
background: var(--v5-surface-solid);
border: 1px solid var(--v5-glass-border);
border-radius: 4px;
padding: 2px 7px;
font-size: 9px; font-weight: 700;
color: var(--v5-primary-light);
letter-spacing: 0.4px;
display: flex; align-items: center; gap: 5px;
z-index: 2;
}
.section-label .badge {
background: rgba(var(--v5-cyan-rgb), 0.15);
color: var(--v5-cyan);
padding: 1px 5px;
border-radius: 2px;
font-size: 8px;
}
.section-inner {
padding: 10px;
min-height: 40px;
}
/* ===== Row (섹션 안의 가로 배치) ===== */
.row {
display: flex;
gap: 10px;
margin-bottom: 8px;
min-height: 36px;
padding: 4px;
border-radius: 6px;
position: relative;
transition: background 0.15s;
}
.row:last-child { margin-bottom: 0; }
.row:hover {
background: rgba(var(--v5-cyan-rgb), 0.04);
outline: 1px dashed rgba(var(--v5-cyan-rgb), 0.3);
outline-offset: -2px;
}
.row-handle {
position: absolute;
left: -16px; top: 50%;
transform: translateY(-50%);
width: 12px; height: 22px;
background: var(--v5-surface-solid);
border: 1px solid var(--v5-border);
border-radius: 3px;
display: none;
align-items: center; justify-content: center;
color: var(--v5-text-muted);
font-size: 8px;
cursor: grab;
}
.row:hover .row-handle { display: flex; }
/* ===== 위젯 ===== */
.widget {
flex: 1 1 0;
min-height: 30px;
padding: 10px 12px;
background: rgba(var(--v5-primary-rgb), 0.05);
border: 1px solid rgba(var(--v5-primary-rgb), 0.15);
border-radius: 6px;
color: var(--v5-text);
font-size: 11px;
display: flex; flex-direction: column; gap: 4px;
position: relative;
transition: all 0.15s;
cursor: pointer;
}
.widget:hover {
background: rgba(var(--v5-primary-rgb), 0.1);
border-color: rgba(var(--v5-primary-rgb), 0.4);
}
.widget.selected {
border-color: var(--v5-primary);
background: rgba(var(--v5-primary-rgb), 0.15);
box-shadow: 0 0 0 2px rgba(var(--v5-primary-rgb), 0.2);
}
.widget .wtype {
font-size: 8.5px; color: var(--v5-text-muted);
text-transform: uppercase; letter-spacing: 0.6px;
font-weight: 700;
}
.widget .wlabel { color: var(--v5-text); font-weight: 500; font-size: 11px; }
/* 위젯 타입별 색 */
.widget[data-type="title"] { background: rgba(var(--v5-pink-rgb), 0.05); border-color: rgba(var(--v5-pink-rgb), 0.2); }
.widget[data-type="title"] .wtype { color: var(--v5-pink); }
.widget[data-type="title"] .wlabel { font-size: 15px; font-weight: 700; }
.widget[data-type="button"] {
background: linear-gradient(135deg, var(--v5-primary), #7268e8);
border-color: transparent; color: white;
flex: 0 0 auto; min-width: 70px; align-items: center; justify-content: center;
padding: 8px 14px;
}
.widget[data-type="button"] .wtype { color: rgba(255,255,255,0.6); }
.widget[data-type="button"] .wlabel { color: white; font-size: 11px; }
.widget[data-type="stats"] {
background: linear-gradient(135deg, rgba(var(--v5-cyan-rgb), 0.08), rgba(var(--v5-primary-rgb), 0.05));
border-color: rgba(var(--v5-cyan-rgb), 0.2);
min-height: 60px;
}
.widget[data-type="stats"] .wtype { color: var(--v5-cyan); }
.widget[data-type="stats"] .stat-val {
font-size: 18px; font-weight: 700; color: var(--v5-text);
font-variant-numeric: tabular-nums;
margin-top: 2px;
}
.widget[data-type="table"] { min-height: 140px; padding: 8px; }
.widget[data-type="table"] .wtype { color: var(--v5-amber); }
.widget[data-type="table"] .tbl-rows {
margin-top: 6px;
display: flex; flex-direction: column; gap: 2px;
}
.widget[data-type="table"] .tbl-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 60px;
gap: 6px;
padding: 4px 6px;
font-size: 10px;
color: var(--v5-text-sec);
border-bottom: 1px solid var(--v5-border-subtle);
}
.widget[data-type="table"] .tbl-row.head { color: var(--v5-text-muted); font-weight: 700; font-size: 9px; letter-spacing: 0.4px; }
.widget[data-type="search"] { background: rgba(var(--v5-amber-rgb), 0.04); border-color: rgba(var(--v5-amber-rgb), 0.2); }
.widget[data-type="search"] .wtype { color: var(--v5-amber); }
.widget[data-type="input"] { background: rgba(255,255,255,0.02); border-color: var(--v5-border); }
/* FULL_WIDTH 마커 */
.row.full-width {
outline: 1px solid rgba(var(--v5-amber-rgb), 0.25);
outline-offset: -2px;
}
/* ===== 컨테이너 쿼리 — 반응 정책의 핵심 ===== */
/* wide: 1200px 기본 배치 */
/* normal ~900px */
@container (max-width: 900px) {
.row[data-policy="stats"] { flex-wrap: wrap; }
.row[data-policy="stats"] .widget { flex: 1 1 calc(50% - 5px); }
.widget[data-type="table"] .tbl-row { grid-template-columns: 1fr 1fr 50px; }
.widget[data-type="table"] .tbl-row .col-3 { display: none; }
}
/* narrow ~560px */
@container (max-width: 560px) {
.row[data-policy="stats"] .widget { flex: 1 1 100%; }
.row[data-policy="header"] { flex-wrap: wrap; }
.row[data-policy="header"] .widget[data-type="button"] { flex: 1 1 100%; }
.widget[data-type="table"] .tbl-row { grid-template-columns: 1fr 60px; }
.widget[data-type="table"] .tbl-row .col-2 { display: none; }
.widget[data-type="button"] .wlabel { font-size: 10px; }
.widget[data-type="button"] { padding: 6px 10px; }
.section-inner { padding: 6px; }
.section { margin: 8px; }
}
/* tiny ~360px */
@container (max-width: 360px) {
.widget[data-type="stats"] { min-height: 44px; padding: 8px; }
.widget[data-type="stats"] .stat-val { font-size: 14px; }
.widget[data-type="table"] { min-height: 90px; }
.widget[data-type="table"] .tbl-row { font-size: 9px; padding: 3px 4px; }
.widget[data-type="title"] .wlabel { font-size: 13px; }
.widget .wtype { display: none; }
.section-label { font-size: 8px; padding: 1px 5px; }
}
/* ===== 우측 속성 패널 ===== */
.props {
grid-area: props;
background: var(--v5-glass);
backdrop-filter: blur(20px);
border-left: 1px solid var(--v5-glass-border);
padding: 12px;
overflow-y: auto;
}
.props-head {
display: flex; align-items: center; justify-content: space-between;
padding-bottom: 8px;
border-bottom: 1px solid var(--v5-border-subtle);
margin-bottom: 10px;
}
.props-head .title { font-size: 11px; font-weight: 700; color: var(--v5-text); }
.props-head .target {
font-size: 9px; color: var(--v5-cyan);
background: rgba(var(--v5-cyan-rgb), 0.1);
padding: 2px 6px; border-radius: 3px;
letter-spacing: 0.4px;
}
.prop-field { margin-bottom: 10px; }
.prop-field label {
display: block;
font-size: 9.5px; color: var(--v5-text-sec);
font-weight: 600; letter-spacing: 0.3px;
margin-bottom: 3px;
text-transform: uppercase;
}
.prop-field input, .prop-field select {
width: 100%;
height: 26px;
padding: 0 8px;
background: var(--v5-surface);
border: 1px solid var(--v5-border);
border-radius: 4px;
color: var(--v5-text);
font-size: 11px;
outline: none;
}
.prop-field input:focus, .prop-field select:focus {
border-color: var(--v5-primary);
box-shadow: var(--v5-glow-sm);
}
.prop-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
font-size: 9.5px; padding: 3px 7px;
background: var(--v5-surface);
border: 1px solid var(--v5-border);
border-radius: 3px;
color: var(--v5-text-sec);
cursor: pointer;
transition: all 0.15s;
}
.chip:hover { border-color: rgba(var(--v5-primary-rgb), 0.4); color: var(--v5-text); }
.chip.active {
background: var(--v5-primary);
color: white; border-color: transparent;
box-shadow: var(--v5-glow-sm);
}
/* 반응 정책 UI */
.policy-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 6px; margin-top: 4px;
}
.policy-card {
padding: 7px 8px;
background: var(--v5-surface);
border: 1px solid var(--v5-border);
border-radius: 5px;
font-size: 10px;
}
.policy-card .mname {
font-size: 9px; color: var(--v5-primary-light);
font-weight: 700; text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 2px;
}
.policy-card .mdesc { color: var(--v5-text-sec); font-size: 10px; line-height: 1.3; }
.hint-box {
padding: 8px 10px;
background: rgba(var(--v5-cyan-rgb), 0.05);
border: 1px solid rgba(var(--v5-cyan-rgb), 0.2);
border-radius: 5px;
font-size: 10px; color: var(--v5-cyan);
line-height: 1.4;
margin-top: 8px;
}
.hint-box b { color: var(--v5-text); }
/* ===== 하단 상태 바 ===== */
.footer {
grid-area: footer;
display: flex; align-items: center; justify-content: space-between;
padding: 0 14px;
background: var(--v5-glass);
border-top: 1px solid var(--v5-glass-border);
font-size: 10px; color: var(--v5-text-muted);
font-variant-numeric: tabular-nums;
}
.footer-left, .footer-right { display: flex; gap: 14px; align-items: center; }
.status { display: flex; align-items: center; gap: 4px; }
.status .sdot { width: 5px; height: 5px; border-radius: 50%; background: var(--v5-cyan); box-shadow: 0 0 4px var(--v5-cyan); }
/* ===== 드래그 커서 / 선택 링 ===== */
.ring-primary { outline: 2px solid var(--v5-primary); outline-offset: 1px; border-radius: 6px; }
/* 스크롤바 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(var(--v5-primary-rgb), 0.2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(var(--v5-primary-rgb), 0.4); }
</style>
</head>
<body>
<div class="cosmic-bg"></div>
<div class="app">
<!-- ======== HEADER ======== -->
<div class="header">
<div class="logo">INVYONE STUDIO</div>
<div class="divider"></div>
<div class="breadcrumb">
<span class="crumb">템플릿</span>
<span class="sep"></span>
<span class="crumb">주문 관리</span>
<span class="sep"></span>
<span class="crumb active">주문 대시보드 v2</span>
</div>
<div class="spacer"></div>
<div class="header-actions">
<button class="v5-btn ghost"><span></span><span>실행 취소</span></button>
<button class="v5-btn ghost"><span></span><span>다시 실행</span></button>
<button class="v5-btn"><span>👁</span><span>미리보기</span></button>
<button class="v5-btn primary"><span>💾</span><span>저장</span></button>
</div>
</div>
<!-- ======== LEFT PALETTE ======== -->
<div class="palette">
<div class="panel-title">구조 (섹션/Row)</div>
<div class="palette-group">
<div class="palette-item section"><div class="ico"></div><div class="meta"><div class="name">섹션</div><div class="desc">반응 그룹 단위</div></div></div>
<div class="palette-item section"><div class="ico"></div><div class="meta"><div class="name">Row</div><div class="desc">가로 배치 줄</div></div></div>
<div class="palette-item section"><div class="ico"></div><div class="meta"><div class="name">스페이서</div><div class="desc">여백</div></div></div>
</div>
<div class="panel-title">기본 컴포넌트</div>
<div class="palette-group">
<div class="palette-item"><div class="ico">T</div><div class="meta"><div class="name">제목</div><div class="desc">Title / Heading</div></div></div>
<div class="palette-item"><div class="ico"></div><div class="meta"><div class="name">버튼</div><div class="desc">Action Button</div></div></div>
<div class="palette-item"><div class="ico"></div><div class="meta"><div class="name">입력</div><div class="desc">Input / Select</div></div></div>
<div class="palette-item"><div class="ico">🔍</div><div class="meta"><div class="name">검색</div><div class="desc">Search Bar</div></div></div>
</div>
<div class="panel-title">데이터</div>
<div class="palette-group">
<div class="palette-item"><div class="ico">📊</div><div class="meta"><div class="name">통계 카드</div><div class="desc">KPI / Stats</div></div></div>
<div class="palette-item"><div class="ico"></div><div class="meta"><div class="name">테이블</div><div class="desc">Data Grid</div></div></div>
<div class="palette-item"><div class="ico">📈</div><div class="meta"><div class="name">차트</div><div class="desc">Line / Bar</div></div></div>
<div class="palette-item"><div class="ico"></div><div class="meta"><div class="name"></div><div class="desc">Form</div></div></div>
</div>
<div class="panel-title">컨테이너</div>
<div class="palette-group">
<div class="palette-item"><div class="ico"></div><div class="meta"><div class="name"></div><div class="desc">Tabs</div></div></div>
<div class="palette-item"><div class="ico"></div><div class="meta"><div class="name">카드</div><div class="desc">Card / Paper</div></div></div>
</div>
</div>
<!-- ======== CENTER CANVAS ======== -->
<div class="canvas-wrap">
<div class="canvas-toolbar">
<div class="viewport-switch" id="sizeSwitch">
<button data-size="desktop">🖥 데스크탑</button>
<button class="active" data-size="quad">▦ 4분할</button>
<button data-size="hex">▦ 6분할</button>
<button data-size="oct">▦ 8분할</button>
</div>
<div class="size-label">카드 폭 <b id="sizeW">600</b>px</div>
<div class="mode-badge"><span class="dot"></span><span id="modeLabel">NORMAL</span></div>
<div style="flex:1"></div>
<button class="v5-btn ghost">⊞ 격자</button>
<button class="v5-btn ghost">⊙ 100%</button>
</div>
<div class="canvas">
<div class="design-container" data-size="quad" id="designer">
<div class="container-chrome">
<span class="cdot"></span><span class="cdot"></span><span class="cdot"></span>
<span class="url">/dashboard/orders (카드 슬롯)</span>
</div>
<!-- ===== 섹션 1: 헤더 ===== -->
<div class="section selected">
<div class="section-label">SECTION · HEADER <span class="badge">header</span></div>
<div class="section-inner">
<div class="row" data-policy="header">
<span class="row-handle">⋮⋮</span>
<div class="widget" data-type="title" style="flex:1">
<div class="wtype">Title</div>
<div class="wlabel">주문 관리 대시보드</div>
</div>
<div class="widget" data-type="button"><div class="wtype">Button</div><div class="wlabel">+ 신규</div></div>
<div class="widget" data-type="button" style="background:linear-gradient(135deg,var(--v5-cyan),#4dd4cc)"><div class="wtype">Button</div><div class="wlabel">↓ 내보내기</div></div>
</div>
</div>
</div>
<!-- ===== 섹션 2: 검색 ===== -->
<div class="section">
<div class="section-label">SECTION · SEARCH <span class="badge">filter</span></div>
<div class="section-inner">
<div class="row" data-policy="search">
<span class="row-handle">⋮⋮</span>
<div class="widget" data-type="input"><div class="wtype">Date</div><div class="wlabel">📅 시작일</div></div>
<div class="widget" data-type="input"><div class="wtype">Date</div><div class="wlabel">📅 종료일</div></div>
<div class="widget" data-type="input"><div class="wtype">Select</div><div class="wlabel">▾ 상태</div></div>
<div class="widget" data-type="search" style="flex:2"><div class="wtype">Search</div><div class="wlabel">🔍 주문번호 / 고객명 검색…</div></div>
</div>
</div>
</div>
<!-- ===== 섹션 3: 통계 ===== -->
<div class="section">
<div class="section-label">SECTION · STATS <span class="badge">stats · reflow</span></div>
<div class="section-inner">
<div class="row" data-policy="stats">
<span class="row-handle">⋮⋮</span>
<div class="widget" data-type="stats"><div class="wtype">Stat</div><div class="wlabel">오늘 주문</div><div class="stat-val">124</div></div>
<div class="widget" data-type="stats"><div class="wtype">Stat</div><div class="wlabel">처리 대기</div><div class="stat-val" style="color:var(--v5-amber)">38</div></div>
<div class="widget" data-type="stats"><div class="wtype">Stat</div><div class="wlabel">매출</div><div class="stat-val" style="color:var(--v5-cyan)">₩8.4M</div></div>
<div class="widget" data-type="stats"><div class="wtype">Stat</div><div class="wlabel">반품</div><div class="stat-val" style="color:var(--v5-red)">3</div></div>
</div>
</div>
</div>
<!-- ===== 섹션 4: 테이블 (FULL_WIDTH) ===== -->
<div class="section">
<div class="section-label">SECTION · TABLE <span class="badge">full-width</span></div>
<div class="section-inner">
<div class="row full-width" data-policy="full">
<span class="row-handle">⋮⋮</span>
<div class="widget" data-type="table" style="flex:1">
<div class="wtype">Table</div>
<div class="wlabel">주문 목록</div>
<div class="tbl-rows">
<div class="tbl-row head"><span class="col-1">주문번호</span><span class="col-2">고객</span><span class="col-3">날짜</span><span>금액</span></div>
<div class="tbl-row"><span class="col-1">ORD-2938</span><span class="col-2">김유진</span><span class="col-3">04/19</span><span>₩128,000</span></div>
<div class="tbl-row"><span class="col-1">ORD-2937</span><span class="col-2">박철수</span><span class="col-3">04/19</span><span>₩84,500</span></div>
<div class="tbl-row"><span class="col-1">ORD-2936</span><span class="col-2">이지은</span><span class="col-3">04/18</span><span>₩256,000</span></div>
<div class="tbl-row"><span class="col-1">ORD-2935</span><span class="col-2">정민호</span><span class="col-3">04/18</span><span>₩49,900</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ======== RIGHT PROPS ======== -->
<div class="props">
<div class="props-head">
<div class="title">속성</div>
<div class="target">SECTION · HEADER</div>
</div>
<div class="prop-field">
<label>섹션 이름</label>
<input type="text" value="Header">
</div>
<div class="prop-field">
<label>반응 정책 (Policy)</label>
<select>
<option>header — 버튼 → 세로 wrap</option>
<option>stats — 4→2→1 reflow</option>
<option>search — 필드 wrap</option>
<option>full-width — 단독 행 강제</option>
<option>none — 반응 없음</option>
</select>
</div>
<div class="prop-field">
<label>최소 표시 모드</label>
<div class="prop-chips">
<span class="chip active">wide</span>
<span class="chip active">normal</span>
<span class="chip active">narrow</span>
<span class="chip">tiny</span>
</div>
</div>
<div class="panel-title" style="margin-top:14px">반응 브레이크</div>
<div class="policy-grid">
<div class="policy-card">
<div class="mname" style="color:var(--v5-cyan)">WIDE</div>
<div class="mdesc">≥ 900px<br>가로 4열</div>
</div>
<div class="policy-card">
<div class="mname">NORMAL</div>
<div class="mdesc">560900px<br>가로 2열</div>
</div>
<div class="policy-card">
<div class="mname" style="color:var(--v5-pink)">NARROW</div>
<div class="mdesc">360560px<br>세로 스택</div>
</div>
<div class="policy-card">
<div class="mname" style="color:var(--v5-amber)">TINY</div>
<div class="mdesc">&lt; 360px<br>핵심만 유지</div>
</div>
</div>
<div class="hint-box">
<b>💡 섹션 기반 구조</b><br>
절대좌표가 아닌 <b>섹션 → Row → 위젯</b> 구조로 저장됩니다. 대시보드 소환 시 카드 크기에 맞춰 @container 쿼리로 자동 반응합니다.
</div>
<div class="panel-title" style="margin-top:12px">저장 포맷 미리보기</div>
<pre style="background:var(--v5-surface);border:1px solid var(--v5-border);border-radius:4px;padding:8px;font-size:9px;color:var(--v5-text-sec);font-family:'SF Mono',monospace;line-height:1.5;overflow-x:auto">{
"sections": [
{"id": "hdr",
"policy": "header",
"rows": [
{"items": [
{"type":"title"},
{"type":"button"},
{"type":"button"}
]}
]},
{"id": "stats",
"policy": "stats",
"rows": [...]}
]
}</pre>
</div>
<!-- ======== FOOTER ======== -->
<div class="footer">
<div class="footer-left">
<span class="status"><span class="sdot"></span>저장됨</span>
<span>섹션 4 · Row 4 · 위젯 13</span>
<span>포맷 v2</span>
</div>
<div class="footer-right">
<span>변경 12분 전</span>
<span>gbpark</span>
</div>
</div>
</div>
<script>
// 뷰포트 스위치 → 컨테이너 폭 변경 → @container 로 자동 반응
const designer = document.getElementById('designer');
const switchEl = document.getElementById('sizeSwitch');
const sizeW = document.getElementById('sizeW');
const modeLabel = document.getElementById('modeLabel');
const SIZES = { desktop: 1200, quad: 600, hex: 400, oct: 300 };
function setSize(name) {
designer.dataset.size = name;
sizeW.textContent = SIZES[name];
const w = SIZES[name];
let mode = 'WIDE';
if (w < 360) mode = 'TINY';
else if (w < 560) mode = 'NARROW';
else if (w < 900) mode = 'NORMAL';
modeLabel.textContent = mode;
}
switchEl.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-size]');
if (!btn) return;
switchEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
setSize(btn.dataset.size);
});
// 위젯 선택 토글
document.querySelectorAll('.widget, .section').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.selected').forEach(s => s.classList.remove('selected'));
el.classList.add('selected');
});
});
</script>
</body>
</html>
@@ -0,0 +1,528 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Template Renderer Scale Demo — INVYONE</title>
<style>
/* ───────────────────────────────────────────────────────────────────────
v5 토큰 (CLAUDE.md 규칙: standalone HTML 도 v5-layout.css 와 동일 토큰)
─────────────────────────────────────────────────────────────────────── */
:root {
--v5-primary: #6c5ce7;
--v5-cyan: #00cec9;
--v5-pink: #fd79a8;
--v5-bg: #0b0d14;
--v5-bg-2: #12141d;
--v5-glass: rgba(255,255,255,0.04);
--v5-glass-strong: rgba(255,255,255,0.07);
--v5-glass-border: rgba(255,255,255,0.08);
--v5-text: #e6e8ef;
--v5-text-muted: #9ba1b3;
--v5-glow-sm: 0 0 12px rgba(108,92,231,.25);
--v5-glow-md: 0 0 24px rgba(108,92,231,.35);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI",
sans-serif;
background:
radial-gradient(circle at 20% 10%, rgba(108,92,231,.12), transparent 40%),
radial-gradient(circle at 80% 90%, rgba(0,206,201,.08), transparent 40%),
var(--v5-bg);
color: var(--v5-text);
min-height: 100vh;
font-size: 0.75rem;
}
.page {
max-width: 1600px;
margin: 0 auto;
padding: 24px;
}
h1 { font-size: 1.1rem; margin: 0 0 4px; letter-spacing: -.01em; }
.lead { color: var(--v5-text-muted); margin: 0 0 20px; font-size: 0.7rem; }
h2 { font-size: 0.85rem; margin: 28px 0 8px; color: var(--v5-cyan); }
/* ───────────────────────────────────────────────────────────────────────
컨트롤 패널
─────────────────────────────────────────────────────────────────────── */
.controls {
display: flex; align-items: center; gap: 16px;
background: var(--v5-glass);
border: 1px solid var(--v5-glass-border);
backdrop-filter: blur(20px) saturate(1.4);
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.controls label { display: flex; align-items: center; gap: 8px; font-size: 0.7rem; }
.controls input[type="range"] {
width: 200px;
accent-color: var(--v5-primary);
}
.controls .val {
font-family: "JetBrains Mono", ui-monospace, monospace;
color: var(--v5-cyan);
min-width: 64px;
text-align: right;
}
.controls .pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(108,92,231,.15);
border: 1px solid rgba(108,92,231,.4);
color: var(--v5-text);
font-size: 0.65rem;
}
.controls .pill.scroll { background: rgba(253,121,168,.15); border-color: rgba(253,121,168,.4); color: var(--v5-pink); }
/* ───────────────────────────────────────────────────────────────────────
데모 1 — 단일 카드, 크기 슬라이더로 줄여보기
─────────────────────────────────────────────────────────────────────── */
.demo-stage {
background:
repeating-linear-gradient(0deg, rgba(255,255,255,.02) 0 1px, transparent 1px 24px),
repeating-linear-gradient(90deg, rgba(255,255,255,.02) 0 1px, transparent 1px 24px),
var(--v5-bg-2);
border: 1px solid var(--v5-glass-border);
border-radius: 12px;
padding: 24px;
overflow: auto;
min-height: 400px;
}
/* ───────────────────────────────────────────────────────────────────────
카드 (= 대시보드의 카드 셀)
scale 적용 핵심:
.card-body 에 overflow:hidden 두고
.canvas-wrapper 에 transform:scale + transform-origin: top left
─────────────────────────────────────────────────────────────────────── */
.card {
background: var(--v5-glass-strong);
border: 1px solid var(--v5-glass-border);
backdrop-filter: blur(20px) saturate(1.4);
border-radius: 14px;
box-shadow: var(--v5-glow-sm), 0 6px 24px rgba(0,0,0,.4);
overflow: hidden;
display: flex; flex-direction: column;
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--v5-glass-border);
background: linear-gradient(180deg, rgba(108,92,231,.08), transparent);
font-size: 0.7rem;
}
.card-title { font-weight: 600; }
.card-meta {
color: var(--v5-text-muted);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.6rem;
}
.card-body {
position: relative;
flex: 1 1 auto;
overflow: auto; /* 하한 도달 시 가로/세로 스크롤 */
background: rgba(0,0,0,.2);
}
.canvas-wrapper {
/* 디자이너 캔버스 — 항상 1920×1080 (또는 디자이너가 정한 baseWidth/baseHeight)
크기 그대로. scale 만 변함. */
width: 1920px;
height: 1080px;
transform-origin: top left;
position: relative;
}
/* ───────────────────────────────────────────────────────────────────────
자유배치 블록 — position:absolute + 디자이너가 정한 px 좌표
(DB 에선 % 로 저장되지만, 렌더 시점엔 baseWidth/baseHeight 곱해서 px)
─────────────────────────────────────────────────────────────────────── */
.block {
position: absolute;
background: rgba(255,255,255,.04);
border: 1px solid var(--v5-glass-border);
border-radius: 10px;
padding: 16px;
backdrop-filter: blur(8px);
overflow: hidden;
}
.block-label {
font-size: 14px;
color: var(--v5-text-muted);
font-weight: 500;
margin-bottom: 8px;
display: flex; align-items: center; gap: 8px;
}
.block-label .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--v5-cyan); box-shadow: 0 0 8px var(--v5-cyan); }
/* 검색 바 */
.blk-search { background: linear-gradient(135deg, rgba(108,92,231,.08), rgba(0,206,201,.04)); }
.search-row { display: flex; gap: 12px; align-items: center; }
.search-input {
flex: 1;
height: 36px;
background: rgba(0,0,0,.3);
border: 1px solid var(--v5-glass-border);
border-radius: 8px;
padding: 0 12px;
color: var(--v5-text);
font-size: 14px;
}
.search-btn {
padding: 0 18px; height: 36px;
background: var(--v5-primary);
color: white; border: none;
border-radius: 8px;
font-size: 14px; font-weight: 600;
box-shadow: var(--v5-glow-sm);
}
/* Stats 카드 */
.blk-stat .stat-num {
font-size: 36px; font-weight: 700;
background: linear-gradient(135deg, var(--v5-primary), var(--v5-cyan));
-webkit-background-clip: text; background-clip: text;
color: transparent;
line-height: 1.1;
}
.blk-stat .stat-trend { font-size: 12px; color: var(--v5-cyan); margin-top: 4px; }
/* 테이블 */
.blk-table table {
width: 100%; border-collapse: collapse;
font-size: 13px;
}
.blk-table th, .blk-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,.06);
}
.blk-table th {
font-weight: 600;
color: var(--v5-text-muted);
background: rgba(108,92,231,.06);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .04em;
}
.blk-table .badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
background: rgba(0,206,201,.15);
color: var(--v5-cyan);
border: 1px solid rgba(0,206,201,.3);
}
.blk-table .badge.warn { background: rgba(253,121,168,.15); color: var(--v5-pink); border-color: rgba(253,121,168,.3); }
/* 차트 placeholder */
.blk-chart .chart-area {
position: relative;
margin-top: 12px;
height: calc(100% - 48px);
display: flex; align-items: flex-end; gap: 12px;
padding: 0 8px;
}
.chart-bar {
flex: 1;
background: linear-gradient(180deg, var(--v5-primary), var(--v5-cyan));
border-radius: 6px 6px 0 0;
box-shadow: 0 0 12px rgba(108,92,231,.4);
min-height: 12px;
}
/* 버튼 바 */
.blk-actions {
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
}
.btn {
padding: 0 16px; height: 36px;
border-radius: 8px;
font-size: 13px; font-weight: 600;
border: 1px solid var(--v5-glass-border);
background: var(--v5-glass);
color: var(--v5-text);
cursor: pointer;
}
.btn.primary {
background: var(--v5-primary); color: white;
border-color: transparent;
box-shadow: var(--v5-glow-sm);
}
.btn.cyan { background: var(--v5-cyan); color: #0b0d14; border-color: transparent; }
/* 페이지네이션 */
.blk-pagination { display: flex; align-items: center; justify-content: center; gap: 6px; }
.pg {
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 6px; border: 1px solid var(--v5-glass-border);
font-size: 12px;
background: var(--v5-glass);
}
.pg.active { background: var(--v5-primary); color: white; border-color: transparent; }
/* ───────────────────────────────────────────────────────────────────────
데모 2 — 대시보드 5 카드 그리드
─────────────────────────────────────────────────────────────────────── */
.dash-grid {
display: grid;
gap: 12px;
grid-template-columns: 2fr 1fr 1fr;
grid-template-rows: 240px 240px;
height: 540px;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,.02) 0 1px, transparent 1px 24px),
repeating-linear-gradient(90deg, rgba(255,255,255,.02) 0 1px, transparent 1px 24px),
var(--v5-bg-2);
border: 1px solid var(--v5-glass-border);
border-radius: 12px;
padding: 12px;
}
.dash-grid > .card:nth-child(1) { grid-row: 1 / span 2; }
</style>
</head>
<body>
<div class="page">
<h1>Template Renderer — Scale 기반 자유배치 데모</h1>
<p class="lead">
디자이너 캔버스(1920×1080) 위에 자유배치된 블록들을, 카드 크기에 따라
<code>transform: scale</code> 로 비례 축소. 하한선(0.5)에 도달하면 카드 안에서
스크롤. role/band/responsivePolicy 추측 없음.
</p>
<!-- ─── 데모 1: 단일 카드 + 슬라이더 ────────────────────────────────── -->
<h2>① 카드 폭을 줄여보세요 — 자유배치 모양은 100% 보존</h2>
<div class="controls">
<label>
카드 너비
<input type="range" id="w" min="240" max="1500" value="1200" step="10">
<span class="val" id="wVal">1200px</span>
</label>
<label>
카드 높이
<input type="range" id="h" min="180" max="900" value="680" step="10">
<span class="val" id="hVal">680px</span>
</label>
<span class="pill" id="scaleBadge">scale 0.625</span>
<span class="pill" id="modeBadge">FIT</span>
</div>
<div class="demo-stage">
<div class="card" id="card" style="width:1200px;height:680px">
<div class="card-header">
<div class="card-title">📊 수주 관리</div>
<div class="card-meta" id="meta">canvas 1920×1080 · scale 0.625</div>
</div>
<div class="card-body" id="body">
<div class="canvas-wrapper" id="cw">
<!-- 자유배치 블록들 — 디자이너가 1920×1080 에 그린 그대로 -->
<div class="block blk-search" style="left:40px;top:40px;width:1840px;height:80px">
<div class="search-row">
<input class="search-input" placeholder="고객명·수주번호·품목으로 검색">
<input class="search-input" placeholder="기간: 2026-01-01 ~ 2026-04-20" style="max-width:280px">
<button class="search-btn">🔍 검색</button>
</div>
</div>
<div class="block blk-stat" style="left:40px;top:140px;width:445px;height:120px">
<div class="block-label"><span class="dot"></span>이번달 수주</div>
<div class="stat-num">128</div>
<div class="stat-trend">↑ 12.4% (전월 대비)</div>
</div>
<div class="block blk-stat" style="left:505px;top:140px;width:445px;height:120px">
<div class="block-label"><span class="dot"></span>매출 합계</div>
<div class="stat-num">₩ 4.2억</div>
<div class="stat-trend">↑ 8.1%</div>
</div>
<div class="block blk-stat" style="left:970px;top:140px;width:445px;height:120px">
<div class="block-label"><span class="dot"></span>처리 대기</div>
<div class="stat-num">23</div>
<div class="stat-trend" style="color:var(--v5-pink)">↓ 4건 감소</div>
</div>
<div class="block blk-stat" style="left:1435px;top:140px;width:445px;height:120px">
<div class="block-label"><span class="dot"></span>완료율</div>
<div class="stat-num">92%</div>
<div class="stat-trend">목표 95%</div>
</div>
<div class="block blk-table" style="left:40px;top:280px;width:1280px;height:520px">
<div class="block-label"><span class="dot"></span>수주 목록</div>
<table>
<thead>
<tr><th>수주번호</th><th>고객사</th><th>품목</th><th>수량</th><th>금액</th><th>상태</th><th>납기</th></tr>
</thead>
<tbody>
<tr><td>SO-26-0421</td><td>삼성전자</td><td>RAM 모듈 32G</td><td>1,200</td><td>₩ 36,000,000</td><td><span class="badge">완료</span></td><td>2026-04-22</td></tr>
<tr><td>SO-26-0420</td><td>LG디스플레이</td><td>패널 27인치</td><td>800</td><td>₩ 64,000,000</td><td><span class="badge">완료</span></td><td>2026-04-25</td></tr>
<tr><td>SO-26-0419</td><td>현대차</td><td>센서 모듈</td><td>3,400</td><td>₩ 18,500,000</td><td><span class="badge warn">대기</span></td><td>2026-04-30</td></tr>
<tr><td>SO-26-0418</td><td>SK하이닉스</td><td>웨이퍼 검사</td><td>200</td><td>₩ 12,000,000</td><td><span class="badge">완료</span></td><td>2026-04-21</td></tr>
<tr><td>SO-26-0417</td><td>네이버</td><td>서버 부품</td><td>50</td><td>₩ 8,500,000</td><td><span class="badge warn">대기</span></td><td>2026-05-02</td></tr>
<tr><td>SO-26-0416</td><td>카카오</td><td>네트워크 장비</td><td>120</td><td>₩ 21,000,000</td><td><span class="badge">완료</span></td><td>2026-04-28</td></tr>
<tr><td>SO-26-0415</td><td>포스코</td><td>특수합금</td><td>15</td><td>₩ 45,000,000</td><td><span class="badge warn">대기</span></td><td>2026-05-10</td></tr>
</tbody>
</table>
</div>
<div class="block blk-chart" style="left:1340px;top:280px;width:540px;height:520px">
<div class="block-label"><span class="dot"></span>월별 수주 추이</div>
<div class="chart-area">
<div class="chart-bar" style="height:35%"></div>
<div class="chart-bar" style="height:55%"></div>
<div class="chart-bar" style="height:42%"></div>
<div class="chart-bar" style="height:78%"></div>
<div class="chart-bar" style="height:65%"></div>
<div class="chart-bar" style="height:88%"></div>
<div class="chart-bar" style="height:95%"></div>
</div>
</div>
<div class="block blk-pagination" style="left:40px;top:820px;width:1280px;height:60px;padding:12px">
<span class="pg">«</span>
<span class="pg active">1</span>
<span class="pg">2</span>
<span class="pg">3</span>
<span class="pg">4</span>
<span class="pg">5</span>
<span class="pg">»</span>
</div>
<div class="block blk-actions" style="left:1340px;top:820px;width:540px;height:60px;padding:12px">
<button class="btn">취소</button>
<button class="btn cyan">엑셀</button>
<button class="btn primary">+ 신규 수주</button>
</div>
</div>
</div>
</div>
</div>
<p class="lead" style="margin-top:8px">
👉 <b>가로/세로 슬라이더</b> 를 움직여 보세요.
카드 영역에 맞춰 <code>scale</code> 이 자동 계산되고,
<code>0.5</code> 미만으로 축소될 상황이면 그 시점부터 카드 안에 스크롤이 생깁니다 (콩알 방지).
</p>
<!-- ─── 데모 2: 대시보드 5 카드 ────────────────────────────────────── -->
<h2>② 대시보드 — 한 화면에 카드 5개 (각자 자유배치 보존)</h2>
<div class="dash-grid" id="dashGrid">
<!-- 큰 카드 -->
<div class="card mini-card" data-template="orders">
<div class="card-header"><div class="card-title">📊 수주 관리</div><div class="card-meta">FHD 캔버스</div></div>
<div class="card-body"><div class="canvas-wrapper"></div></div>
</div>
<div class="card mini-card" data-template="dept">
<div class="card-header"><div class="card-title">👥 부서 관리</div><div class="card-meta">FHD 캔버스</div></div>
<div class="card-body"><div class="canvas-wrapper"></div></div>
</div>
<div class="card mini-card" data-template="ship">
<div class="card-header"><div class="card-title">📦 출고 관리</div><div class="card-meta">FHD 캔버스</div></div>
<div class="card-body"><div class="canvas-wrapper"></div></div>
</div>
<div class="card mini-card" data-template="po">
<div class="card-header"><div class="card-title">🛒 발주 관리</div><div class="card-meta">FHD 캔버스</div></div>
<div class="card-body"><div class="canvas-wrapper"></div></div>
</div>
<div class="card mini-card" data-template="report">
<div class="card-header"><div class="card-title">📈 영업 리포트</div><div class="card-meta">FHD 캔버스</div></div>
<div class="card-body"><div class="canvas-wrapper"></div></div>
</div>
</div>
<p class="lead" style="margin-top:8px">
각 카드는 1920×1080 캔버스를 통째로 비례 축소해서 보여줍니다.
카드가 작아도 자유배치 모양은 그대로 — 글자가 작아도 비율이 유지되어
"이 화면이 어떻게 생겼는지" 가 한눈에 보입니다.
</p>
</div>
<script>
// ─── 데모 1: 단일 카드 scale 계산 ────────────────────────────────────
const CANVAS_W = 1920;
const CANVAS_H = 1080;
const MIN_SCALE = 0.5; // 콩알 방지 하한선
const card = document.getElementById('card');
const cw = document.getElementById('cw');
const w = document.getElementById('w');
const h = document.getElementById('h');
const wVal = document.getElementById('wVal');
const hVal = document.getElementById('hVal');
const meta = document.getElementById('meta');
const scaleBadge = document.getElementById('scaleBadge');
const modeBadge = document.getElementById('modeBadge');
function applyScale() {
const cardW = parseInt(w.value, 10);
const cardH = parseInt(h.value, 10);
card.style.width = cardW + 'px';
card.style.height = cardH + 'px';
wVal.textContent = cardW + 'px';
hVal.textContent = cardH + 'px';
// 카드 본체(헤더 제외) 크기 ≈ cardH - 32px header
const bodyW = cardW;
const bodyH = cardH - 32;
const fitW = bodyW / CANVAS_W;
const fitH = bodyH / CANVAS_H;
const fit = Math.min(fitW, fitH);
let scale, mode;
if (fit < MIN_SCALE) {
scale = MIN_SCALE; // 하한 도달 → 더 안 줄임
mode = 'SCROLL';
} else {
scale = fit;
mode = 'FIT';
}
cw.style.transform = `scale(${scale})`;
// 캔버스 wrapper 의 실제 점유 크기를 scale 후 크기로 강제 → 부모 overflow 가 정상 계산
cw.style.width = CANVAS_W + 'px';
cw.style.height = CANVAS_H + 'px';
// body 가 가지는 visible 영역 vs 캔버스 scaled 영역
// scale 후 캔버스가 차지하는 px = CANVAS_W*scale, CANVAS_H*scale
// 그것보다 body 가 작으면 자동 스크롤 발생.
scaleBadge.textContent = `scale ${scale.toFixed(3)}`;
modeBadge.textContent = mode;
modeBadge.className = mode === 'SCROLL' ? 'pill scroll' : 'pill';
meta.textContent = `canvas ${CANVAS_W}×${CANVAS_H} · scale ${scale.toFixed(3)} · ${mode}`;
}
w.addEventListener('input', applyScale);
h.addEventListener('input', applyScale);
applyScale();
// ─── 데모 2: 미니카드들 ──────────────────────────────────────────────
// 각 카드에 동일한 자유배치 캔버스를 복제, 컨테이너 크기에 맞춰 scale
const sampleHTML = document.getElementById('cw').innerHTML;
document.querySelectorAll('.mini-card .canvas-wrapper').forEach((wrap) => {
wrap.innerHTML = sampleHTML;
wrap.style.width = CANVAS_W + 'px';
wrap.style.height = CANVAS_H + 'px';
});
function applyMini() {
document.querySelectorAll('.mini-card').forEach((mc) => {
const body = mc.querySelector('.card-body');
const wrap = mc.querySelector('.canvas-wrapper');
const bodyW = body.clientWidth;
const bodyH = body.clientHeight;
const fit = Math.min(bodyW / CANVAS_W, bodyH / CANVAS_H);
const scale = Math.max(fit, 0.05); // 미니 미리보기는 하한선 더 낮게
wrap.style.transform = `scale(${scale})`;
});
}
applyMini();
window.addEventListener('resize', () => { applyScale(); applyMini(); });
</script>
</body>
</html>
@@ -0,0 +1,327 @@
# 템플릿 저장 모델 v2 — 자유배치 유지, 저장만 semantic 화
> 2026-04-20 / gbpark
> 자유배치 편집은 그대로 두고, DB 저장 모델을 px 절대좌표 + 추측렌더에서
> % 정규좌표 + role + responsivePolicy 로 갈아엎는다.
> 목표 = 런타임 추측 제거.
---
## 0. 왜 바꾸는가
현재 `components/dash/TemplateRenderer.tsx` 는 저장된 절대 px 좌표에서
row/group/overlay/action 을 **추론**한다:
- top band clustering (tolerance 기반)
- `FIXED_SMALL_IDS` 하드코딩 (button/divider/input)
- `FULL_WIDTH_IDS` 하드코딩 (table/container/…)
- centerY 범위 기반 overlay 흡수
- action line clamp (main 과 겹칠 때만)
- rowHeight / overlayFullBottomPx / effectiveRowHeight / marginTop — 직교하지 않는
6개 이상의 계산이 누적
케이스별로 clamp 를 붙이며 증상은 잡고 있지만, 다음 템플릿에서 바로 흔들린다:
- 버튼이 카드 **옆** 에 있어야 하는 경우
- 카드보다 더 **낮게 내려간 companion** 이 있는 경우
- 테이블 band **안쪽** 에 의도된 overlay 가 있는 경우
근본 원인은 **의도 정보가 저장되지 않아서** 렌더러가 좌표만 보고 추측한다는 것.
## 1. 저장 모델 v2 스키마
### 1-1. 블록 스키마
```ts
export interface BlockV2 {
id: string;
componentId: string; // unified id: 'table' | 'stats' | 'button' | ...
// 정규화된 좌표 (기준 캔버스 대비 %)
xPct: number; // 0.0 ~ 1.0
yPct: number; // 0.0 ~ 1.0
wPct: number; // 0.0 ~ 1.0
hPct: number; // 0.0 ~ 1.0
// 디자이너 의도 — 런타임이 이걸 그대로 렌더
role: BlockRole;
responsivePolicy: ResponsivePolicy;
// 앵커 (선택 — 런타임 추측 금지, 디자이너가 명시)
anchor?: BlockAnchor;
// 컴포넌트 설정 (현재 그대로 유지)
config: Record<string, any>;
}
export type BlockRole =
| 'main' // 페이지의 주 콘텐츠 (카드/테이블/폼)
| 'action' // main 아래/위 별도 액션 라인 (저장/수정/새로고침 바)
| 'companion' // main 과 같은 라인에 붙는 동료 (카드 옆 인디케이터 등)
| 'overlay'; // main 위에 띄우는 floating (플로팅 버튼, 토스트 앵커 등)
export type ResponsivePolicy =
| 'fixed' // 원본 w/h 유지. 카드가 좁아져도 자기 크기 유지
| 'scroll' // 부모 폭 채우고 내부 overflow (테이블/컨테이너)
| 'reflow' // auto-fit grid 로 내부 열 재배치 (stats 그리드)
| 'wrap'; // flex-wrap (버튼 그룹 등)
export type BlockAnchor =
| 'top-left' | 'top-center' | 'top-right'
| 'center-left' | 'center' | 'center-right'
| 'bottom-left' | 'bottom-center' | 'bottom-right';
```
### 1-2. 뷰(View) 스키마
```ts
export interface ViewV2 {
blocks: BlockV2[];
canvas: {
baseWidth: number; // 정규화 기준 폭 (예: 1920)
baseHeight: number; // 정규화 기준 높이 (예: 1080)
aspectPolicy: 'preserve' | 'free'; // 카드 폭 축소 시 세로도 비례 축소할지
};
}
export interface TemplateV2 {
templateId: string;
name: string;
category: string;
primaryTable?: string;
fields: FieldConfig[];
views: {
list?: ViewV2;
create?: ViewV2;
edit?: ViewV2;
};
connections: Connection[];
status: 'draft' | 'published';
version: 2; // v1 (현재) 과 구분
}
```
### 1-3. DB 저장 포맷
`templates` 테이블 구조는 유지. `VIEWS` jsonb 컬럼 안에서 version 필드로 v1/v2 구분.
```json
{
"version": 2,
"canvas": { "baseWidth": 1920, "baseHeight": 1080, "aspectPolicy": "preserve" },
"list": {
"blocks": [
{
"id": "stats_1", "componentId": "stats",
"xPct": 0.05, "yPct": 0.1, "wPct": 0.28, "hPct": 0.22,
"role": "main", "responsivePolicy": "reflow",
"config": { /* ... */ }
},
{
"id": "btn_save", "componentId": "button",
"xPct": 0.78, "yPct": 0.14, "wPct": 0.1, "hPct": 0.05,
"role": "action", "responsivePolicy": "fixed",
"anchor": "top-right",
"config": { "text": "저장", "variant": "primary" }
}
]
}
}
```
## 2. 런타임 렌더 모델 (추측 제거)
### 2-1. 한 가지 원칙
**렌더러는 블록의 `role` 과 `responsivePolicy` 를 보고만 결정한다.**
- componentId 기반 특수처리 금지 (FIXED_SMALL_IDS, FULL_WIDTH_IDS 삭제)
- top/center/bbox 기반 clustering 금지
- overlap 판정 금지
- action line clamp 금지
### 2-2. role 별 렌더 전략 (고정)
| role | 렌더 방식 |
|---|---|
| `main` | row flex 에 참여. 같은 y 밴드(명시된 y 범위 겹침)끼리 묶여 가로 배치 |
| `action` | main band 다음 라인. 원본 y 순서는 유지하되 **항상 main 아래** 로 배치 |
| `companion` | 같은 band 의 main 과 **가로로 공존** (flex row child). overlap 아님 |
| `overlay` | `position: absolute`. 원본 x/y% 그대로 띄움 (main 위에 얹힘) |
band 분할 규칙도 단순화:
- role 이 `main` 인 블록들만 band clustering (같은 행 = yPct 근접)
- `companion` 은 가장 가까운 main band 에 붙임 (디자이너가 명시한 band id 선택지 제공)
- `action` 은 자기가 가리키는 main band id 기준으로 그 아래 라인 생성
- `overlay` 는 band 무관, 자기 xPct/yPct 로 절대 배치
선택지: 블록에 `bandId?: string` 을 추가해 band 를 명시하면 clustering 조차 필요 없음.
디자이너에서 "이 버튼은 어느 카드 밴드의 action?" 을 명시적으로 연결.
### 2-3. responsivePolicy 별 렌더 (role 과 직교)
| policy | 구현 |
|---|---|
| `fixed` | `width: wPct%; height: hPct%` 또는 원본 px. flex-shrink 0 |
| `scroll` | 내부 overflow:auto. 카드 폭 축소 시 wrapper 만 좁아지고 내부 스크롤 |
| `reflow` | `display:grid; grid-template-columns: repeat(auto-fit, minmax(Xpx, 1fr))` |
| `wrap` | `display:flex; flex-wrap: wrap; gap: Y` |
각 policy 의 파라미터(minItemWidth, gap 등) 는 블록 config 에 명시.
## 3. 디자이너 측 변경
### 3-1. 편집 UX (변경 없음)
- 자유배치 드래그/리사이즈 그대로
- Toolbar 에 "역할" 드롭다운 추가: main / action / companion / overlay
- "반응 정책" 드롭다운 추가: fixed / scroll / reflow / wrap
- 선택적 "앵커" 드롭다운
- 선택적 "band 연결" 드롭다운 (companion/action 용)
### 3-2. 저장 시 normalize
현재 `templateAdapter.ts``saveTemplate` 에 추가:
```ts
function normalize(block: BlockRaw, canvas: { w: number; h: number }): BlockV2 {
return {
id: block.id,
componentId: block.componentId,
xPct: block.pos.left / canvas.w,
yPct: block.pos.top / canvas.h,
wPct: block.pos.width / canvas.w,
hPct: block.pos.height / canvas.h,
role: block.role ?? inferRoleOnSave(block), // 저장 시점 1회 추론, 이후는 사용자가 수정
responsivePolicy: block.responsivePolicy ?? inferPolicyOnSave(block),
anchor: block.anchor,
config: block.config,
};
}
```
### 3-3. 로드 시 de-normalize (편집 화면)
```ts
function denormalize(b: BlockV2, canvas: { w: number; h: number }): BlockRaw {
return {
id: b.id,
componentId: b.componentId,
pos: {
left: b.xPct * canvas.w,
top: b.yPct * canvas.h,
width: b.wPct * canvas.w,
height: b.hPct * canvas.h,
},
role: b.role,
responsivePolicy: b.responsivePolicy,
anchor: b.anchor,
config: b.config,
};
}
```
디자이너는 내부적으로 px 로 편집, 저장/로드 경계에서만 % 변환.
## 4. 마이그레이션 경로
### 4-1. 기존 v1 → v2 자동 변환
런타임에서 version 필드 확인:
- `version === 2` → v2 렌더러
- 없음/`version === 1`**v1 → v2 즉석 변환** 후 v2 렌더러로 통합
변환 로직 (`lib/utils/templateMigrate.ts`):
```ts
function migrateV1toV2(v1View: any): ViewV2 {
const canvas = v1View.screenResolution ?? { width: 1920, height: 1080 };
const blocks = extractComponents(v1View).map((c) => {
const pos = normalizePos(c, canvas);
return {
id: c.id,
componentId: normalizeComponentId(c),
xPct: pos.left / canvas.width,
yPct: pos.top / canvas.height,
wPct: pos.width / canvas.width,
hPct: pos.height / canvas.height,
role: inferRoleFromV1(c), // 마이그레이션용 1회 추론
responsivePolicy: inferPolicyFromV1(c),
config: c.config ?? c.overrides ?? {},
};
});
return {
blocks,
canvas: {
baseWidth: canvas.width,
baseHeight: canvas.height,
aspectPolicy: 'preserve',
},
};
}
```
추론 규칙 (마이그레이션 1회만 — v2 로 저장되면 디자이너가 고칠 수 있음):
- `FULL_WIDTH_IDS` (table/container/search/accordion) → `role: main`, `policy: scroll`
- `stats``role: main`, `policy: reflow`
- `button` → 현재 overlay 흡수 판정 적용 (위치 관계 보고 main/action/overlay 추정)
- `title`, `divider``role: companion`, `policy: fixed`
### 4-2. 단계적 전환
- v1 템플릿은 건드리지 않는다 (DB 상 jsonb 유지)
- 저장할 때마다 v2 포맷으로 upgrade
- 1개월 후 "v1 템플릿 변환" 배치 스크립트로 남은 것 일괄 변환
### 4-3. 하위 호환
`TemplateRenderer.tsx` 는 version 분기 없이 **입력을 항상 v2 로 보게** 하고,
로드 경로(`pickViewsObj` 근처)에서 **v1 인지 체크 → migrateV1toV2 적용**.
즉 렌더러 본체에는 v1 특수 로직이 없음.
## 5. 구현 Phase
**Phase 1 — 타입 + 런타임 렌더러 v2 (변환 경유)**
- `types/invyone-component.ts``BlockV2`, `ViewV2`, `TemplateV2`, `BlockRole`, `ResponsivePolicy` 추가
- `lib/utils/templateMigrate.ts` (v1 → v2 변환)
- `components/dash/TemplateRenderer.tsx` 를 v2 스키마만 받는 버전으로 재작성
- 상단에서 v1 을 받으면 `migrateV1toV2` 돌려 v2 로
- 내부 렌더는 `role` + `responsivePolicy` 만 참조
- 기존 템플릿이 동일 화면으로 렌더되는지 회귀 확인
**Phase 2 — 디자이너 로드/저장 전환**
- `templateAdapter.ts``loadTemplateAsLayout`, `saveTemplate` 을 v2 경유로
- 저장 시 px → % normalize, role/policy 없으면 자동 추론 1회
- 편집 화면은 내부적으로 px 유지
**Phase 3 — 디자이너 UI (role/policy/band 선택)**
- 속성 패널(`components/screen/RealtimePreviewDynamic.tsx` 또는 별도)에 드롭다운 추가
- "역할 = main/action/companion/overlay"
- "반응 정책 = fixed/scroll/reflow/wrap"
- "소속 밴드" (companion/action 용)
- 디자이너가 의도를 명시할 수 있게
**Phase 4 — v1 제거**
- 런타임 v1 migrate 경로 삭제
- 남은 v1 템플릿은 배치 변환 후 `TemplateRenderer` 에서 v2 만 받음
**Phase 5 — 규칙 clean up**
- `FIXED_SMALL_IDS`, `FULL_WIDTH_IDS` 상수 제거
- `groupIntoRows` 의 top-tolerance / overlap 로직 제거
- `renderOverlay` 의 action line clamp 제거
- Before/After 로 line count 측정 (예상: 800줄 → 300줄)
## 6. 체크리스트 (작업 착수 전 확인)
- [ ] 위 BlockV2 스키마 필드 OK (추가/제거 필요한 것 있는지)
- [ ] `bandId` 명시 연결 방식을 쓸지 (추천) / y% 근접 clustering 으로 갈지
- [ ] aspectPolicy: preserve (세로 비례) vs free (세로 content size) 기본값
- [ ] anchor 필드가 fixed policy 와 함께 있을 때 우선순위
- [ ] v1 템플릿 migrate 1회 추론 결과가 기존 렌더와 비슷하게 나오는지 (회귀)
- [ ] Phase 1~5 순서 승인
## 7. 의도적으로 하지 않는 것
- ❌ 디자이너 UX 리뉴얼 (자유배치 유지)
- ❌ grid/slot 강제 (C 옵션)
- ❌ 전체 scale transform (B 옵션)
- ❌ 컴포넌트 ID 하드코딩으로 특수처리 (현재 방식)
- ❌ 좌표 기반 overlap/clustering 런타임 추측
이 문서를 바탕으로 Phase 1 먼저 착수하면 됩니다.