feat(template): 라이브러리 카드에 wireframe 썸네일 표시
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:
2026-04-30 08:00:10 +09:00
parent 59f230187d
commit df9a539017
5 changed files with 157 additions and 1 deletions
@@ -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>
);
}
+13
View File
@@ -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 {