임시저장
This commit is contained in:
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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">560–900px<br>가로 2열</div>
|
||||
</div>
|
||||
<div class="policy-card">
|
||||
<div class="mname" style="color:var(--v5-pink)">NARROW</div>
|
||||
<div class="mdesc">360–560px<br>세로 스택</div>
|
||||
</div>
|
||||
<div class="policy-card">
|
||||
<div class="mname" style="color:var(--v5-amber)">TINY</div>
|
||||
<div class="mdesc">< 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 먼저 착수하면 됩니다.
|
||||
Reference in New Issue
Block a user