feat(WEB): 컴포넌트 레지스트리 관계도 뷰 및 프리뷰 확장
- ComponentRelationshipView 트리 관계도 컴포넌트 추가 - RelationshipTreeNode 재귀 트리 노드 컴포넌트 추가 - 컴포넌트 프리뷰 항목 대폭 확장 - actions에 관계 분석 로직 추가 - CLAUDE.md 규칙 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
CLAUDE.md
11
CLAUDE.md
@@ -138,6 +138,17 @@ const response = await fetch('/api/proxy/item-master/init');
|
||||
| 폼 | 레이아웃, 필드 배치, 버튼 위치 |
|
||||
| 테이블/리스트 | 컬럼 구조, 체크박스, 페이지네이션 |
|
||||
|
||||
### 컴포넌트 레지스트리 활용 (dev/component-registry)
|
||||
실시간 스캔 기반 컴포넌트 목록 + 관계도 페이지가 존재함. 새로고침 시 최신 상태 반영.
|
||||
|
||||
**새 컴포넌트 생성 전 필수 확인**:
|
||||
1. **목록 뷰**: 동일/유사 컴포넌트가 이미 있는지 검색
|
||||
2. **관계도 뷰**: 유사 컴포넌트의 구성요소(imports)를 확인하여 동일한 공통 컴포넌트 조합 패턴 따르기
|
||||
|
||||
**기존 컴포넌트 수정 시 필수 확인**:
|
||||
- 관계도의 **사용처(usedBy)** 확인 → 수정 시 영향받는 범위 파악
|
||||
- usedBy가 많은 공통 컴포넌트일수록 수정 시 주의
|
||||
|
||||
---
|
||||
|
||||
## Common Table Standards
|
||||
|
||||
@@ -13,9 +13,12 @@ import {
|
||||
Eye,
|
||||
Loader2,
|
||||
X,
|
||||
List,
|
||||
GitFork,
|
||||
} from 'lucide-react';
|
||||
import { getComponentSource, type RegistryData, type ComponentEntry } from './actions';
|
||||
import { UI_PREVIEWS } from './previews';
|
||||
import ComponentRelationshipView from './ComponentRelationshipView';
|
||||
|
||||
interface ComponentRegistryClientProps {
|
||||
registry: RegistryData;
|
||||
@@ -330,6 +333,7 @@ function CategorySection({
|
||||
}
|
||||
|
||||
export default function ComponentRegistryClient({ registry }: ComponentRegistryClientProps) {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'relationship'>('list');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTier, setActiveTier] = useState('전체');
|
||||
const [activeDomainCategory, setActiveDomainCategory] = useState('전체');
|
||||
@@ -435,7 +439,7 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -447,131 +451,167 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
|
||||
{registry.totalCount}개
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
스캔: {generatedDate} · 새로고침으로 갱신 (실시간 스캔)
|
||||
· 카드 클릭: 소스코드 보기 · 프리뷰: {previewCount}개
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="컴포넌트명, 파일명, 경로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tier Filter Chips */}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{ALL_TIERS.map((tier) => {
|
||||
const isActive = activeTier === tier;
|
||||
const count = tierCounts[tier] || 0;
|
||||
if (tier !== '전체' && count === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-xs text-gray-400 flex-1">
|
||||
스캔: {generatedDate} · 새로고침으로 갱신 (실시간 스캔)
|
||||
· 카드 클릭: 소스코드 보기 · 프리뷰: {previewCount}개
|
||||
</p>
|
||||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
|
||||
<button
|
||||
key={tier}
|
||||
onClick={() => handleTierChange(tier)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:border-blue-400'
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-blue-600 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tier === '전체' ? '전체' : TIER_CONFIG[tier]?.label || tier}
|
||||
<span className={`ml-1.5 text-xs ${isActive ? 'text-blue-100' : 'text-gray-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
목록
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Domain Sub-filter Chips */}
|
||||
{activeTier === 'domain' && domainCategories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
<button
|
||||
onClick={() => handleDomainCategoryChange('전체')}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
activeDomainCategory === '전체'
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
<span className={`ml-1 ${activeDomainCategory === '전체' ? 'text-gray-300' : 'text-gray-400'}`}>
|
||||
{tierCounts['domain'] || 0}
|
||||
</span>
|
||||
</button>
|
||||
{domainCategories.map(({ name, count }) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleDomainCategoryChange(name)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
activeDomainCategory === name
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-600 hover:border-gray-400'
|
||||
onClick={() => setViewMode('relationship')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === 'relationship'
|
||||
? 'bg-white dark:bg-gray-700 text-blue-600 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
<span className={`ml-1 ${activeDomainCategory === name ? 'text-gray-300' : 'text-gray-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
<GitFork className="w-3.5 h-3.5" />
|
||||
관계도
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relationship View */}
|
||||
{viewMode === 'relationship' && (
|
||||
<ComponentRelationshipView components={registry.components} />
|
||||
)}
|
||||
|
||||
{/* Results count & Expand/Collapse All */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{(searchTerm || activeTier !== '전체') ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
{filtered.length}개 결과
|
||||
</p>
|
||||
) : <div />}
|
||||
{groupedByCategory.length > 1 && (
|
||||
<button
|
||||
onClick={handleToggleAll}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{isAllExpanded ? '전체 접기' : '전체 펼치기'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* List View */}
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="컴포넌트명, 파일명, 경로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Component List */}
|
||||
<div className="space-y-2">
|
||||
{groupedByCategory.map(([category, { tier, components }]) => (
|
||||
<CategorySection
|
||||
key={`${category}-${globalExpandKey}`}
|
||||
category={category}
|
||||
tier={tier}
|
||||
components={components}
|
||||
expandedCard={expandedCard}
|
||||
onCardToggle={handleCardToggle}
|
||||
defaultExpanded={isAllExpanded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Tier Filter Chips */}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{ALL_TIERS.map((tier) => {
|
||||
const isActive = activeTier === tier;
|
||||
const count = tierCounts[tier] || 0;
|
||||
if (tier !== '전체' && count === 0) return null;
|
||||
|
||||
{/* Empty State */}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<FileCode className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
return (
|
||||
<button
|
||||
key={tier}
|
||||
onClick={() => handleTierChange(tier)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
{tier === '전체' ? '전체' : TIER_CONFIG[tier]?.label || tier}
|
||||
<span className={`ml-1.5 text-xs ${isActive ? 'text-blue-100' : 'text-gray-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Domain Sub-filter Chips */}
|
||||
{activeTier === 'domain' && domainCategories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
<button
|
||||
onClick={() => handleDomainCategoryChange('전체')}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
activeDomainCategory === '전체'
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
<span className={`ml-1 ${activeDomainCategory === '전체' ? 'text-gray-300' : 'text-gray-400'}`}>
|
||||
{tierCounts['domain'] || 0}
|
||||
</span>
|
||||
</button>
|
||||
{domainCategories.map(({ name, count }) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleDomainCategoryChange(name)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
activeDomainCategory === name
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
<span className={`ml-1 ${activeDomainCategory === name ? 'text-gray-300' : 'text-gray-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results count & Expand/Collapse All */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{(searchTerm || activeTier !== '전체') ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
{filtered.length}개 결과
|
||||
</p>
|
||||
) : <div />}
|
||||
{groupedByCategory.length > 1 && (
|
||||
<button
|
||||
onClick={handleToggleAll}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{isAllExpanded ? '전체 접기' : '전체 펼치기'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Component List */}
|
||||
<div className="space-y-2">
|
||||
{groupedByCategory.map(([category, { tier, components }]) => (
|
||||
<CategorySection
|
||||
key={`${category}-${globalExpandKey}`}
|
||||
category={category}
|
||||
tier={tier}
|
||||
components={components}
|
||||
expandedCard={expandedCard}
|
||||
onCardToggle={handleCardToggle}
|
||||
defaultExpanded={isAllExpanded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<FileCode className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { Search, X, ArrowRight, ArrowLeft, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
import type { ComponentEntry } from './actions';
|
||||
|
||||
const TIER_CONFIG: Record<string, { label: string; color: string; bg: string; border: string; ring: string }> = {
|
||||
ui: { label: 'UI', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200', ring: 'ring-blue-200' },
|
||||
atoms: { label: 'Atoms', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200', ring: 'ring-green-200' },
|
||||
molecules: { label: 'Molecules', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200', ring: 'ring-purple-200' },
|
||||
organisms: { label: 'Organisms', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200', ring: 'ring-orange-200' },
|
||||
common: { label: 'Common', color: 'text-teal-700', bg: 'bg-teal-50', border: 'border-teal-200', ring: 'ring-teal-200' },
|
||||
layout: { label: 'Layout', color: 'text-indigo-700', bg: 'bg-indigo-50', border: 'border-indigo-200', ring: 'ring-indigo-200' },
|
||||
dev: { label: 'Dev', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200', ring: 'ring-yellow-200' },
|
||||
domain: { label: 'Domain', color: 'text-gray-700', bg: 'bg-gray-50', border: 'border-gray-200', ring: 'ring-gray-200' },
|
||||
};
|
||||
|
||||
interface ComponentRelationshipViewProps {
|
||||
components: ComponentEntry[];
|
||||
}
|
||||
|
||||
/** 카드형 플로우: 사용처(좌) → 선택 컴포넌트(중앙) → 구성요소(우) */
|
||||
function FlowCard({
|
||||
comp,
|
||||
onSelect,
|
||||
size = 'normal',
|
||||
}: {
|
||||
comp: ComponentEntry;
|
||||
onSelect: (filePath: string) => void;
|
||||
size?: 'normal' | 'large';
|
||||
}) {
|
||||
const tc = TIER_CONFIG[comp.tier] || TIER_CONFIG.domain;
|
||||
const isLarge = size === 'large';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(comp.filePath)}
|
||||
className={`
|
||||
group text-left rounded-lg border-2 transition-all w-full
|
||||
${isLarge
|
||||
? `${tc.border} ${tc.bg} p-4 shadow-md ring-2 ${tc.ring}`
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-500'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-semibold ${isLarge ? 'text-base' : 'text-sm'} text-gray-900 dark:text-white truncate`}>
|
||||
{comp.name}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${tc.bg} ${tc.color} ${tc.border} border shrink-0`}>
|
||||
{tc.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`font-mono text-gray-400 mt-1 truncate ${isLarge ? 'text-xs' : 'text-[10px]'}`}>
|
||||
{comp.filePath.replace('src/components/', '')}
|
||||
</p>
|
||||
{isLarge && (
|
||||
<div className="flex gap-3 mt-2 text-xs text-gray-500">
|
||||
<span>{comp.imports.length}개 사용</span>
|
||||
<span>{comp.usedBy.length}곳에서 사용</span>
|
||||
<span>{comp.lineCount}L</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 연결선 칼럼 (좌→중앙 또는 중앙→우 사이의 화살표 영역) */
|
||||
function ConnectorColumn({ direction, count }: { direction: 'left' | 'right'; count: number }) {
|
||||
if (count === 0) return <div className="w-8 shrink-0" />;
|
||||
|
||||
return (
|
||||
<div className="w-8 shrink-0 flex flex-col items-center justify-center gap-1">
|
||||
{direction === 'left' ? (
|
||||
<ArrowRight className="w-5 h-5 text-emerald-400" />
|
||||
) : (
|
||||
<ArrowRight className="w-5 h-5 text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 2차 카드 (클릭하면 그 컴포넌트의 imports/usedBy를 미니 리스트로 펼침) */
|
||||
function ExpandableFlowCard({
|
||||
comp,
|
||||
componentMap,
|
||||
direction,
|
||||
onSelect,
|
||||
}: {
|
||||
comp: ComponentEntry;
|
||||
componentMap: Map<string, ComponentEntry>;
|
||||
direction: 'usedBy' | 'imports';
|
||||
onSelect: (filePath: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tc = TIER_CONFIG[comp.tier] || TIER_CONFIG.domain;
|
||||
const children = direction === 'usedBy' ? comp.usedBy : comp.imports;
|
||||
const resolvedChildren = children
|
||||
.map((fp) => componentMap.get(fp))
|
||||
.filter((c): c is ComponentEntry => c !== undefined);
|
||||
const hasChildren = resolvedChildren.length > 0;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="group flex items-center gap-2 rounded-lg border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-2.5 transition-all hover:shadow-md hover:border-blue-300 dark:hover:border-blue-500 cursor-pointer"
|
||||
>
|
||||
{/* 펼침 토글 */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{expanded ? <ChevronLeft className="w-3.5 h-3.5 rotate-[-90deg]" /> : <ChevronRight className="w-3.5 h-3.5 rotate-[-90deg]" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* 메인 컨텐츠 (클릭 → 선택) */}
|
||||
<button onClick={() => onSelect(comp.filePath)} className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{comp.name}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1 py-0.5 rounded ${tc.bg} ${tc.color} ${tc.border} border shrink-0`}>
|
||||
{tc.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] font-mono text-gray-400 truncate mt-0.5">
|
||||
{comp.filePath.replace('src/components/', '')}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* 카운트 뱃지 */}
|
||||
{hasChildren && (
|
||||
<span className="text-[10px] bg-gray-100 dark:bg-gray-700 text-gray-500 px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{resolvedChildren.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 펼친 하위 목록 */}
|
||||
{expanded && hasChildren && (
|
||||
<div className="ml-4 mt-1 pl-3 border-l-2 border-gray-200 dark:border-gray-700 space-y-1">
|
||||
{resolvedChildren.slice(0, 10).map((child) => {
|
||||
const childTc = TIER_CONFIG[child.tier] || TIER_CONFIG.domain;
|
||||
return (
|
||||
<button
|
||||
key={child.filePath}
|
||||
onClick={() => onSelect(child.filePath)}
|
||||
className="w-full text-left flex items-center gap-1.5 px-2 py-1.5 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300 truncate">
|
||||
{child.name}
|
||||
</span>
|
||||
<span className={`text-[9px] px-1 py-0.5 rounded ${childTc.bg} ${childTc.color} ${childTc.border} border shrink-0`}>
|
||||
{childTc.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{resolvedChildren.length > 10 && (
|
||||
<p className="text-[10px] text-gray-400 px-2">+{resolvedChildren.length - 10}개 더...</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComponentRelationshipView({ components }: ComponentRelationshipViewProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const componentMap = useMemo(() => {
|
||||
const map = new Map<string, ComponentEntry>();
|
||||
for (const comp of components) {
|
||||
map.set(comp.filePath, comp);
|
||||
}
|
||||
return map;
|
||||
}, [components]);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchTerm) return [];
|
||||
const q = searchTerm.toLowerCase();
|
||||
return components
|
||||
.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.fileName.toLowerCase().includes(q) ||
|
||||
c.filePath.toLowerCase().includes(q)
|
||||
)
|
||||
.slice(0, 10);
|
||||
}, [components, searchTerm]);
|
||||
|
||||
const selectedComponent = selectedFilePath ? componentMap.get(selectedFilePath) : null;
|
||||
|
||||
const handleSelect = useCallback((filePath: string) => {
|
||||
setSelectedFilePath((prev) => {
|
||||
if (prev && prev !== filePath) {
|
||||
setHistory((h) => [...h.slice(-19), prev]);
|
||||
}
|
||||
return filePath;
|
||||
});
|
||||
setSearchTerm('');
|
||||
setShowDropdown(false);
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (history.length === 0) return;
|
||||
const prev = history[history.length - 1];
|
||||
setHistory((h) => h.slice(0, -1));
|
||||
setSelectedFilePath(prev);
|
||||
}, [history]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// resolve usedBy/imports 컴포넌트 목록
|
||||
const usedByComponents = useMemo(() => {
|
||||
if (!selectedComponent) return [];
|
||||
return selectedComponent.usedBy
|
||||
.map((fp) => componentMap.get(fp))
|
||||
.filter((c): c is ComponentEntry => c !== undefined);
|
||||
}, [selectedComponent, componentMap]);
|
||||
|
||||
const importComponents = useMemo(() => {
|
||||
if (!selectedComponent) return [];
|
||||
return selectedComponent.imports
|
||||
.map((fp) => componentMap.get(fp))
|
||||
.filter((c): c is ComponentEntry => c !== undefined);
|
||||
}, [selectedComponent, componentMap]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="컴포넌트 검색 후 선택..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onFocus={() => { if (searchTerm) setShowDropdown(true); }}
|
||||
className="w-full pl-10 pr-10 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => { setSearchTerm(''); setShowDropdown(false); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showDropdown && searchResults.length > 0 && (
|
||||
<div className="absolute z-20 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
||||
{searchResults.map((comp) => {
|
||||
const tc = TIER_CONFIG[comp.tier] || TIER_CONFIG.domain;
|
||||
return (
|
||||
<button
|
||||
key={comp.filePath}
|
||||
onClick={() => handleSelect(comp.filePath)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-white">
|
||||
{comp.name}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1 py-0.5 rounded ${tc.bg} ${tc.color} ${tc.border} border`}>
|
||||
{tc.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-auto truncate max-w-[50%]">
|
||||
{comp.filePath}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택 전 안내 */}
|
||||
{!selectedComponent && (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="flex items-center justify-center gap-3 mb-3">
|
||||
<div className="w-16 h-10 rounded border-2 border-dashed border-gray-300 flex items-center justify-center text-[10px] text-gray-300">
|
||||
사용처
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-gray-300" />
|
||||
<div className="w-20 h-12 rounded border-2 border-dashed border-blue-300 flex items-center justify-center text-xs text-blue-300">
|
||||
선택
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-gray-300" />
|
||||
<div className="w-16 h-10 rounded border-2 border-dashed border-gray-300 flex items-center justify-center text-[10px] text-gray-300">
|
||||
구성요소
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">컴포넌트를 검색하고 선택하면 관계도가 표시됩니다.</p>
|
||||
<p className="text-xs mt-1">카드를 클릭하면 해당 컴포넌트로 이동합니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드형 플로우 */}
|
||||
{selectedComponent && (
|
||||
<>
|
||||
{/* 뒤로가기 */}
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
이전 컴포넌트
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 3칼럼 플로우 레이아웃 */}
|
||||
<div className="flex items-start gap-0">
|
||||
{/* 좌: 사용처 (이 컴포넌트를 사용하는 곳) */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-3 px-1">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
사용처
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
({usedByComponents.length})
|
||||
</span>
|
||||
</div>
|
||||
{usedByComponents.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-xs text-gray-400">사용처 없음</p>
|
||||
<p className="text-[10px] text-gray-300 mt-1">최상위 컴포넌트</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto pr-1">
|
||||
{usedByComponents.map((comp) => (
|
||||
<ExpandableFlowCard
|
||||
key={comp.filePath}
|
||||
comp={comp}
|
||||
componentMap={componentMap}
|
||||
direction="usedBy"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연결선 좌→중앙 */}
|
||||
<ConnectorColumn direction="left" count={usedByComponents.length} />
|
||||
|
||||
{/* 중앙: 선택된 컴포넌트 */}
|
||||
<div className="w-[260px] shrink-0">
|
||||
<div className="flex items-center gap-1.5 mb-3 px-1 justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
선택됨
|
||||
</span>
|
||||
</div>
|
||||
<FlowCard comp={selectedComponent} onSelect={handleSelect} size="large" />
|
||||
</div>
|
||||
|
||||
{/* 연결선 중앙→우 */}
|
||||
<ConnectorColumn direction="right" count={importComponents.length} />
|
||||
|
||||
{/* 우: 구성요소 (이 컴포넌트가 사용하는 것) */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-3 px-1">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400" />
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
구성요소
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
({importComponents.length})
|
||||
</span>
|
||||
</div>
|
||||
{importComponents.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-xs text-gray-400">구성요소 없음</p>
|
||||
<p className="text-[10px] text-gray-300 mt-1">기본 컴포넌트 (leaf)</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto pl-1">
|
||||
{importComponents.map((comp) => (
|
||||
<ExpandableFlowCard
|
||||
key={comp.filePath}
|
||||
comp={comp}
|
||||
componentMap={componentMap}
|
||||
direction="imports"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center justify-center gap-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-400">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
사용처: 이 컴포넌트를 import하는 곳
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-400">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400" />
|
||||
구성요소: 이 컴포넌트가 import하는 것
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-400">
|
||||
카드 클릭 = 해당 컴포넌트로 이동
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState } from 'react';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import type { ComponentEntry } from './actions';
|
||||
|
||||
const TIER_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
ui: { label: 'UI', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
|
||||
atoms: { label: 'Atoms', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
|
||||
molecules: { label: 'Molecules', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
|
||||
organisms: { label: 'Organisms', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
|
||||
common: { label: 'Common', color: 'text-teal-700', bg: 'bg-teal-50', border: 'border-teal-200' },
|
||||
layout: { label: 'Layout', color: 'text-indigo-700', bg: 'bg-indigo-50', border: 'border-indigo-200' },
|
||||
dev: { label: 'Dev', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
|
||||
domain: { label: 'Domain', color: 'text-gray-700', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||
};
|
||||
|
||||
interface RelationshipTreeNodeProps {
|
||||
component: ComponentEntry;
|
||||
allComponents: Map<string, ComponentEntry>;
|
||||
direction: 'up' | 'down'; // up=usedBy, down=imports
|
||||
depth: number;
|
||||
maxDepth: number;
|
||||
visitedPaths: Set<string>;
|
||||
onSelectComponent: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export const RelationshipTreeNode = memo(function RelationshipTreeNode({
|
||||
component,
|
||||
allComponents,
|
||||
direction,
|
||||
depth,
|
||||
maxDepth,
|
||||
visitedPaths,
|
||||
onSelectComponent,
|
||||
}: RelationshipTreeNodeProps) {
|
||||
const childPaths = direction === 'up' ? component.usedBy : component.imports;
|
||||
const children = childPaths
|
||||
.map((fp) => allComponents.get(fp))
|
||||
.filter((c): c is ComponentEntry => c !== undefined);
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
const [expanded, setExpanded] = useState(depth < 2);
|
||||
|
||||
const isCircular = visitedPaths.has(component.filePath);
|
||||
const isMaxDepth = depth >= maxDepth;
|
||||
|
||||
const tierConfig = TIER_CONFIG[component.tier] || TIER_CONFIG.domain;
|
||||
const paddingLeft = depth * 24;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group flex items-center py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 rounded transition-colors cursor-pointer"
|
||||
style={{ paddingLeft: `${paddingLeft + 8}px` }}
|
||||
onClick={() => onSelectComponent(component.filePath)}
|
||||
>
|
||||
{/* 펼침/접힘 */}
|
||||
<button
|
||||
className={`h-5 w-5 flex items-center justify-center shrink-0 ${
|
||||
!hasChildren || isCircular || isMaxDepth ? 'invisible' : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 컴포넌트 정보 */}
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 ml-1">
|
||||
{component.name}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1 py-0.5 rounded ml-1.5 ${tierConfig.bg} ${tierConfig.color} ${tierConfig.border} border`}>
|
||||
{tierConfig.label}
|
||||
</span>
|
||||
|
||||
{isCircular && (
|
||||
<span className="text-[10px] text-amber-600 ml-1.5">(순환)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자식 노드 (재귀) */}
|
||||
{hasChildren && expanded && !isCircular && !isMaxDepth && (
|
||||
<>
|
||||
{children.map((child) => {
|
||||
const nextVisited = new Set(visitedPaths);
|
||||
nextVisited.add(component.filePath);
|
||||
return (
|
||||
<RelationshipTreeNode
|
||||
key={child.filePath}
|
||||
component={child}
|
||||
allComponents={allComponents}
|
||||
direction={direction}
|
||||
depth={depth + 1}
|
||||
maxDepth={maxDepth}
|
||||
visitedPaths={nextVisited}
|
||||
onSelectComponent={onSelectComponent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -37,6 +37,8 @@ export interface ComponentEntry {
|
||||
propsName: string | null;
|
||||
isClientComponent: boolean;
|
||||
lineCount: number;
|
||||
imports: string[]; // 이 컴포넌트가 사용하는 다른 컴포넌트 filePath[]
|
||||
usedBy: string[]; // 이 컴포넌트를 사용하는 컴포넌트 filePath[]
|
||||
}
|
||||
|
||||
interface CategorySummary {
|
||||
@@ -96,6 +98,16 @@ function getSubcategory(relPath: string): string | null {
|
||||
return parts.length > 2 ? parts[1] : null;
|
||||
}
|
||||
|
||||
function extractComponentImports(content: string): string[] {
|
||||
const regex = /from\s+['"]@\/components\/([^'"]+)['"]/g;
|
||||
const imports: string[] = [];
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
imports.push(match[1]); // "ui/button", "organisms/DataTable" 등
|
||||
}
|
||||
return imports;
|
||||
}
|
||||
|
||||
function extractComponentInfo(content: string, fileName: string) {
|
||||
const isClient = /^['"]use client['"];?/m.test(content);
|
||||
|
||||
@@ -143,8 +155,9 @@ function extractComponentInfo(content: string, fileName: string) {
|
||||
const propsName = propsNameMatch ? propsNameMatch[1] : null;
|
||||
|
||||
const lineCount = content.split('\n').length;
|
||||
const componentImports = extractComponentImports(content);
|
||||
|
||||
return { name, exportType, hasProps, propsName, isClientComponent: isClient, lineCount };
|
||||
return { name, exportType, hasProps, propsName, isClientComponent: isClient, lineCount, componentImports };
|
||||
}
|
||||
|
||||
async function scanDirectory(dir: string, componentsRoot: string): Promise<ComponentEntry[]> {
|
||||
@@ -175,6 +188,8 @@ async function scanDirectory(dir: string, componentsRoot: string): Promise<Compo
|
||||
propsName: info.propsName,
|
||||
isClientComponent: info.isClientComponent,
|
||||
lineCount: info.lineCount,
|
||||
imports: info.componentImports,
|
||||
usedBy: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -196,6 +211,63 @@ function buildCategories(components: ComponentEntry[]): CategorySummary[] {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* import 경로 → filePath 매핑으로 양방향 관계(imports/usedBy) 구축
|
||||
*/
|
||||
function buildRelationships(components: ComponentEntry[]): void {
|
||||
// 경로 조회 맵: import 경로 변형 → filePath
|
||||
const pathLookup = new Map<string, string>();
|
||||
|
||||
for (const comp of components) {
|
||||
// "src/components/ui/button.tsx" → 여러 매칭 키 등록
|
||||
const rel = comp.filePath.replace(/^src\/components\//, ''); // "ui/button.tsx"
|
||||
const withoutExt = rel.replace(/\.tsx$/, ''); // "ui/button"
|
||||
|
||||
pathLookup.set(withoutExt, comp.filePath);
|
||||
pathLookup.set(rel, comp.filePath);
|
||||
|
||||
// 디렉토리/파일명 패턴: "organisms/DataTable/DataTable" → filePath
|
||||
const parts = withoutExt.split('/');
|
||||
if (parts.length >= 2) {
|
||||
const dirName = parts[parts.length - 2];
|
||||
const fileName = parts[parts.length - 1];
|
||||
// "organisms/DataTable" → filePath (barrel 패턴 대응)
|
||||
if (dirName === fileName) {
|
||||
const barrelPath = parts.slice(0, -1).join('/');
|
||||
if (!pathLookup.has(barrelPath)) {
|
||||
pathLookup.set(barrelPath, comp.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 각 컴포넌트의 raw imports → resolved filePath
|
||||
for (const comp of components) {
|
||||
const resolved: string[] = [];
|
||||
for (const rawImport of comp.imports) {
|
||||
// rawImport: "ui/button", "organisms/DataTable/DataTable" 등
|
||||
const found = pathLookup.get(rawImport);
|
||||
if (found && found !== comp.filePath) {
|
||||
resolved.push(found);
|
||||
}
|
||||
}
|
||||
comp.imports = [...new Set(resolved)];
|
||||
}
|
||||
|
||||
// 역방향: usedBy 구축
|
||||
const usedByMap = new Map<string, Set<string>>();
|
||||
for (const comp of components) {
|
||||
for (const imp of comp.imports) {
|
||||
if (!usedByMap.has(imp)) usedByMap.set(imp, new Set());
|
||||
usedByMap.get(imp)!.add(comp.filePath);
|
||||
}
|
||||
}
|
||||
for (const comp of components) {
|
||||
const set = usedByMap.get(comp.filePath);
|
||||
comp.usedBy = set ? [...set] : [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 레지스트리 실시간 스캔
|
||||
*
|
||||
@@ -206,6 +278,7 @@ export async function scanComponentRegistry(): Promise<RegistryData> {
|
||||
const componentsRoot = join(process.cwd(), 'src', 'components');
|
||||
|
||||
const components = await scanDirectory(componentsRoot, componentsRoot);
|
||||
buildRelationships(components);
|
||||
components.sort((a, b) =>
|
||||
a.tier.localeCompare(b.tier) || a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
@@ -80,6 +80,22 @@ import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { StatCards } from '@/components/organisms/StatCards';
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import { FormFieldGrid } from '@/components/organisms/FormFieldGrid';
|
||||
// Organisms - 추가
|
||||
import { SearchFilter } from '@/components/organisms/SearchFilter';
|
||||
import { ScreenVersionHistory } from '@/components/organisms/ScreenVersionHistory';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
// Common
|
||||
import { EmptyPage } from '@/components/common/EmptyPage';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
import { AccessDenied } from '@/components/common/AccessDenied';
|
||||
import { EditableTable } from '@/components/common/EditableTable/EditableTable';
|
||||
import { NoticePopupModal } from '@/components/common/NoticePopupModal/NoticePopupModal';
|
||||
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar/ScheduleCalendar';
|
||||
// Organisms - 추가
|
||||
import { DataTable } from '@/components/organisms/DataTable';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal';
|
||||
// UI - 추가
|
||||
import { VisuallyHidden } from '@/components/ui/visually-hidden';
|
||||
// Lucide icons for demos
|
||||
import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react';
|
||||
|
||||
@@ -224,6 +240,15 @@ function YearQuarterFilterDemo() {
|
||||
return <YearQuarterFilter year={year} quarter={quarter} onYearChange={setYear} onQuarterChange={setQuarter} />;
|
||||
}
|
||||
|
||||
function SearchFilterDemo() {
|
||||
const [v, setV] = useState('');
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<SearchFilter searchValue={v} onSearchChange={setV} searchPlaceholder="품목명, 코드 검색..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileFilterDemo() {
|
||||
const [values, setValues] = useState<Record<string, string | string[]>>({ status: 'all' });
|
||||
const fields = [
|
||||
@@ -236,6 +261,84 @@ function MobileFilterDemo() {
|
||||
return <MobileFilter fields={fields} values={values} onChange={(k, v) => setValues(prev => ({ ...prev, [k]: v }))} onReset={() => setValues({ status: 'all' })} />;
|
||||
}
|
||||
|
||||
function EditableTableDemo() {
|
||||
const [data, setData] = useState([
|
||||
{ id: '1', name: '볼트 M10', spec: 'M10x30', qty: 100 },
|
||||
{ id: '2', name: '너트 M10', spec: 'M10', qty: 200 },
|
||||
]);
|
||||
return (
|
||||
<EditableTable
|
||||
title="자재 목록"
|
||||
columns={[
|
||||
{ key: 'name' as const, header: '품목명', placeholder: '품목명 입력' },
|
||||
{ key: 'spec' as const, header: '규격', placeholder: '규격 입력' },
|
||||
{ key: 'qty' as const, header: '수량', type: 'number' as const, align: 'right' as const },
|
||||
]}
|
||||
data={data}
|
||||
onChange={setData}
|
||||
createNewRow={() => ({ id: String(Date.now()), name: '', spec: '', qty: 0 })}
|
||||
compact
|
||||
maxRows={5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NoticePopupDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>공지 팝업 열기</Button>
|
||||
<NoticePopupModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
popup={{
|
||||
id: 'demo-1',
|
||||
title: '시스템 점검 안내',
|
||||
content: '<p>2026년 2월 15일 (토) 02:00~06:00 시스템 점검이 예정되어 있습니다.</p>',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchableSelectionDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const mockItems = [
|
||||
{ id: '1', name: '볼트 M10', code: 'BLT-001' },
|
||||
{ id: '2', name: '너트 M10', code: 'NUT-001' },
|
||||
{ id: '3', name: '와셔 M10', code: 'WSH-001' },
|
||||
{ id: '4', name: '스프링 와셔', code: 'SWS-001' },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>검색 선택 모달 열기</Button>
|
||||
<SearchableSelectionModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="품목 검색"
|
||||
mode="single"
|
||||
searchPlaceholder="품목명, 코드 검색..."
|
||||
fetchData={async (query: string) => {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
if (!query) return mockItems;
|
||||
return mockItems.filter(item =>
|
||||
item.name.includes(query) || item.code.includes(query)
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={(item) => (
|
||||
<div className="px-3 py-2 hover:bg-muted">
|
||||
<div className="font-medium text-sm">{item.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.code}</div>
|
||||
</div>
|
||||
)}
|
||||
onSelect={() => setOpen(false)}
|
||||
loadOnOpen
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview Registry ──
|
||||
|
||||
type PreviewEntry = {
|
||||
@@ -1188,4 +1291,153 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
// ─── Organisms 추가 ───
|
||||
'SearchFilter.tsx': [
|
||||
{ label: 'Search', render: () => <SearchFilterDemo /> },
|
||||
],
|
||||
|
||||
'ScreenVersionHistory.tsx': [
|
||||
{
|
||||
label: 'History',
|
||||
render: () => (
|
||||
<div className="max-w-md">
|
||||
<ScreenVersionHistory versionHistory={[
|
||||
{ id: '1', version: 3, changeDescription: '단가 수정', changedBy: '김철수', changedAt: '2026-02-12' },
|
||||
{ id: '2', version: 2, changeDescription: '규격 변경', changedBy: '이영희', changedAt: '2026-02-10' },
|
||||
{ id: '3', version: 1, changeDescription: '최초 등록', changedBy: '박민수', changedAt: '2026-02-08' },
|
||||
]} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
'PageLayout.tsx': [
|
||||
{
|
||||
label: 'Max Widths',
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<PageLayout maxWidth="sm">
|
||||
<div className="p-3 bg-blue-50 rounded text-xs text-center">maxWidth="sm" (max-w-3xl)</div>
|
||||
</PageLayout>
|
||||
<PageLayout maxWidth="full">
|
||||
<div className="p-3 bg-green-50 rounded text-xs text-center">maxWidth="full" (기본값)</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
// ─── Common ───
|
||||
'EmptyPage.tsx': [
|
||||
{
|
||||
label: 'Under Construction',
|
||||
render: () => (
|
||||
<div className="max-h-[280px] overflow-auto rounded-lg border">
|
||||
<EmptyPage title="품목 관리" description="이 페이지는 현재 개발 중입니다." showBackButton={false} showHomeButton={false} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
'ServerErrorPage.tsx': [
|
||||
{
|
||||
label: 'Server Error',
|
||||
render: () => (
|
||||
<div className="max-h-[280px] overflow-auto rounded-lg border">
|
||||
<ServerErrorPage errorCode="500" showBackButton={false} showHomeButton={false} showContactInfo={false} onRetry={() => {}} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
'AccessDenied.tsx': [
|
||||
{
|
||||
label: 'Access Denied',
|
||||
render: () => (
|
||||
<div className="max-h-[280px] overflow-auto rounded-lg border">
|
||||
<AccessDenied showBackButton={false} showHomeButton={false} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
'EditableTable.tsx': [
|
||||
{ label: 'Editable', render: () => <EditableTableDemo /> },
|
||||
],
|
||||
|
||||
'NoticePopupModal.tsx': [
|
||||
{ label: 'Popup', render: () => <NoticePopupDemo /> },
|
||||
],
|
||||
|
||||
'ScheduleCalendar.tsx': [
|
||||
{
|
||||
label: 'Calendar',
|
||||
render: () => (
|
||||
<div className="max-w-lg">
|
||||
<ScheduleCalendar
|
||||
events={[
|
||||
{ id: '1', title: '김담당 - 현장A', startDate: '2026-02-09', endDate: '2026-02-13', color: 'blue' },
|
||||
{ id: '2', title: '이과장 - 현장B', startDate: '2026-02-11', endDate: '2026-02-14', color: 'green' },
|
||||
{ id: '3', title: '박대리 - 출장', startDate: '2026-02-15', endDate: '2026-02-15', color: 'orange' },
|
||||
]}
|
||||
badges={[
|
||||
{ date: '2026-02-09', count: 2, color: 'blue' },
|
||||
{ date: '2026-02-15', count: 1, color: 'red' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
// ─── UI 추가 ───
|
||||
'visually-hidden.tsx': [
|
||||
{
|
||||
label: 'Concept',
|
||||
render: () => (
|
||||
<div className="space-y-2 max-w-sm">
|
||||
<p className="text-sm">스크린리더 전용 숨김 텍스트:</p>
|
||||
<div className="flex items-center gap-2 p-2 border rounded">
|
||||
<Button size="sm" variant="outline">
|
||||
<Bell className="w-4 h-4" />
|
||||
<VisuallyHidden>알림 버튼</VisuallyHidden>
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">아이콘만 표시, 접근성 텍스트 포함</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
// ─── Organisms 추가 ───
|
||||
'DataTable.tsx': [
|
||||
{
|
||||
label: 'Mock Table',
|
||||
render: () => (
|
||||
<div className="max-w-lg [&_.hidden]:!block">
|
||||
<DataTable
|
||||
keyField="id"
|
||||
columns={[
|
||||
{ key: 'name', label: '품목명' },
|
||||
{ key: 'spec', label: '규격' },
|
||||
{ key: 'qty', label: '수량', type: 'number' as const },
|
||||
{ key: 'price', label: '단가', type: 'currency' as const },
|
||||
]}
|
||||
data={[
|
||||
{ id: '1', name: '볼트 M10x30', spec: 'M10', qty: 100, price: 500 },
|
||||
{ id: '2', name: '너트 M10', spec: 'M10', qty: 200, price: 300 },
|
||||
{ id: '3', name: '와셔 M10', spec: 'M10', qty: 50, price: 100 },
|
||||
]}
|
||||
compact
|
||||
striped
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
'SearchableSelectionModal.tsx': [
|
||||
{ label: 'Modal', render: () => <SearchableSelectionDemo /> },
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user