feat(WEB): 컴포넌트 레지스트리 UI 개선 및 middleware 업데이트

- ComponentRegistryClient 기능 확장 및 UI 개선
- middleware 라우팅 로직 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-12 14:59:46 +09:00
parent 75e2f50714
commit 2b8a19b4af
2 changed files with 104 additions and 9 deletions

View File

@@ -258,14 +258,16 @@ function CategorySection({
tier,
expandedCard,
onCardToggle,
defaultExpanded = true,
}: {
category: string;
components: ComponentEntry[];
tier: string;
expandedCard: string | null;
onCardToggle: (filePath: string) => void;
defaultExpanded?: boolean;
}) {
const [expanded, setExpanded] = useState(true);
const [expanded, setExpanded] = useState(defaultExpanded);
// Group by subcategory
const groups = useMemo(() => {
@@ -330,12 +332,35 @@ function CategorySection({
export default function ComponentRegistryClient({ registry }: ComponentRegistryClientProps) {
const [searchTerm, setSearchTerm] = useState('');
const [activeTier, setActiveTier] = useState('전체');
const [activeDomainCategory, setActiveDomainCategory] = useState('전체');
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const [isAllExpanded, setIsAllExpanded] = useState(true);
const [globalExpandKey, setGlobalExpandKey] = useState(0);
const handleCardToggle = useCallback((filePath: string) => {
setExpandedCard((prev) => (prev === filePath ? null : filePath));
}, []);
const handleTierChange = useCallback((tier: string) => {
setActiveTier(tier);
if (tier !== 'domain') setActiveDomainCategory('전체');
setIsAllExpanded(tier !== 'domain');
setGlobalExpandKey(prev => prev + 1);
}, []);
const handleDomainCategoryChange = useCallback((cat: string) => {
setActiveDomainCategory(cat);
if (cat !== '전체') {
setIsAllExpanded(true);
setGlobalExpandKey(prev => prev + 1);
}
}, []);
const handleToggleAll = useCallback(() => {
setIsAllExpanded(prev => !prev);
setGlobalExpandKey(prev => prev + 1);
}, []);
const filtered = useMemo(() => {
let comps = registry.components;
@@ -343,6 +368,10 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
comps = comps.filter((c) => c.tier === activeTier);
}
if (activeTier === 'domain' && activeDomainCategory !== '전체') {
comps = comps.filter((c) => c.category === activeDomainCategory);
}
if (searchTerm) {
const q = searchTerm.toLowerCase();
comps = comps.filter(
@@ -355,7 +384,7 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
}
return comps;
}, [registry.components, activeTier, searchTerm]);
}, [registry.components, activeTier, activeDomainCategory, searchTerm]);
// Group filtered components by category
const groupedByCategory = useMemo(() => {
@@ -382,6 +411,19 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
return counts;
}, [registry.components]);
// Domain sub-categories for sub-filter chips
const domainCategories = useMemo(() => {
const cats = new Map<string, number>();
for (const comp of registry.components) {
if (comp.tier === 'domain') {
cats.set(comp.category, (cats.get(comp.category) || 0) + 1);
}
}
return [...cats.entries()]
.sort(([, a], [, b]) => b - a)
.map(([name, count]) => ({ name, count }));
}, [registry.components]);
// Count of previewable components (all tiers)
const previewCount = useMemo(() => {
return registry.components.filter(
@@ -441,7 +483,7 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
return (
<button
key={tier}
onClick={() => setActiveTier(tier)}
onClick={() => handleTierChange(tier)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive
? 'bg-blue-500 text-white'
@@ -457,23 +499,69 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
})}
</div>
{/* Results count */}
{(searchTerm || activeTier !== '전체') && (
<p className="text-sm text-gray-500 mb-4">
{filtered.length}
</p>
{/* 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}
key={`${category}-${globalExpandKey}`}
category={category}
tier={tier}
components={components}
expandedCard={expandedCard}
onCardToggle={handleCardToggle}
defaultExpanded={isAllExpanded}
/>
))}
</div>

View File

@@ -218,6 +218,13 @@ export async function middleware(request: NextRequest) {
// 1⃣ 로케일 제거
const pathnameWithoutLocale = getPathnameWithoutLocale(pathname);
// 1.5️⃣ 프로덕션 환경에서 /dev/ 경로 차단
if (process.env.NODE_ENV === 'production' && (
pathnameWithoutLocale.startsWith('/dev/') || pathnameWithoutLocale === '/dev'
)) {
return new NextResponse(null, { status: 404 });
}
// 2⃣ Bot Detection (기존 로직)
const isBotRequest = isBot(userAgent);