feat(template): 라이브러리 카드에 wireframe 썸네일 표시
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m26s
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m26s
템플릿 목록 카드의 정적 📋 아이콘을 실제 view 구조 기반의 미니 와이어프레임으로 교체. 사용자가 카드만 보고도 템플릿이 어떤 화면인지(테이블 위주 / 폼 위주 / 단순 버튼 등) 파악 가능. - backend: getTemplateList SQL 에 VIEWS 컬럼 추가, list 응답 각 row 의 views jsonb 를 객체로 파싱 - frontend: TemplateThumbnail 컴포넌트 신설 — v2(BlockV2.xPct/yPct /wPct/hPct) 정규화 좌표 우선, v1(order/row) 폴백, 컴포넌트 종류별 색상(table=primary, form=cyan, button=pink) - TemplateLibraryModal 카드 아이콘 자리 교체 - dashboard.css 에 .dash-lib-card-thumb / -block 스타일 추가 (v5 토큰 준수 — solid + glow, blur 없음) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,9 @@ public class TemplateService extends BaseService {
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getTemplateListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getTemplateList", params);
|
||||
for (Map<String, Object> row : list) {
|
||||
parseJsonField(row, "views");
|
||||
}
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
, CATEGORY
|
||||
, DESCRIPTION
|
||||
, PRIMARY_TABLE
|
||||
, VIEWS
|
||||
, STATUS
|
||||
, VERSION
|
||||
, CREATED_BY
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { getTemplateList } from '@/lib/api/template';
|
||||
import { TemplateThumbnail } from './TemplateThumbnail';
|
||||
|
||||
interface TemplateLibraryModalProps {
|
||||
open: boolean;
|
||||
@@ -142,7 +143,7 @@ export function TemplateLibraryModal({ open, onClose, onSelectTemplate }: Templa
|
||||
className="dash-lib-card"
|
||||
onClick={() => onSelectTemplate(t)}
|
||||
>
|
||||
<div className="dash-lib-card-icon">📋</div>
|
||||
<TemplateThumbnail views={t.views ?? t.VIEWS} />
|
||||
<div className="dash-lib-card-name">{name}</div>
|
||||
{desc && <div className="dash-lib-card-desc">{desc}</div>}
|
||||
<div style={{ display: 'flex', gap: '.2rem', marginTop: 'auto', flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 템플릿 카드용 작은 wireframe 썸네일.
|
||||
*
|
||||
* - v2 (BlockV2 + xPct/yPct/wPct/hPct): 정규화 좌표 그대로 절대 배치
|
||||
* - v1 (TemplateComponent + order/row): row 별로 묶어 세로 스택, 같은 row 는 가로 분할
|
||||
* - 데이터 없음: 빈 placeholder
|
||||
*
|
||||
* 컴포넌트 종류는 색상으로만 구분 (table=primary, form=cyan, button=pink, 기타=muted).
|
||||
*/
|
||||
|
||||
interface TemplateThumbnailProps {
|
||||
views?: any;
|
||||
}
|
||||
|
||||
interface MiniBlock {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
const KIND_COLOR: Record<string, string> = {
|
||||
table: 'rgba(var(--v5-primary-rgb), .55)',
|
||||
form: 'rgba(0, 206, 201, .55)',
|
||||
search: 'rgba(0, 206, 201, .35)',
|
||||
button: 'rgba(253, 121, 168, .65)',
|
||||
'button-bar': 'rgba(253, 121, 168, .55)',
|
||||
stats: 'rgba(var(--v5-primary-rgb), .35)',
|
||||
tabs: 'rgba(var(--v5-primary-rgb), .25)',
|
||||
title: 'rgba(var(--v5-text-rgb, 100, 100, 120), .35)',
|
||||
divider: 'rgba(var(--v5-text-rgb, 100, 100, 120), .25)',
|
||||
pagination: 'rgba(var(--v5-text-rgb, 100, 100, 120), .25)',
|
||||
};
|
||||
|
||||
function kindColor(kind: string): string {
|
||||
const lower = (kind || '').toLowerCase();
|
||||
for (const key of Object.keys(KIND_COLOR)) {
|
||||
if (lower.includes(key)) return KIND_COLOR[key];
|
||||
}
|
||||
return 'rgba(var(--v5-primary-rgb), .25)';
|
||||
}
|
||||
|
||||
function inferKind(componentId: string): string {
|
||||
const id = (componentId || '').toLowerCase();
|
||||
if (id.includes('table') || id.includes('grid') || id.includes('list')) return 'table';
|
||||
if (id.includes('form') || id.includes('input')) return 'form';
|
||||
if (id.includes('search') || id.includes('filter')) return 'search';
|
||||
if (id.includes('button')) return 'button';
|
||||
if (id.includes('stat') || id.includes('kpi') || id.includes('chart')) return 'stats';
|
||||
if (id.includes('tab')) return 'tabs';
|
||||
if (id.includes('title') || id.includes('label') || id.includes('text')) return 'title';
|
||||
if (id.includes('divider')) return 'divider';
|
||||
if (id.includes('pag')) return 'pagination';
|
||||
return id || 'unknown';
|
||||
}
|
||||
|
||||
function fromV2(views: any): MiniBlock[] | null {
|
||||
const blocks = views?.list?.blocks;
|
||||
if (!Array.isArray(blocks) || blocks.length === 0) return null;
|
||||
return blocks.map((b: any) => ({
|
||||
x: clamp01(b.xPct ?? 0),
|
||||
y: clamp01(b.yPct ?? 0),
|
||||
w: clamp01(b.wPct ?? 0.2),
|
||||
h: clamp01(b.hPct ?? 0.1),
|
||||
kind: inferKind(b.componentId),
|
||||
}));
|
||||
}
|
||||
|
||||
function fromV1(views: any): MiniBlock[] | null {
|
||||
const components = views?.list?.components;
|
||||
if (!Array.isArray(components) || components.length === 0) return null;
|
||||
|
||||
const sorted = [...components].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
const rowsMap = new Map<string, any[]>();
|
||||
let nextRowId = 0;
|
||||
for (const c of sorted) {
|
||||
const key = c.row != null ? `r${c.row}` : `s${nextRowId++}`;
|
||||
if (!rowsMap.has(key)) rowsMap.set(key, []);
|
||||
rowsMap.get(key)!.push(c);
|
||||
}
|
||||
|
||||
const rows = Array.from(rowsMap.values());
|
||||
const totalRows = rows.length;
|
||||
const blocks: MiniBlock[] = [];
|
||||
rows.forEach((rowComps, rowIdx) => {
|
||||
const cols = rowComps.length;
|
||||
rowComps.forEach((c, colIdx) => {
|
||||
blocks.push({
|
||||
x: colIdx / cols,
|
||||
y: rowIdx / totalRows,
|
||||
w: 1 / cols,
|
||||
h: 1 / totalRows,
|
||||
kind: inferKind(c.componentId),
|
||||
});
|
||||
});
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (typeof v !== 'number' || isNaN(v)) return 0;
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function TemplateThumbnail({ views }: TemplateThumbnailProps) {
|
||||
const blocks = fromV2(views) ?? fromV1(views);
|
||||
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return (
|
||||
<div className="dash-lib-card-thumb dash-lib-card-thumb--empty" aria-hidden="true">
|
||||
<span style={{ fontSize: '.95rem', opacity: .6 }}>📋</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dash-lib-card-thumb" aria-hidden="true">
|
||||
{blocks.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="dash-lib-card-thumb-block"
|
||||
style={{
|
||||
left: `${b.x * 100}%`,
|
||||
top: `${b.y * 100}%`,
|
||||
width: `${Math.max(b.w * 100, 4)}%`,
|
||||
height: `${Math.max(b.h * 100, 8)}%`,
|
||||
background: kindColor(b.kind),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -478,6 +478,19 @@
|
||||
background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.15), rgba(var(--v5-cyan-rgb),.08));
|
||||
border: 1px solid rgba(var(--v5-primary-rgb),.15);
|
||||
}
|
||||
.dash-lib-card-thumb {
|
||||
position: relative; width: 100%; aspect-ratio: 16 / 10;
|
||||
border-radius: 8px; overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.06), rgba(var(--v5-cyan-rgb),.04));
|
||||
border: 1px solid var(--v5-border);
|
||||
}
|
||||
.dash-lib-card-thumb--empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.dash-lib-card-thumb-block {
|
||||
position: absolute; border-radius: 2px;
|
||||
outline: 1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
.dash-lib-card-name { font-size: .78rem; font-weight: 700; color: var(--v5-text); }
|
||||
.dash-lib-card-desc { font-size: .55rem; color: var(--v5-text-muted); line-height: 1.4; }
|
||||
.dash-lib-card-tag {
|
||||
|
||||
Reference in New Issue
Block a user