feat(frontend): 디자인 시스템 쇼케이스 페이지 + 신규 공통 컴포넌트
쇼케이스 (/design-system.html): - 별도 Vite entry (System Flow 패턴 재사용, 메인 SPA 분리) - 10개 섹션: Intro / Token / Typography / Badge / Button / Form / Card / Layout / Catalog (19+) / Guide - 추적 ID 체계 (TRK-CATEGORY-SLUG): - hover 시 툴팁 + "ID 복사 모드"에서 클릭 시 클립보드 복사 - URL hash 딥링크 (#trk=TRK-BADGE-critical-sm) 스크롤+하이라이트 - 산출문서/논의에서 특정 변형 정확히 참조 가능 - Dark/Light 테마 토글로 양쪽 시각 검증 신규 공통 컴포넌트: - Button (@shared/components/ui/button.tsx) - 5 variant × 3 size = 15 변형 - primary/secondary/ghost/outline/destructive × sm/md/lg - Input / Select / Textarea / Checkbox / Radio - Input · Select 공통 inputVariants 공유 (sm/md/lg × default/error/success) - PageContainer / PageHeader / Section (shared/components/layout/) - PageContainer: size sm/md/lg + fullBleed (지도/풀화면 예외) - PageHeader: title + description + icon + demo 배지 + actions 슬롯 - Section: Card + CardHeader + CardTitle + CardContent 단축 variants.ts 확장: - buttonVariants / inputVariants / pageContainerVariants CVA 정의 - Button/Input/Select는 variants.ts에서 import하여 fast-refresh 경고 회피 빌드 검증 완료: - TypeScript 타입 체크 통과 - ESLint 통과 (경고 0) - vite build: designSystem-*.js 54KB (메인 SPA와 분리) 이 쇼케이스가 확정된 후 실제 40+ 페이지 마이그레이션 진행 예정.
This commit is contained in:
부모
a07b7d9ba5
커밋
e0b51efc54
12
frontend/design-system.html
Normal file
12
frontend/design-system.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KCG AI Monitoring — Design System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/designSystemMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
138
frontend/src/design-system/DesignSystemApp.css
Normal file
138
frontend/src/design-system/DesignSystemApp.css
Normal file
@ -0,0 +1,138 @@
|
||||
/* 디자인 쇼케이스 전용 스타일 */
|
||||
|
||||
.ds-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--background, #0b1220);
|
||||
color: var(--foreground, #e2e8f0);
|
||||
}
|
||||
|
||||
.ds-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid rgb(51 65 85 / 0.5);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-overlay, rgb(15 23 42 / 0.6));
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.ds-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ds-nav {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
padding: 1rem 0.75rem;
|
||||
border-right: 1px solid rgb(51 65 85 / 0.5);
|
||||
overflow-y: auto;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.ds-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem 2.5rem 6rem;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.ds-main section {
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
/* 추적 ID 시스템 */
|
||||
.trk-item {
|
||||
position: relative;
|
||||
transition: outline-color 0.2s;
|
||||
}
|
||||
|
||||
.trk-copyable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trk-copyable:hover {
|
||||
outline: 1px dashed rgb(59 130 246 / 0.5);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.trk-active {
|
||||
outline: 2px solid rgb(59 130 246);
|
||||
outline-offset: 4px;
|
||||
animation: trk-pulse 1.2s ease-out;
|
||||
}
|
||||
|
||||
.trk-item[data-copied='true'] {
|
||||
outline: 2px solid rgb(34 197 94) !important;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.trk-item[data-copied='true']::after {
|
||||
content: '복사됨 ✓';
|
||||
position: absolute;
|
||||
top: -1.5rem;
|
||||
left: 0;
|
||||
font-size: 0.625rem;
|
||||
color: rgb(34 197 94);
|
||||
background: rgb(34 197 94 / 0.15);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes trk-pulse {
|
||||
0% {
|
||||
outline-color: rgb(59 130 246);
|
||||
}
|
||||
50% {
|
||||
outline-color: rgb(59 130 246 / 0.3);
|
||||
}
|
||||
100% {
|
||||
outline-color: rgb(59 130 246);
|
||||
}
|
||||
}
|
||||
|
||||
/* 쇼케이스 그리드 */
|
||||
.ds-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ds-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.ds-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.ds-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.ds-grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
|
||||
.ds-sample {
|
||||
padding: 1rem;
|
||||
background: var(--surface-raised, rgb(30 41 59 / 0.4));
|
||||
border: 1px solid rgb(51 65 85 / 0.4);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.ds-sample-label {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-hint, rgb(148 163 184));
|
||||
font-family: ui-monospace, monospace;
|
||||
margin-top: 0.5rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ds-code {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgb(15 23 42 / 0.7);
|
||||
border: 1px solid rgb(51 65 85 / 0.4);
|
||||
border-radius: 0.375rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(203 213 225);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
174
frontend/src/design-system/DesignSystemApp.tsx
Normal file
174
frontend/src/design-system/DesignSystemApp.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TrkProvider, useTrk } from './lib/TrkContext';
|
||||
import { IntroSection } from './sections/IntroSection';
|
||||
import { TokenSection } from './sections/TokenSection';
|
||||
import { TypographySection } from './sections/TypographySection';
|
||||
import { BadgeSection } from './sections/BadgeSection';
|
||||
import { ButtonSection } from './sections/ButtonSection';
|
||||
import { FormSection } from './sections/FormSection';
|
||||
import { CardSectionShowcase } from './sections/CardSection';
|
||||
import { LayoutSection } from './sections/LayoutSection';
|
||||
import { CatalogSection } from './sections/CatalogSection';
|
||||
import { GuideSection } from './sections/GuideSection';
|
||||
import './DesignSystemApp.css';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
anchor: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'intro', label: '1. 소개', anchor: 'TRK-SEC-intro' },
|
||||
{ id: 'token', label: '2. 테마 · 토큰', anchor: 'TRK-SEC-token' },
|
||||
{ id: 'typography', label: '3. 타이포그래피', anchor: 'TRK-SEC-typography' },
|
||||
{ id: 'badge', label: '4. Badge', anchor: 'TRK-SEC-badge' },
|
||||
{ id: 'button', label: '5. Button', anchor: 'TRK-SEC-button' },
|
||||
{ id: 'form', label: '6. Form', anchor: 'TRK-SEC-form' },
|
||||
{ id: 'card', label: '7. Card / Section', anchor: 'TRK-SEC-card' },
|
||||
{ id: 'layout', label: '8. Layout', anchor: 'TRK-SEC-layout' },
|
||||
{ id: 'catalog', label: '9. 분류 카탈로그', anchor: 'TRK-SEC-catalog' },
|
||||
{ id: 'guide', label: '10. 예외 / 가이드', anchor: 'TRK-SEC-guide' },
|
||||
];
|
||||
|
||||
function DesignSystemShell() {
|
||||
const { copyMode, setCopyMode } = useTrk();
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
const [activeNav, setActiveNav] = useState<string>('intro');
|
||||
|
||||
// 테마 토글
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark', 'light');
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
// 스크롤 감지로 현재 네비 하이라이트
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const id = entry.target.getAttribute('data-section-id');
|
||||
if (id) setActiveNav(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-40% 0px -55% 0px', threshold: 0 },
|
||||
);
|
||||
const sections = document.querySelectorAll('[data-section-id]');
|
||||
sections.forEach((s) => observer.observe(s));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const scrollTo = (anchor: string) => {
|
||||
const el = document.querySelector(`[data-trk="${anchor}"]`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ds-shell">
|
||||
{/* 고정 헤더 */}
|
||||
<header className="ds-header">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-base font-bold text-heading">KCG Design System</h1>
|
||||
<code className="text-[10px] text-hint font-mono">v0.1.0 · 쇼케이스</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyMode}
|
||||
onChange={(e) => setCopyMode(e.target.checked)}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
ID 복사 모드
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="px-3 py-1 rounded-md border border-slate-600/40 text-xs text-label hover:bg-slate-700/30"
|
||||
>
|
||||
{theme === 'dark' ? '☾ Dark' : '☀ Light'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="ds-body">
|
||||
{/* 좌측 네비 */}
|
||||
<nav className="ds-nav">
|
||||
<div className="text-[10px] text-hint uppercase mb-2 tracking-wider">Sections</div>
|
||||
<ul className="space-y-0.5">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollTo(item.anchor)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors ${
|
||||
activeNav === item.id
|
||||
? 'bg-blue-500/15 text-blue-400 border-l-2 border-blue-500'
|
||||
: 'text-label hover:bg-slate-700/20'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-slate-700/40 text-[10px] text-hint space-y-1">
|
||||
<div>
|
||||
<strong className="text-label">추적 ID 체계</strong>
|
||||
</div>
|
||||
<code className="block font-mono">TRK-<카테고리>-<슬러그></code>
|
||||
<div className="mt-2">
|
||||
딥링크: <code className="font-mono">#trk=ID</code>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 우측 컨텐츠 */}
|
||||
<main className="ds-main">
|
||||
<section data-section-id="intro">
|
||||
<IntroSection />
|
||||
</section>
|
||||
<section data-section-id="token">
|
||||
<TokenSection />
|
||||
</section>
|
||||
<section data-section-id="typography">
|
||||
<TypographySection />
|
||||
</section>
|
||||
<section data-section-id="badge">
|
||||
<BadgeSection />
|
||||
</section>
|
||||
<section data-section-id="button">
|
||||
<ButtonSection />
|
||||
</section>
|
||||
<section data-section-id="form">
|
||||
<FormSection />
|
||||
</section>
|
||||
<section data-section-id="card">
|
||||
<CardSectionShowcase />
|
||||
</section>
|
||||
<section data-section-id="layout">
|
||||
<LayoutSection />
|
||||
</section>
|
||||
<section data-section-id="catalog">
|
||||
<CatalogSection />
|
||||
</section>
|
||||
<section data-section-id="guide">
|
||||
<GuideSection />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignSystemApp() {
|
||||
return (
|
||||
<TrkProvider>
|
||||
<DesignSystemShell />
|
||||
</TrkProvider>
|
||||
);
|
||||
}
|
||||
71
frontend/src/design-system/lib/Trk.tsx
Normal file
71
frontend/src/design-system/lib/Trk.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { type ReactNode, type CSSProperties, type MouseEvent } from 'react';
|
||||
import { useTrk } from './TrkContext';
|
||||
|
||||
/**
|
||||
* 쇼케이스 추적 ID 시스템
|
||||
* 각 쇼케이스 항목에 고유 ID를 부여하여:
|
||||
* 1. 호버 시 툴팁으로 ID 노출
|
||||
* 2. "ID 복사 모드"에서 클릭 시 클립보드 복사
|
||||
* 3. URL hash 딥링크 (#trk=TRK-BADGE-critical-sm) 지원
|
||||
*/
|
||||
interface TrkProps {
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** 인라인 요소로 렌더할지 여부 (기본: block) */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export function Trk({ id, children, className = '', style, inline = false }: TrkProps) {
|
||||
const { copyMode, activeId } = useTrk();
|
||||
const isActive = activeId === id;
|
||||
|
||||
const handleClick = async (e: MouseEvent) => {
|
||||
if (!copyMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.dataset.copied = 'true';
|
||||
setTimeout(() => delete el.dataset.copied, 800);
|
||||
} catch {
|
||||
// clipboard API 미지원 시 무시
|
||||
}
|
||||
};
|
||||
|
||||
const Wrapper = inline ? 'span' : 'div';
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
data-trk={id}
|
||||
className={`trk-item ${isActive ? 'trk-active' : ''} ${copyMode ? 'trk-copyable' : ''} ${className}`}
|
||||
style={style}
|
||||
title={id}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrkSectionHeader({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div data-trk={id} className="mb-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-xl font-bold text-heading">{title}</h2>
|
||||
<code className="text-[10px] text-hint font-mono">{id}</code>
|
||||
</div>
|
||||
{description && <p className="text-xs text-hint mt-1">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/design-system/lib/TrkContext.tsx
Normal file
50
frontend/src/design-system/lib/TrkContext.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useContext, createContext, useState, useEffect, type ReactNode } from 'react';
|
||||
|
||||
interface TrkContextValue {
|
||||
copyMode: boolean;
|
||||
setCopyMode: (v: boolean) => void;
|
||||
activeId: string | null;
|
||||
setActiveId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const TrkContext = createContext<TrkContextValue | null>(null);
|
||||
|
||||
export function TrkProvider({ children }: { children: ReactNode }) {
|
||||
const [copyMode, setCopyMode] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// 딥링크 처리: #trk=TRK-BADGE-critical-sm
|
||||
useEffect(() => {
|
||||
const applyHash = () => {
|
||||
const hash = window.location.hash;
|
||||
const match = hash.match(/#trk=([\w-]+)/);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
setActiveId(id);
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector(`[data-trk="${id}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
applyHash();
|
||||
window.addEventListener('hashchange', applyHash);
|
||||
return () => window.removeEventListener('hashchange', applyHash);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TrkContext.Provider value={{ copyMode, setCopyMode, activeId, setActiveId }}>
|
||||
{children}
|
||||
</TrkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useTrk() {
|
||||
const ctx = useContext(TrkContext);
|
||||
if (!ctx) throw new Error('useTrk must be used within TrkProvider');
|
||||
return ctx;
|
||||
}
|
||||
114
frontend/src/design-system/sections/BadgeSection.tsx
Normal file
114
frontend/src/design-system/sections/BadgeSection.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import type { BadgeIntent, BadgeSize } from '@lib/theme/variants';
|
||||
|
||||
const INTENTS: BadgeIntent[] = ['critical', 'high', 'warning', 'info', 'success', 'muted', 'purple', 'cyan'];
|
||||
const SIZES: BadgeSize[] = ['xs', 'sm', 'md', 'lg'];
|
||||
|
||||
const INTENT_USAGE: Record<BadgeIntent, string> = {
|
||||
critical: '심각 · 긴급 · 위험 (빨강 계열)',
|
||||
high: '높음 · 경고 (주황 계열)',
|
||||
warning: '주의 · 보류 (노랑 계열)',
|
||||
info: '일반 · 정보 (파랑 계열, 기본값)',
|
||||
success: '성공 · 완료 · 정상 (초록 계열)',
|
||||
muted: '비활성 · 중립 · 기타 (회색 계열)',
|
||||
purple: '분석 · AI · 특수 (보라 계열)',
|
||||
cyan: '모니터링 · 스트림 (청록 계열)',
|
||||
};
|
||||
|
||||
export function BadgeSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-badge"
|
||||
title="Badge"
|
||||
description="8 intent × 4 size = 32 변형. CVA + cn()로 className override 허용, !important 없음."
|
||||
/>
|
||||
|
||||
{/* 32 변형 그리드 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">32 변형 매트릭스</h3>
|
||||
<Trk id="TRK-BADGE-matrix" className="ds-sample">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">intent ↓ / size →</th>
|
||||
{SIZES.map((size) => (
|
||||
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-2">
|
||||
{size}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{INTENTS.map((intent) => (
|
||||
<tr key={intent}>
|
||||
<td className="text-[11px] text-label font-mono pr-3 py-1.5">{intent}</td>
|
||||
{SIZES.map((size) => (
|
||||
<td key={size} className="px-2 py-1.5">
|
||||
<Trk id={`TRK-BADGE-${intent}-${size}`} inline>
|
||||
<Badge intent={intent} size={size}>
|
||||
{intent.toUpperCase()}
|
||||
</Badge>
|
||||
</Trk>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* intent별 의미 가이드 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Intent 의미 가이드</h3>
|
||||
<div className="ds-grid ds-grid-2">
|
||||
{INTENTS.map((intent) => (
|
||||
<Trk key={intent} id={`TRK-BADGE-usage-${intent}`} className="ds-sample">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge intent={intent} size="md">
|
||||
{intent.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="text-xs text-label">{INTENT_USAGE[intent]}</span>
|
||||
</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 코드 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">사용 예시</h3>
|
||||
<Trk id="TRK-BADGE-usage-code" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
|
||||
// 카탈로그 API와 결합
|
||||
<Badge intent={getAlertLevelIntent('CRITICAL')} size="sm">
|
||||
{getAlertLevelLabel('CRITICAL', t, lang)}
|
||||
</Badge>
|
||||
|
||||
// className override (tailwind-merge가 같은 그룹 충돌 감지)
|
||||
<Badge intent="info" size="md" className="rounded-full px-3">
|
||||
커스텀 둥근 배지
|
||||
</Badge>`}
|
||||
</code>
|
||||
</Trk>
|
||||
|
||||
{/* 금지 패턴 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">금지 패턴</h3>
|
||||
<Trk id="TRK-BADGE-antipattern" className="ds-sample border-red-500/30">
|
||||
<div className="text-xs text-red-400 mb-2">❌ 아래 패턴은 사용하지 마세요</div>
|
||||
<code className="ds-code">
|
||||
{`// ❌ className 직접 작성 (intent prop 무시)
|
||||
<Badge className="bg-red-400 text-white text-[11px]">...</Badge>
|
||||
|
||||
// ❌ !important 사용
|
||||
<Badge className="!bg-red-400 !text-slate-900">...</Badge>
|
||||
|
||||
// ❌ <div className="bg-red-400 ..."> → Badge 컴포넌트 사용 필수
|
||||
<div className="inline-flex bg-red-400 text-white rounded px-2">위험</div>`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
frontend/src/design-system/sections/ButtonSection.tsx
Normal file
131
frontend/src/design-system/sections/ButtonSection.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import type { ButtonVariant, ButtonSize } from '@lib/theme/variants';
|
||||
import { Plus, Download, Trash2, Search, Save } from 'lucide-react';
|
||||
|
||||
const VARIANTS: ButtonVariant[] = ['primary', 'secondary', 'ghost', 'outline', 'destructive'];
|
||||
const SIZES: ButtonSize[] = ['sm', 'md', 'lg'];
|
||||
|
||||
const VARIANT_USAGE: Record<ButtonVariant, string> = {
|
||||
primary: '주요 액션 · 기본 CTA (페이지당 1개 권장)',
|
||||
secondary: '보조 액션 · 툴바 버튼 (기본값)',
|
||||
ghost: '고요한 액션 · 리스트 행 내부',
|
||||
outline: '강조 보조 · 필터 활성화 상태',
|
||||
destructive: '삭제 · 비활성화 등 위험 액션',
|
||||
};
|
||||
|
||||
export function ButtonSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-button"
|
||||
title="Button"
|
||||
description="5 variant × 3 size = 15 변형. CVA 기반, 직접 className 작성 금지."
|
||||
/>
|
||||
|
||||
{/* 매트릭스 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">15 변형 매트릭스</h3>
|
||||
<Trk id="TRK-BUTTON-matrix" className="ds-sample">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">variant ↓ / size →</th>
|
||||
{SIZES.map((size) => (
|
||||
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-3">
|
||||
{size}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{VARIANTS.map((variant) => (
|
||||
<tr key={variant}>
|
||||
<td className="text-[11px] text-label font-mono pr-3 py-2">{variant}</td>
|
||||
{SIZES.map((size) => (
|
||||
<td key={size} className="px-3 py-2">
|
||||
<Trk id={`TRK-BUTTON-${variant}-${size}`} inline>
|
||||
<Button variant={variant} size={size}>
|
||||
{variant}
|
||||
</Button>
|
||||
</Trk>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* 아이콘 버튼 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">아이콘 포함 패턴</h3>
|
||||
<Trk id="TRK-BUTTON-with-icon" className="ds-sample">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||
새 항목 추가
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Download className="w-4 h-4" />}>
|
||||
다운로드
|
||||
</Button>
|
||||
<Button variant="outline" icon={<Search className="w-4 h-4" />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button variant="ghost" icon={<Save className="w-4 h-4" />}>
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="destructive" icon={<Trash2 className="w-4 h-4" />}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* 상태 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">상태</h3>
|
||||
<Trk id="TRK-BUTTON-states" className="ds-sample">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary">Normal</Button>
|
||||
<Button variant="primary" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* variant 의미 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Variant 의미 가이드</h3>
|
||||
<div className="ds-grid ds-grid-2">
|
||||
{VARIANTS.map((variant) => (
|
||||
<Trk key={variant} id={`TRK-BUTTON-usage-${variant}`} className="ds-sample">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant={variant} size="sm">
|
||||
{variant}
|
||||
</Button>
|
||||
<span className="text-xs text-label flex-1">{VARIANT_USAGE[variant]}</span>
|
||||
</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">사용 예시</h3>
|
||||
<Trk id="TRK-BUTTON-usage-code" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { Button } from '@shared/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
<Button variant="primary" size="md" icon={<Plus className="w-4 h-4" />}>
|
||||
새 보고서
|
||||
</Button>
|
||||
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
||||
삭제
|
||||
</Button>
|
||||
|
||||
// 금지
|
||||
// ❌ <button className="bg-blue-600 text-white px-4 py-2 rounded-lg">
|
||||
// ❌ <Button className="bg-red-500"> → variant="destructive" 사용`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
111
frontend/src/design-system/sections/CardSection.tsx
Normal file
111
frontend/src/design-system/sections/CardSection.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
|
||||
import { Database, Activity, Bell } from 'lucide-react';
|
||||
|
||||
export function CardSectionShowcase() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-card"
|
||||
title="Card · Section"
|
||||
description="4 variant (default/elevated/inner/transparent). CardHeader+CardTitle+CardContent 조합."
|
||||
/>
|
||||
|
||||
{/* 4 variant */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Variant</h3>
|
||||
<div className="ds-grid ds-grid-2">
|
||||
<Trk id="TRK-CARD-default" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">variant=default</label>
|
||||
<Card variant="default">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Database className="w-3.5 h-3.5 text-blue-400" />
|
||||
데이터베이스
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">PostgreSQL</span>
|
||||
<span className="text-label">v15.4 운영중</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">연결</span>
|
||||
<span className="text-label">8 / 20</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-CARD-elevated" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">variant=elevated (기본값)</label>
|
||||
<Card variant="elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Activity className="w-3.5 h-3.5 text-green-400" />
|
||||
시스템 상태
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">API</span>
|
||||
<span className="text-green-400">정상</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">Prediction</span>
|
||||
<span className="text-green-400">5분 주기</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-CARD-inner" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">variant=inner</label>
|
||||
<Card variant="inner">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Bell className="w-3.5 h-3.5 text-yellow-400" />
|
||||
알림
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[11px] text-label">중첩 카드 내부에 사용. 외부 카드보다 한 단계 낮은 depth.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-CARD-transparent" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">variant=transparent</label>
|
||||
<Card variant="transparent">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>투명 카드</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[11px] text-label">배경/보더 없이 구조만 활용 (그룹핑 목적).</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">사용 예시</h3>
|
||||
<Trk id="TRK-CARD-usage-code" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
|
||||
import { Database } from 'lucide-react';
|
||||
|
||||
<Card variant="elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Database className="w-3.5 h-3.5 text-blue-400" />
|
||||
데이터베이스
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* 콘텐츠 */}
|
||||
</CardContent>
|
||||
</Card>`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
278
frontend/src/design-system/sections/CatalogSection.tsx
Normal file
278
frontend/src/design-system/sections/CatalogSection.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
import { ALERT_LEVELS } from '@shared/constants/alertLevels';
|
||||
import { VIOLATION_TYPES } from '@shared/constants/violationTypes';
|
||||
import { EVENT_STATUSES } from '@shared/constants/eventStatuses';
|
||||
import { ENFORCEMENT_ACTIONS } from '@shared/constants/enforcementActions';
|
||||
import { ENFORCEMENT_RESULTS } from '@shared/constants/enforcementResults';
|
||||
import { PATROL_STATUSES } from '@shared/constants/patrolStatuses';
|
||||
import { ENGINE_SEVERITIES } from '@shared/constants/engineSeverities';
|
||||
import { DEVICE_STATUSES } from '@shared/constants/deviceStatuses';
|
||||
import {
|
||||
PARENT_RESOLUTION_STATUSES,
|
||||
LABEL_SESSION_STATUSES,
|
||||
} from '@shared/constants/parentResolutionStatuses';
|
||||
import {
|
||||
MODEL_STATUSES,
|
||||
QUALITY_GATE_STATUSES,
|
||||
EXPERIMENT_STATUSES,
|
||||
} from '@shared/constants/modelDeploymentStatuses';
|
||||
import { GEAR_GROUP_TYPES } from '@shared/constants/gearGroupTypes';
|
||||
import { DARK_VESSEL_PATTERNS } from '@shared/constants/darkVesselPatterns';
|
||||
import { USER_ACCOUNT_STATUSES } from '@shared/constants/userAccountStatuses';
|
||||
import { LOGIN_RESULTS } from '@shared/constants/loginResultStatuses';
|
||||
import { PERMIT_STATUSES, GEAR_JUDGMENTS } from '@shared/constants/permissionStatuses';
|
||||
import {
|
||||
VESSEL_SURVEILLANCE_STATUSES,
|
||||
VESSEL_RISK_RINGS,
|
||||
} from '@shared/constants/vesselAnalysisStatuses';
|
||||
import { CONNECTION_STATUSES } from '@shared/constants/connectionStatuses';
|
||||
import { TRAINING_ZONE_TYPES } from '@shared/constants/trainingZoneTypes';
|
||||
|
||||
/** 카탈로그 메타 공통 속성 (일부만 있을 수 있음) */
|
||||
interface AnyMeta {
|
||||
code: string;
|
||||
intent?: BadgeIntent;
|
||||
fallback?: { ko: string; en: string };
|
||||
classes?: string | { bg?: string; text?: string; border?: string };
|
||||
label?: string;
|
||||
}
|
||||
|
||||
type AnyCatalog = Record<string, AnyMeta>;
|
||||
|
||||
function getLabel(meta: AnyMeta): string {
|
||||
return meta.fallback?.ko ?? meta.label ?? meta.code;
|
||||
}
|
||||
|
||||
/** classes가 문자열인 경우 그대로, 객체인 경우 bg+text 조합 */
|
||||
function getFallbackClasses(meta: AnyMeta): string | undefined {
|
||||
if (typeof meta.classes === 'string') return meta.classes;
|
||||
if (typeof meta.classes === 'object' && meta.classes) {
|
||||
return [meta.classes.bg, meta.classes.text, meta.classes.border].filter(Boolean).join(' ');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 카탈로그 항목을 Badge 또는 fallback span으로 렌더 */
|
||||
function CatalogBadges({ catalog, idPrefix }: { catalog: AnyCatalog; idPrefix: string }) {
|
||||
const entries = Object.values(catalog);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{entries.map((meta) => {
|
||||
const label = getLabel(meta);
|
||||
const trkId = `${idPrefix}-${meta.code}`;
|
||||
const content: ReactNode = meta.intent ? (
|
||||
<Badge intent={meta.intent} size="sm">
|
||||
{label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md border text-[12px] font-semibold ${getFallbackClasses(meta) ?? 'bg-slate-500/20 text-slate-300 border-slate-500/30'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Trk key={meta.code} id={trkId} inline>
|
||||
{content}
|
||||
</Trk>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CatalogEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
catalog: AnyCatalog;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const CATALOGS: CatalogEntry[] = [
|
||||
{
|
||||
id: 'TRK-CAT-alert-level',
|
||||
title: '위험도 · AlertLevel',
|
||||
description: 'CRITICAL / HIGH / MEDIUM / LOW — 모든 이벤트/알림 배지',
|
||||
catalog: ALERT_LEVELS as unknown as AnyCatalog,
|
||||
source: 'backend code_master EVENT_LEVEL',
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-violation-type',
|
||||
title: '위반 유형 · ViolationType',
|
||||
description: '중국불법조업 / 환적의심 / EEZ침범 등',
|
||||
catalog: VIOLATION_TYPES as unknown as AnyCatalog,
|
||||
source: 'backend ViolationType enum',
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-event-status',
|
||||
title: '이벤트 상태 · EventStatus',
|
||||
description: 'NEW / ACK / IN_PROGRESS / RESOLVED / FALSE_POSITIVE',
|
||||
catalog: EVENT_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-enforcement-action',
|
||||
title: '단속 조치 · EnforcementAction',
|
||||
description: 'CAPTURE / INSPECT / WARN / DISPERSE / TRACK / EVIDENCE',
|
||||
catalog: ENFORCEMENT_ACTIONS as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-enforcement-result',
|
||||
title: '단속 결과 · EnforcementResult',
|
||||
description: 'PUNISHED / REFERRED / WARNED / RELEASED / FALSE_POSITIVE',
|
||||
catalog: ENFORCEMENT_RESULTS as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-patrol-status',
|
||||
title: '함정 상태 · PatrolStatus',
|
||||
description: '출동 / 순찰 / 복귀 / 정박 / 정비 / 대기',
|
||||
catalog: PATROL_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-engine-severity',
|
||||
title: '엔진 심각도 · EngineSeverity',
|
||||
description: 'AI 모델/분석엔진 오류 심각도',
|
||||
catalog: ENGINE_SEVERITIES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-device-status',
|
||||
title: '함정 Agent 장치 상태 · DeviceStatus',
|
||||
description: 'ONLINE / OFFLINE / SYNCING / NOT_DEPLOYED',
|
||||
catalog: DEVICE_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-parent-resolution',
|
||||
title: '모선 확정 상태 · ParentResolutionStatus',
|
||||
description: 'PENDING / CONFIRMED / REJECTED / REVIEWING',
|
||||
catalog: PARENT_RESOLUTION_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-label-session',
|
||||
title: '라벨 세션 · LabelSessionStatus',
|
||||
description: '모선 학습 세션 상태',
|
||||
catalog: LABEL_SESSION_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-model-status',
|
||||
title: 'AI 모델 상태 · ModelStatus',
|
||||
description: 'DEV / STAGING / CANARY / PROD / ARCHIVED',
|
||||
catalog: MODEL_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-quality-gate',
|
||||
title: '품질 게이트 · QualityGateStatus',
|
||||
description: '모델 배포 전 품질 검증',
|
||||
catalog: QUALITY_GATE_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-experiment',
|
||||
title: 'ML 실험 · ExperimentStatus',
|
||||
description: 'MLOps 실험 상태',
|
||||
catalog: EXPERIMENT_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-gear-group',
|
||||
title: '어구 그룹 유형 · GearGroupType',
|
||||
description: 'FLEET / GEAR_IN_ZONE / GEAR_OUT_ZONE',
|
||||
catalog: GEAR_GROUP_TYPES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-dark-vessel',
|
||||
title: '다크베셀 패턴 · DarkVesselPattern',
|
||||
description: 'AIS 끊김/스푸핑 패턴 5종',
|
||||
catalog: DARK_VESSEL_PATTERNS as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-user-account',
|
||||
title: '사용자 계정 상태 · UserAccountStatus',
|
||||
description: 'ACTIVE / LOCKED / INACTIVE / PENDING',
|
||||
catalog: USER_ACCOUNT_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-login-result',
|
||||
title: '로그인 결과 · LoginResult',
|
||||
description: 'SUCCESS / FAILED / LOCKED',
|
||||
catalog: LOGIN_RESULTS as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-permit-status',
|
||||
title: '허가 상태 · PermitStatus',
|
||||
description: '선박 허가 유효/만료/정지',
|
||||
catalog: PERMIT_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-gear-judgment',
|
||||
title: '어구 판정 · GearJudgment',
|
||||
description: '합법 / 의심 / 불법',
|
||||
catalog: GEAR_JUDGMENTS as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-vessel-surveillance',
|
||||
title: '선박 감시 상태 · VesselSurveillanceStatus',
|
||||
description: '관심선박 추적 상태',
|
||||
catalog: VESSEL_SURVEILLANCE_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-vessel-risk-ring',
|
||||
title: '선박 위험 링 · VesselRiskRing',
|
||||
description: '지도 마커 위험도 링',
|
||||
catalog: VESSEL_RISK_RINGS as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-connection',
|
||||
title: '연결 상태 · ConnectionStatus',
|
||||
description: 'OK / WARNING / ERROR',
|
||||
catalog: CONNECTION_STATUSES as unknown as AnyCatalog,
|
||||
},
|
||||
{
|
||||
id: 'TRK-CAT-training-zone',
|
||||
title: '훈련 수역 · TrainingZoneType',
|
||||
description: 'NAVY / AIRFORCE / ARMY / ADD / KCG',
|
||||
catalog: TRAINING_ZONE_TYPES as unknown as AnyCatalog,
|
||||
},
|
||||
];
|
||||
|
||||
export function CatalogSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-catalog"
|
||||
title="분류 카탈로그 (19+)"
|
||||
description="백엔드 enum/code_master 기반 SSOT. 모든 위험도·상태·유형 배지의 단일 정의."
|
||||
/>
|
||||
|
||||
<Trk id="TRK-CAT-intro" className="ds-sample mb-4">
|
||||
<p className="text-xs text-label leading-relaxed">
|
||||
페이지에서 배지를 렌더할 때는 반드시 카탈로그 API를 사용한다:
|
||||
</p>
|
||||
<code className="ds-code mt-2">
|
||||
{`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
|
||||
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
|
||||
{getAlertLevelLabel(event.level, t, lang)}
|
||||
</Badge>`}
|
||||
</code>
|
||||
</Trk>
|
||||
|
||||
{CATALOGS.map((entry) => (
|
||||
<Trk key={entry.id} id={entry.id} className="ds-sample mb-3">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-semibold text-heading">{entry.title}</h3>
|
||||
<code className="text-[10px] text-hint font-mono">{entry.id}</code>
|
||||
</div>
|
||||
<p className="text-[11px] text-hint">{entry.description}</p>
|
||||
{entry.source && (
|
||||
<p className="text-[10px] text-hint italic mt-0.5">출처: {entry.source}</p>
|
||||
)}
|
||||
</div>
|
||||
<CatalogBadges catalog={entry.catalog} idPrefix={entry.id} />
|
||||
</Trk>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
frontend/src/design-system/sections/FormSection.tsx
Normal file
124
frontend/src/design-system/sections/FormSection.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { Textarea } from '@shared/components/ui/textarea';
|
||||
import { Checkbox } from '@shared/components/ui/checkbox';
|
||||
import { Radio } from '@shared/components/ui/radio';
|
||||
|
||||
export function FormSection() {
|
||||
const [radio, setRadio] = useState('a');
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-form"
|
||||
title="Form 요소"
|
||||
description="Input · Select · Textarea · Checkbox · Radio. 공통 스타일 토큰 공유."
|
||||
/>
|
||||
|
||||
{/* Input 사이즈 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Input · 사이즈</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<Trk key={size} id={`TRK-FORM-input-${size}`} className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
|
||||
<Input size={size} placeholder={`Input size=${size}`} />
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input 상태 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Input · 상태</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
<Trk id="TRK-FORM-input-default" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">state=default</label>
|
||||
<Input placeholder="기본 상태" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-error" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">state=error</label>
|
||||
<Input state="error" defaultValue="잘못된 값" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-success" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">state=success</label>
|
||||
<Input state="success" defaultValue="유효한 값" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-disabled" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">disabled</label>
|
||||
<Input disabled defaultValue="비활성" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-readonly" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">readOnly</label>
|
||||
<Input readOnly defaultValue="읽기 전용" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-type-number" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">type=number</label>
|
||||
<Input type="number" defaultValue={42} />
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
{/* Select */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Select</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<Trk key={size} id={`TRK-FORM-select-${size}`} className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
|
||||
<Select size={size} defaultValue="">
|
||||
<option value="">전체 선택</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
</Select>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Textarea</h3>
|
||||
<Trk id="TRK-FORM-textarea" className="ds-sample">
|
||||
<Textarea placeholder="여러 줄 입력..." rows={3} />
|
||||
</Trk>
|
||||
|
||||
{/* Checkbox */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Checkbox</h3>
|
||||
<Trk id="TRK-FORM-checkbox" className="ds-sample">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Checkbox label="옵션 1" defaultChecked />
|
||||
<Checkbox label="옵션 2" />
|
||||
<Checkbox label="비활성" disabled />
|
||||
<Checkbox label="체크됨 비활성" disabled defaultChecked />
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* Radio */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radio</h3>
|
||||
<Trk id="TRK-FORM-radio" className="ds-sample">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Radio name="grp" label="선택 A" checked={radio === 'a'} onChange={() => setRadio('a')} />
|
||||
<Radio name="grp" label="선택 B" checked={radio === 'b'} onChange={() => setRadio('b')} />
|
||||
<Radio name="grp" label="선택 C" checked={radio === 'c'} onChange={() => setRadio('c')} />
|
||||
<Radio name="grp" label="비활성" disabled />
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">사용 예시</h3>
|
||||
<Trk id="TRK-FORM-usage-code" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { Checkbox } from '@shared/components/ui/checkbox';
|
||||
|
||||
<Input size="md" placeholder="검색어 입력" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
|
||||
<Select size="sm" value={level} onChange={(e) => setLevel(e.target.value)}>
|
||||
<option value="">전체 등급</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</Select>
|
||||
|
||||
<Checkbox label="활성화" checked={active} onChange={(e) => setActive(e.target.checked)} />`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
frontend/src/design-system/sections/GuideSection.tsx
Normal file
130
frontend/src/design-system/sections/GuideSection.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
|
||||
export function GuideSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-guide"
|
||||
title="예외 처리 · 가이드"
|
||||
description="공통 패턴에서 벗어날 때의 명시적 규칙"
|
||||
/>
|
||||
|
||||
<Trk id="TRK-GUIDE-fullbleed" className="ds-sample">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">언제 fullBleed를 쓰는가?</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
|
||||
<li>
|
||||
<strong className="text-heading">지도 중심 페이지</strong> — 가장자리까지 지도가 확장되어야 할 때 (LiveMapView,
|
||||
VesselDetail)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">3단 패널 레이아웃</strong> — 좌측 리스트 + 중앙 콘텐츠 + 우측 상세 구조로
|
||||
padding이 상위에서 관리되지 않는 경우
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">높이 100% 요구</strong> — 브라우저 뷰포트 전체를 차지해야 할 때
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-hint mt-2">
|
||||
예외: <code className="font-mono"><PageContainer fullBleed></code>로 명시.{' '}
|
||||
<code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> 같은 negative margin 해킹 금지.
|
||||
</p>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-GUIDE-classname-override" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">언제 className override를 허용하는가?</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
|
||||
<li>
|
||||
<strong className="text-heading">레이아웃 보정</strong> — 부모 컨테이너 특성상 width/margin 조정이 필요할 때 (
|
||||
<code className="font-mono">w-48</code>, <code className="font-mono">flex-1</code> 등)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">반응형 조정</strong> — sm/md/lg 브레이크포인트별 조정
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-label mt-2">
|
||||
<strong className="text-green-400">허용:</strong>{' '}
|
||||
<code className="font-mono">
|
||||
<Badge intent="info" className="w-full justify-center">
|
||||
</code>
|
||||
</p>
|
||||
<p className="text-xs text-label">
|
||||
<strong className="text-red-400">금지:</strong>{' '}
|
||||
<code className="font-mono"><Badge className="bg-red-500 text-white"></code>{' '}
|
||||
— <span className="text-hint">intent prop을 대체하려 하지 말 것</span>
|
||||
</p>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-GUIDE-dynamic-color" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">동적 hex 색상이 필요한 경우</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
|
||||
<li>
|
||||
DB에서 사용자가 정의한 색상 (예: Role.colorHex) → <code className="font-mono">style={`{{ background: role.colorHex }}`}</code>{' '}
|
||||
인라인 허용
|
||||
</li>
|
||||
<li>
|
||||
차트 팔레트 → <code className="font-mono">getAlertLevelHex(level)</code> 같은 카탈로그 API에서 hex 조회
|
||||
</li>
|
||||
<li>
|
||||
지도 마커 deck.gl → RGB 튜플로 변환 필요, 카탈로그 hex 기반
|
||||
</li>
|
||||
</ul>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-GUIDE-anti-patterns" className="ds-sample mt-3 border border-red-500/30">
|
||||
<h3 className="text-sm font-semibold text-red-400 mb-2">금지 패턴 체크리스트</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 leading-relaxed">
|
||||
<li>❌ <code className="font-mono">!important</code> prefix (<code className="font-mono">!bg-red-500</code>)</li>
|
||||
<li>❌ <code className="font-mono">className="bg-X text-Y"</code>로 Badge 스타일을 재정의</li>
|
||||
<li>❌ <code className="font-mono"><button className="bg-blue-600 ..."></code> — Button 컴포넌트 사용 필수</li>
|
||||
<li>❌ <code className="font-mono"><input className="bg-surface ..."></code> — Input 컴포넌트 사용 필수</li>
|
||||
<li>❌ <code className="font-mono">p-4 space-y-5</code> 같은 제각각 padding — PageContainer size 사용</li>
|
||||
<li>❌ <code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> negative margin 해킹 — fullBleed 사용</li>
|
||||
<li>❌ 페이지에서 <code className="font-mono">const STATUS_COLORS = {`{...}`}</code> 로컬 상수 정의 — shared/constants 카탈로그 사용</li>
|
||||
<li>❌ <code className="font-mono">date.toLocaleString('ko-KR', ...)</code> 직접 호출 — <code className="font-mono">formatDateTime</code> 사용</li>
|
||||
</ul>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-GUIDE-new-page" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">새 페이지 작성 템플릿</h3>
|
||||
<code className="ds-code">
|
||||
{`import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { Shield, Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function MyNewPage() {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
const lang = i18n.language as 'ko' | 'en';
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title="새 페이지"
|
||||
description="페이지 설명"
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Section title="데이터 목록">
|
||||
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
|
||||
{getAlertLevelLabel('HIGH', t, lang)}
|
||||
</Badge>
|
||||
<span className="text-xs text-hint">{formatDateTime(row.createdAt)}</span>
|
||||
</Section>
|
||||
</PageContainer>
|
||||
);
|
||||
}`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
frontend/src/design-system/sections/IntroSection.tsx
Normal file
68
frontend/src/design-system/sections/IntroSection.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
|
||||
export function IntroSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-intro"
|
||||
title="KCG Design System"
|
||||
description="kcg-ai-monitoring 프론트엔드의 시각 언어 · 컴포넌트 · 레이아웃 규칙 단일 참조 페이지"
|
||||
/>
|
||||
|
||||
<Trk id="TRK-INTRO-overview" className="ds-sample">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">이 페이지의 목적</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
|
||||
<li>
|
||||
<strong className="text-heading">모든 스타일의 뼈대</strong> — 페이지 파일은 이 쇼케이스에 정의된 컴포넌트와 토큰만
|
||||
사용한다. 임의의 `className="bg-red-600"` 같은 직접 스타일은 금지.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">미세조정의 단일 지점</strong> — 색상 · 여백 · 텍스트 크기 등 모든 변경은 이 페이지에서
|
||||
먼저 시각적으로 검증한 후 실제 페이지에 적용한다.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">ID 기반 참조</strong> — 각 쇼케이스 항목에 <code>TRK-*</code> 추적 ID가 부여되어 있어,
|
||||
특정 변형을 정확히 가리키며 논의 · 수정이 가능하다.
|
||||
</li>
|
||||
</ul>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-INTRO-howto" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">사용 방법</h3>
|
||||
<ol className="text-xs text-label space-y-1.5 list-decimal list-inside leading-relaxed">
|
||||
<li>
|
||||
상단 <strong className="text-heading">"ID 복사 모드"</strong> 체크박스를 켜면 쇼케이스 항목 클릭 시 ID가 클립보드에
|
||||
복사된다.
|
||||
</li>
|
||||
<li>
|
||||
URL 해시 <code className="font-mono text-blue-400">#trk=TRK-BADGE-critical-sm</code> 으로 특정 항목 딥링크 — 스크롤
|
||||
+ 하이라이트.
|
||||
</li>
|
||||
<li>
|
||||
상단 <strong className="text-heading">Dark / Light</strong> 토글로 두 테마에서 동시에 검증.
|
||||
</li>
|
||||
<li>
|
||||
좌측 네비게이션으로 섹션 이동. 각 섹션 제목 옆에 섹션의 <code>TRK-SEC-*</code> ID가 노출된다.
|
||||
</li>
|
||||
</ol>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-INTRO-naming" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">추적 ID 명명 규칙</h3>
|
||||
<code className="ds-code">
|
||||
{`TRK-<카테고리>-<슬러그>[-<변형>]
|
||||
|
||||
예시:
|
||||
TRK-TOKEN-text-heading → 시맨틱 토큰 --text-heading
|
||||
TRK-BADGE-critical-sm → Badge intent=critical size=sm
|
||||
TRK-BUTTON-primary-md → Button variant=primary size=md
|
||||
TRK-LAYOUT-page-container → PageContainer 루트
|
||||
TRK-CAT-alert-level-HIGH → alertLevels 카탈로그의 HIGH 배지
|
||||
TRK-SEC-badge → Badge 섹션 자체
|
||||
|
||||
카테고리: SEC, INTRO, TOKEN, TYPO, BADGE, BUTTON, FORM, CARD, LAYOUT, CAT, GUIDE`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
177
frontend/src/design-system/sections/LayoutSection.tsx
Normal file
177
frontend/src/design-system/sections/LayoutSection.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import { Shield, BarChart3, Plus, Search } from 'lucide-react';
|
||||
|
||||
export function LayoutSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-layout"
|
||||
title="Layout 컴포넌트"
|
||||
description="PageContainer · PageHeader · Section. 40+ 페이지 레이아웃 표준."
|
||||
/>
|
||||
|
||||
{/* PageContainer */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">PageContainer</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
<Trk id="TRK-LAYOUT-page-container-sm" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size=sm (p-4 space-y-3)</label>
|
||||
<div className="border border-dashed border-slate-600/40 rounded">
|
||||
<PageContainer size="sm">
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
</PageContainer>
|
||||
</div>
|
||||
</Trk>
|
||||
<Trk id="TRK-LAYOUT-page-container-md" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size=md · 기본값 (p-5 space-y-4)</label>
|
||||
<div className="border border-dashed border-slate-600/40 rounded">
|
||||
<PageContainer size="md">
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
</PageContainer>
|
||||
</div>
|
||||
</Trk>
|
||||
<Trk id="TRK-LAYOUT-page-container-lg" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size=lg (p-6 space-y-4)</label>
|
||||
<div className="border border-dashed border-slate-600/40 rounded">
|
||||
<PageContainer size="lg">
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
</PageContainer>
|
||||
</div>
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
<Trk id="TRK-LAYOUT-page-container-fullbleed" className="ds-sample mt-3">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">
|
||||
fullBleed — 지도/풀화면 페이지 (padding 0, space-y 0)
|
||||
</label>
|
||||
<div className="border border-dashed border-slate-600/40 rounded h-16 relative">
|
||||
<PageContainer fullBleed className="h-full">
|
||||
<div className="h-full bg-blue-500/30 rounded flex items-center justify-center text-xs text-heading">
|
||||
fullBleed: 가장자리까지 콘텐츠 (LiveMapView / VesselDetail 패턴)
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* PageHeader */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">PageHeader</h3>
|
||||
<div className="space-y-3">
|
||||
<Trk id="TRK-LAYOUT-page-header-simple" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">단순형 (title + description)</label>
|
||||
<PageHeader title="대시보드" description="실시간 종합 상황판" />
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-LAYOUT-page-header-icon" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">아이콘 포함</label>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title="권한 관리"
|
||||
description="사용자/역할/권한 설정"
|
||||
/>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-LAYOUT-page-header-demo" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">데모 배지</label>
|
||||
<PageHeader
|
||||
icon={BarChart3}
|
||||
iconColor="text-purple-400"
|
||||
title="AI 모델 관리"
|
||||
description="ML 모델 배포 상태"
|
||||
demo
|
||||
/>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-LAYOUT-page-header-actions" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">우측 액션 슬롯</label>
|
||||
<PageHeader
|
||||
title="보고서 관리"
|
||||
description="단속 증거 보고서 목록"
|
||||
actions={
|
||||
<>
|
||||
<Input size="sm" placeholder="검색..." className="w-40" />
|
||||
<Button variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" icon={<Plus className="w-3.5 h-3.5" />}>
|
||||
새 보고서
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
{/* Section */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Section (Card 단축)</h3>
|
||||
<div className="ds-grid ds-grid-2">
|
||||
<Trk id="TRK-LAYOUT-section-basic" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">기본 Section</label>
|
||||
<Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">동해</span>
|
||||
<span className="text-label">23건</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">서해</span>
|
||||
<span className="text-label">12건</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-LAYOUT-section-actions" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">우측 액션</label>
|
||||
<Section
|
||||
title="최근 이벤트"
|
||||
icon={BarChart3}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm">
|
||||
전체 보기
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="text-[11px] text-hint">이벤트 3건</div>
|
||||
</Section>
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
{/* 전체 조합 예시 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">전체 조합 예시</h3>
|
||||
<Trk id="TRK-LAYOUT-full-example" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Shield, Plus } from 'lucide-react';
|
||||
|
||||
export function AccessControlPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title="권한 관리"
|
||||
description="사용자별 역할 및 권한 매핑"
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||
역할 추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Section title="사용자 목록">
|
||||
<DataTable ... />
|
||||
</Section>
|
||||
</PageContainer>
|
||||
);
|
||||
}`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
frontend/src/design-system/sections/TokenSection.tsx
Normal file
160
frontend/src/design-system/sections/TokenSection.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
|
||||
/** 시맨틱 색상 토큰 목록. theme.css :root 정의 기준 */
|
||||
const SURFACE_TOKENS = [
|
||||
{ id: 'background', label: '--background', desc: '페이지 최하위 배경' },
|
||||
{ id: 'card', label: '--card', desc: 'Card 컴포넌트 배경' },
|
||||
{ id: 'surface-raised', label: '--surface-raised', desc: '카드 내부 섹션' },
|
||||
{ id: 'surface-overlay', label: '--surface-overlay', desc: '모달/오버레이' },
|
||||
{ id: 'muted', label: '--muted', desc: '약한 배경 (inactive/disabled)' },
|
||||
{ id: 'popover', label: '--popover', desc: '드롭다운/툴팁 배경' },
|
||||
];
|
||||
|
||||
const TEXT_TOKENS = [
|
||||
{ id: 'text-heading', label: '--text-heading', example: '제목 Heading', desc: 'h1~h4, 강조 텍스트' },
|
||||
{ id: 'text-label', label: '--text-label', example: '본문 Label', desc: '일반 본문, 라벨' },
|
||||
{ id: 'text-hint', label: '--text-hint', example: '보조 Hint', desc: '설명, 힌트, placeholder' },
|
||||
{ id: 'text-on-vivid', label: '--text-on-vivid', example: '컬러풀 위 텍스트', desc: '-600/-700 진한 배경 위 (버튼)', bg: '#2563eb' },
|
||||
{ id: 'text-on-bright', label: '--text-on-bright', example: '밝은 위 텍스트', desc: '-300/-400 밝은 배경 위 (배지)', bg: '#60a5fa' },
|
||||
];
|
||||
|
||||
const BRAND_COLORS = [
|
||||
{ id: 'primary', label: '--primary', desc: '기본 강조색 (파랑)' },
|
||||
{ id: 'destructive', label: '--destructive', desc: '위험/삭제 (빨강)' },
|
||||
{ id: 'ring', label: '--ring', desc: 'focus ring' },
|
||||
{ id: 'border', label: '--border', desc: '경계선' },
|
||||
];
|
||||
|
||||
const CHART_COLORS = [
|
||||
{ id: 'chart-1', label: '--chart-1' },
|
||||
{ id: 'chart-2', label: '--chart-2' },
|
||||
{ id: 'chart-3', label: '--chart-3' },
|
||||
{ id: 'chart-4', label: '--chart-4' },
|
||||
{ id: 'chart-5', label: '--chart-5' },
|
||||
];
|
||||
|
||||
const RADIUS_SCALE = [
|
||||
{ id: 'sm', label: 'radius-sm', cls: 'rounded-sm', px: 'calc(radius - 4px)' },
|
||||
{ id: 'md', label: 'radius-md', cls: 'rounded-md', px: 'calc(radius - 2px)' },
|
||||
{ id: 'lg', label: 'radius-lg', cls: 'rounded-lg', px: '0.5rem (default)' },
|
||||
{ id: 'xl', label: 'radius-xl', cls: 'rounded-xl', px: 'calc(radius + 4px)' },
|
||||
{ id: 'full', label: 'rounded-full', cls: 'rounded-full', px: '9999px' },
|
||||
];
|
||||
|
||||
const SPACING_SCALE = [
|
||||
{ id: '1', size: 4 }, { id: '2', size: 8 }, { id: '3', size: 12 },
|
||||
{ id: '4', size: 16 }, { id: '5', size: 20 }, { id: '6', size: 24 },
|
||||
{ id: '8', size: 32 }, { id: '10', size: 40 },
|
||||
];
|
||||
|
||||
export function TokenSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-token"
|
||||
title="테마 · 시맨틱 토큰"
|
||||
description="모든 색상/여백은 CSS 변수로 관리. 다크/라이트 모드는 값만 바뀌고 이름은 동일."
|
||||
/>
|
||||
|
||||
{/* Surface 토큰 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-4">Surface / 배경</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
{SURFACE_TOKENS.map((t) => (
|
||||
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
|
||||
<div
|
||||
className={`h-16 rounded-md border border-slate-700/40 mb-2`}
|
||||
style={{ background: `var(--${t.id})` }}
|
||||
/>
|
||||
<div className="text-xs text-heading font-semibold">{t.label}</div>
|
||||
<div className="text-[10px] text-hint">{t.desc}</div>
|
||||
<div className="ds-sample-label">TRK-TOKEN-{t.id}</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 텍스트 토큰 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Text / 텍스트</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
{TEXT_TOKENS.map((t) => (
|
||||
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
|
||||
<div
|
||||
className="h-16 rounded-md flex items-center justify-center text-sm font-semibold mb-2"
|
||||
style={{
|
||||
background: t.bg ?? 'var(--surface-overlay)',
|
||||
color: `var(--${t.id})`,
|
||||
}}
|
||||
>
|
||||
{t.example}
|
||||
</div>
|
||||
<div className="text-xs text-heading font-semibold">{t.label}</div>
|
||||
<div className="text-[10px] text-hint">{t.desc}</div>
|
||||
<div className="ds-sample-label">TRK-TOKEN-{t.id}</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 브랜드 색 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Brand / 기능색</h3>
|
||||
<div className="ds-grid ds-grid-4">
|
||||
{BRAND_COLORS.map((t) => (
|
||||
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
|
||||
<div
|
||||
className="h-12 rounded-md border border-slate-700/40 mb-2"
|
||||
style={{ background: `var(--${t.id})` }}
|
||||
/>
|
||||
<div className="text-xs text-heading font-semibold">{t.label}</div>
|
||||
<div className="text-[10px] text-hint">{t.desc}</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart 색 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Chart / 차트 팔레트</h3>
|
||||
<Trk id="TRK-TOKEN-chart-palette" className="ds-sample">
|
||||
<div className="flex gap-2">
|
||||
{CHART_COLORS.map((t) => (
|
||||
<div key={t.id} className="flex-1 text-center">
|
||||
<div
|
||||
className="h-12 rounded-md border border-slate-700/40 mb-1"
|
||||
style={{ background: `var(--${t.id})` }}
|
||||
/>
|
||||
<div className="text-[10px] text-hint font-mono">{t.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ds-sample-label">TRK-TOKEN-chart-palette</div>
|
||||
</Trk>
|
||||
|
||||
{/* Radius 스케일 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radius / 모서리 반경</h3>
|
||||
<Trk id="TRK-TOKEN-radius-scale" className="ds-sample">
|
||||
<div className="flex gap-4 items-end">
|
||||
{RADIUS_SCALE.map((r) => (
|
||||
<div key={r.id} className="flex-1 text-center">
|
||||
<div className={`h-16 bg-blue-500 ${r.cls} mb-1`} />
|
||||
<div className="text-[11px] text-heading">{r.label}</div>
|
||||
<div className="text-[9px] text-hint font-mono">{r.px}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* Spacing 스케일 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Spacing / 간격 스케일</h3>
|
||||
<Trk id="TRK-TOKEN-spacing-scale" className="ds-sample">
|
||||
<div className="space-y-1.5">
|
||||
{SPACING_SCALE.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-3">
|
||||
<code className="text-[10px] text-hint font-mono w-12">p-{s.id}</code>
|
||||
<div
|
||||
className="h-4 bg-blue-500 rounded-sm"
|
||||
style={{ width: `${s.size}px` }}
|
||||
/>
|
||||
<span className="text-[10px] text-hint font-mono">{s.size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
frontend/src/design-system/sections/TypographySection.tsx
Normal file
35
frontend/src/design-system/sections/TypographySection.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
|
||||
const TYPE_SCALE = [
|
||||
{ id: 'h1', tag: 'h1' as const, cls: 'text-2xl font-bold text-heading', example: '주요 타이틀 (H1 · text-2xl)' },
|
||||
{ id: 'h2', tag: 'h2' as const, cls: 'text-xl font-bold text-heading', example: '페이지 제목 (H2 · text-xl)' },
|
||||
{ id: 'h3', tag: 'h3' as const, cls: 'text-lg font-semibold text-heading', example: '섹션 제목 (H3 · text-lg)' },
|
||||
{ id: 'h4', tag: 'h4' as const, cls: 'text-base font-semibold text-heading', example: '카드 제목 (H4 · text-base)' },
|
||||
{ id: 'body', tag: 'p' as const, cls: 'text-sm text-label', example: '본문 텍스트 (body · text-sm)' },
|
||||
{ id: 'body-sm', tag: 'p' as const, cls: 'text-xs text-label', example: '작은 본문 (body-sm · text-xs)' },
|
||||
{ id: 'label', tag: 'span' as const, cls: 'text-[11px] text-label font-medium', example: '라벨 (label · 11px)' },
|
||||
{ id: 'hint', tag: 'span' as const, cls: 'text-[10px] text-hint', example: '힌트/캡션 (hint · 10px)' },
|
||||
];
|
||||
|
||||
export function TypographySection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-typography"
|
||||
title="타이포그래피"
|
||||
description="8단계 텍스트 스케일 + 시맨틱 색상 토큰 조합"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{TYPE_SCALE.map(({ id, tag: Tag, cls, example }) => (
|
||||
<Trk key={id} id={`TRK-TYPO-${id}`} className="ds-sample">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Tag className={cls}>{example}</Tag>
|
||||
<code className="text-[10px] text-hint font-mono whitespace-nowrap">{cls}</code>
|
||||
</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
frontend/src/designSystemMain.tsx
Normal file
10
frontend/src/designSystemMain.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles/index.css';
|
||||
import { DesignSystemApp } from './design-system/DesignSystemApp';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<DesignSystemApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
@ -78,3 +78,89 @@ export type CardVariant = 'default' | 'elevated' | 'inner' | 'transparent';
|
||||
export type BadgeIntent = 'critical' | 'high' | 'warning' | 'info' | 'success' | 'muted' | 'purple' | 'cyan';
|
||||
export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
export type StatusDotStatus = 'online' | 'warning' | 'danger' | 'offline';
|
||||
|
||||
/** Button 변형 — 5 variant × 3 size = 15
|
||||
*
|
||||
* 색상 정책:
|
||||
* - primary/destructive: 솔리드 배경 + text-on-vivid (흰색, 테마 무관)
|
||||
* - secondary/ghost/outline: 텍스트 토큰 (text-label / text-heading on hover)
|
||||
*/
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium rounded-md transition-colors ' +
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ' +
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-blue-600 hover:bg-blue-500 text-on-vivid border border-blue-700',
|
||||
secondary:
|
||||
'bg-surface-overlay hover:bg-surface-raised text-label hover:text-heading border border-slate-600/40',
|
||||
ghost: 'bg-transparent hover:bg-surface-overlay text-label hover:text-heading border border-transparent',
|
||||
outline:
|
||||
'bg-transparent hover:bg-blue-500/10 text-blue-400 hover:text-blue-300 border border-blue-500/50',
|
||||
destructive: 'bg-red-600 hover:bg-red-500 text-on-vivid border border-red-700',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-7 px-2.5 text-xs',
|
||||
md: 'h-8 px-3 text-sm',
|
||||
lg: 'h-10 px-4 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'secondary',
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'destructive';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/** Input / Select / 유사 폼 요소 공통 변형 */
|
||||
export const inputVariants = cva(
|
||||
'w-full bg-surface-overlay border rounded-md text-label placeholder:text-hint ' +
|
||||
'focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/30 ' +
|
||||
'transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-7 px-2 text-xs',
|
||||
md: 'h-8 px-3 text-sm',
|
||||
lg: 'h-10 px-3.5 text-base',
|
||||
},
|
||||
state: {
|
||||
default: 'border-slate-600/40',
|
||||
error: 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30',
|
||||
success: 'border-green-500/60 focus:border-green-500 focus:ring-green-500/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
state: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type InputSize = 'sm' | 'md' | 'lg';
|
||||
export type InputState = 'default' | 'error' | 'success';
|
||||
|
||||
/** PageContainer 변형 — 표준 페이지 루트 padding/spacing */
|
||||
export const pageContainerVariants = cva('', {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'p-4 space-y-3',
|
||||
md: 'p-5 space-y-4',
|
||||
lg: 'p-6 space-y-4',
|
||||
},
|
||||
fullBleed: {
|
||||
true: 'p-0 space-y-0',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
fullBleed: false,
|
||||
},
|
||||
});
|
||||
|
||||
export type PageContainerSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
34
frontend/src/shared/components/layout/PageContainer.tsx
Normal file
34
frontend/src/shared/components/layout/PageContainer.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { type HTMLAttributes, type ReactNode } from 'react';
|
||||
import { pageContainerVariants, type PageContainerSize } from '@lib/theme/variants';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface PageContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: PageContainerSize;
|
||||
/** 지도/3단 패널 등 풀화면 페이지: padding 0, space-y 0 */
|
||||
fullBleed?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PageContainer — 모든 feature 페이지의 표준 루트 컨테이너
|
||||
*
|
||||
* 기본값: `p-5 space-y-4` (프로젝트 전역 기준)
|
||||
* - size="sm" → p-4 space-y-3
|
||||
* - size="lg" → p-6 space-y-4 (admin 계열)
|
||||
* - fullBleed → padding 없음 (LiveMapView, VesselDetail 패턴)
|
||||
*
|
||||
* ESLint rule로 feature 페이지 루트는 PageContainer 필수 강제 (예정).
|
||||
*/
|
||||
export function PageContainer({
|
||||
className,
|
||||
size,
|
||||
fullBleed,
|
||||
children,
|
||||
...props
|
||||
}: PageContainerProps) {
|
||||
return (
|
||||
<div className={cn(pageContainerVariants({ size, fullBleed }), className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/shared/components/layout/PageHeader.tsx
Normal file
63
frontend/src/shared/components/layout/PageHeader.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
|
||||
/**
|
||||
* PageHeader — 페이지 최상단 제목/설명/액션 헤더
|
||||
*
|
||||
* 구성:
|
||||
* [좌] 아이콘 + 제목 + 설명 + (선택) 데모 배지
|
||||
* [우] 액션 슬롯 (검색/필터/버튼 등)
|
||||
*
|
||||
* 사용:
|
||||
* <PageHeader title="대시보드" description="실시간 종합 상황판" />
|
||||
*
|
||||
* <PageHeader
|
||||
* icon={Shield}
|
||||
* title="권한 관리"
|
||||
* description="사용자/역할/권한 설정"
|
||||
* demo
|
||||
* actions={<Button variant="primary">저장</Button>}
|
||||
* />
|
||||
*/
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
/** 아이콘 색상 클래스 (text-blue-400 등). 미지정 시 text-muted-foreground */
|
||||
iconColor?: string;
|
||||
/** 데모 데이터 배지 노출 */
|
||||
demo?: boolean;
|
||||
/** 우측 액션 슬롯 */
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
iconColor = 'text-muted-foreground',
|
||||
demo = false,
|
||||
actions,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-4', className)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
{Icon && <Icon className={cn('w-5 h-5', iconColor)} />}
|
||||
{title}
|
||||
{demo && (
|
||||
<Badge intent="warning" size="xs" className="font-normal">
|
||||
데모 데이터
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
{description && <p className="text-[11px] text-hint mt-0.5">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/shared/components/layout/Section.tsx
Normal file
51
frontend/src/shared/components/layout/Section.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
|
||||
import type { CardVariant } from '@lib/theme/variants';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
/**
|
||||
* Section — Card + CardHeader + CardTitle + CardContent 조합을 한 번에
|
||||
*
|
||||
* 사용:
|
||||
* <Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
|
||||
* {...}
|
||||
* </Section>
|
||||
*/
|
||||
export interface SectionProps {
|
||||
title?: string;
|
||||
icon?: LucideIcon;
|
||||
iconColor?: string;
|
||||
variant?: CardVariant;
|
||||
/** 우측 액션 슬롯 (제목 옆 버튼 등) */
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
icon: Icon,
|
||||
iconColor = 'text-muted-foreground',
|
||||
variant = 'default',
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<Card variant={variant} className={className}>
|
||||
{title && (
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
{Icon && <Icon className={cn('w-3.5 h-3.5', iconColor)} />}
|
||||
{title}
|
||||
</CardTitle>
|
||||
{actions && <div className="flex items-center gap-1.5">{actions}</div>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={cn(title ? 'pt-0' : 'pt-4', contentClassName)}>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
3
frontend/src/shared/components/layout/index.ts
Normal file
3
frontend/src/shared/components/layout/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { PageContainer, type PageContainerProps } from './PageContainer';
|
||||
export { PageHeader, type PageHeaderProps } from './PageHeader';
|
||||
export { Section, type SectionProps } from './Section';
|
||||
29
frontend/src/shared/components/ui/button.tsx
Normal file
29
frontend/src/shared/components/ui/button.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import { buttonVariants, type ButtonVariant, type ButtonSize } from '@lib/theme/variants';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: ReactNode;
|
||||
trailingIcon?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button — 프로젝트 표준 버튼
|
||||
* variant: primary / secondary / ghost / outline / destructive
|
||||
* size: sm / md / lg
|
||||
* className override는 cn()으로 안전하게 허용됨.
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, icon, trailingIcon, children, ...props }, ref) => {
|
||||
return (
|
||||
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props}>
|
||||
{icon}
|
||||
{children}
|
||||
{trailingIcon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
30
frontend/src/shared/components/ui/checkbox.tsx
Normal file
30
frontend/src/shared/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Checkbox — native input에 라벨/스타일만 씌운 가벼운 래퍼 */
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, label, id, ...props }, ref) => {
|
||||
const inputId = id ?? `cb-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return (
|
||||
<label htmlFor={inputId} className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 rounded border-slate-600/60 bg-surface-overlay',
|
||||
'accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="text-xs text-label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
23
frontend/src/shared/components/ui/input.tsx
Normal file
23
frontend/src/shared/components/ui/input.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
size?: InputSize;
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
/** Input — 프로젝트 표준 입력 필드 */
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, size, state, type = 'text', ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(inputVariants({ size, state }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
29
frontend/src/shared/components/ui/radio.tsx
Normal file
29
frontend/src/shared/components/ui/radio.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||
({ className, label, id, ...props }, ref) => {
|
||||
const inputId = id ?? `rd-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return (
|
||||
<label htmlFor={inputId} className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type="radio"
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 border-slate-600/60 bg-surface-overlay',
|
||||
'accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="text-xs text-label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
Radio.displayName = 'Radio';
|
||||
24
frontend/src/shared/components/ui/select.tsx
Normal file
24
frontend/src/shared/components/ui/select.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { forwardRef, type SelectHTMLAttributes } from 'react';
|
||||
import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||
size?: InputSize;
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
/** Select — Input과 동일한 스타일 토큰 공유 */
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, size, state, children, ...props }, ref) => {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(inputVariants({ size, state }), 'cursor-pointer', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
Select.displayName = 'Select';
|
||||
31
frontend/src/shared/components/ui/textarea.tsx
Normal file
31
frontend/src/shared/components/ui/textarea.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
state?: 'default' | 'error' | 'success';
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, state = 'default', ...props }, ref) => {
|
||||
const stateClass = {
|
||||
default: 'border-slate-600/40',
|
||||
error: 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30',
|
||||
success: 'border-green-500/60 focus:border-green-500 focus:ring-green-500/30',
|
||||
}[state];
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full bg-surface-overlay border rounded-md px-3 py-2 text-sm text-label placeholder:text-hint',
|
||||
'focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/30',
|
||||
'transition-colors resize-y min-h-[4rem]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
stateClass,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
@ -33,6 +33,7 @@ export default defineConfig({
|
||||
input: {
|
||||
main: path.resolve(__dirname, 'index.html'),
|
||||
systemFlow: path.resolve(__dirname, 'system-flow.html'),
|
||||
designSystem: path.resolve(__dirname, 'design-system.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user