feat(WEB): 컴포넌트 레지스트리 관계도 뷰 및 프리뷰 확장

- ComponentRelationshipView 트리 관계도 컴포넌트 추가
- RelationshipTreeNode 재귀 트리 노드 컴포넌트 추가
- 컴포넌트 프리뷰 항목 대폭 확장
- actions에 관계 분석 로직 추가
- CLAUDE.md 규칙 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-12 16:21:01 +09:00
parent 2b8a19b4af
commit 3aeaaa76a8
6 changed files with 1032 additions and 114 deletions

View File

@@ -138,6 +138,17 @@ const response = await fetch('/api/proxy/item-master/init');
| 폼 | 레이아웃, 필드 배치, 버튼 위치 |
| 테이블/리스트 | 컬럼 구조, 체크박스, 페이지네이션 |
### 컴포넌트 레지스트리 활용 (dev/component-registry)
실시간 스캔 기반 컴포넌트 목록 + 관계도 페이지가 존재함. 새로고침 시 최신 상태 반영.
**새 컴포넌트 생성 전 필수 확인**:
1. **목록 뷰**: 동일/유사 컴포넌트가 이미 있는지 검색
2. **관계도 뷰**: 유사 컴포넌트의 구성요소(imports)를 확인하여 동일한 공통 컴포넌트 조합 패턴 따르기
**기존 컴포넌트 수정 시 필수 확인**:
- 관계도의 **사용처(usedBy)** 확인 → 수정 시 영향받는 범위 파악
- usedBy가 많은 공통 컴포넌트일수록 수정 시 주의
---
## Common Table Standards

View File

@@ -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} &middot; ( )
&middot; 클릭: 소스코드 &middot; : {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} &middot; ( )
&middot; 클릭: 소스코드 &middot; : {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 */}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
})}
</>
)}
</>
);
});

View File

@@ -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)
);

View File

@@ -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=&quot;sm&quot; (max-w-3xl)</div>
</PageLayout>
<PageLayout maxWidth="full">
<div className="p-3 bg-green-50 rounded text-xs text-center">maxWidth=&quot;full&quot; ()</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 /> },
],
};