feat(WEB): DynamicItemForm 필드 타입 확장 및 컴포넌트 레지스트리 추가
- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed) - DynamicTableSection 및 TableCellRenderer 추가 - 필드 프리셋 및 설정 구조 분리 - 컴포넌트 레지스트리 개발 도구 페이지 추가 - UniversalListPage 개선 - 근태관리 코드 정리 - 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
FileCode,
|
||||
Code,
|
||||
Eye,
|
||||
Loader2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { getComponentSource } from './actions';
|
||||
import { UI_PREVIEWS } from './previews';
|
||||
|
||||
interface ComponentEntry {
|
||||
name: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
tier: string;
|
||||
category: string;
|
||||
subcategory: string | null;
|
||||
exportType: 'default' | 'named' | 'both' | 'none';
|
||||
hasProps: boolean;
|
||||
propsName: string | null;
|
||||
isClientComponent: boolean;
|
||||
lineCount: number;
|
||||
}
|
||||
|
||||
interface CategorySummary {
|
||||
tier: string;
|
||||
category: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RegistryData {
|
||||
generatedAt: string;
|
||||
totalCount: number;
|
||||
categories: CategorySummary[];
|
||||
components: ComponentEntry[];
|
||||
}
|
||||
|
||||
interface ComponentRegistryClientProps {
|
||||
registry: RegistryData;
|
||||
}
|
||||
|
||||
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' },
|
||||
};
|
||||
|
||||
const ALL_TIERS = ['전체', 'ui', 'atoms', 'molecules', 'organisms', 'common', 'layout', 'dev', 'domain'];
|
||||
|
||||
function CopyButton({ text, label }: { text: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title={label}
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TierBadge({ tier }: { tier: string }) {
|
||||
const config = TIER_CONFIG[tier] || TIER_CONFIG.domain;
|
||||
return (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${config.bg} ${config.color} ${config.border} border`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCodeViewer({ source }: { source: string }) {
|
||||
const lines = source.split('\n');
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<CopyButton text={source} label="소스코드 전체 복사" />
|
||||
</div>
|
||||
<pre className="bg-gray-950 text-gray-300 rounded-lg p-4 text-xs overflow-x-auto max-h-[500px] overflow-y-auto leading-relaxed">
|
||||
<code>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className="text-gray-600 select-none w-10 text-right pr-3 shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre">{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentCard({
|
||||
comp,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
comp: ComponentEntry;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'preview' | 'code'>('preview');
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [isLoadingSource, setIsLoadingSource] = useState(false);
|
||||
|
||||
const importPath = '@/' + comp.filePath.replace(/^src\//, '').replace(/\.tsx$/, '');
|
||||
const hasPreview = !!UI_PREVIEWS[comp.fileName];
|
||||
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (!isExpanded) {
|
||||
// Opening: load source code
|
||||
if (!source && !isLoadingSource) {
|
||||
setIsLoadingSource(true);
|
||||
const result = await getComponentSource(comp.filePath);
|
||||
if (result.source) setSource(result.source);
|
||||
setIsLoadingSource(false);
|
||||
}
|
||||
if (hasPreview) {
|
||||
setActiveTab('preview');
|
||||
} else {
|
||||
setActiveTab('code');
|
||||
}
|
||||
}
|
||||
onToggle();
|
||||
}, [isExpanded, source, isLoadingSource, comp.filePath, hasPreview, onToggle]);
|
||||
|
||||
const handleTabChange = useCallback(async (tab: 'preview' | 'code') => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'code' && !source && !isLoadingSource) {
|
||||
setIsLoadingSource(true);
|
||||
const result = await getComponentSource(comp.filePath);
|
||||
if (result.source) setSource(result.source);
|
||||
setIsLoadingSource(false);
|
||||
}
|
||||
}, [source, isLoadingSource, comp.filePath]);
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg border transition-all ${
|
||||
isExpanded
|
||||
? 'border-blue-400 dark:border-blue-500 shadow-md'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-sm'
|
||||
}`}>
|
||||
{/* Card Header */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="flex items-center justify-between p-3 w-full text-left"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{comp.name}
|
||||
</span>
|
||||
{comp.isClientComponent && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||
client
|
||||
</span>
|
||||
)}
|
||||
{comp.hasProps && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200">
|
||||
{comp.propsName || 'Props'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500">
|
||||
{comp.exportType}
|
||||
</span>
|
||||
{hasPreview && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
Preview
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono mt-1 truncate">
|
||||
{comp.filePath}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||
<span className="text-xs text-gray-400">{comp.lineCount}L</span>
|
||||
<CopyButton text={importPath} label={`복사: ${importPath}`} />
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700">
|
||||
{/* Tab Buttons */}
|
||||
<div className="flex items-center gap-1 px-3 pt-2">
|
||||
{hasPreview && (
|
||||
<button
|
||||
onClick={() => handleTabChange('preview')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-t transition-colors ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-white dark:bg-gray-800 text-blue-600 border border-b-0 border-gray-200 dark:border-gray-700'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
미리보기
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleTabChange('code')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-t transition-colors ${
|
||||
activeTab === 'code'
|
||||
? 'bg-white dark:bg-gray-800 text-blue-600 border border-b-0 border-gray-200 dark:border-gray-700'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
소스코드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-3">
|
||||
{activeTab === 'preview' && hasPreview && (
|
||||
<div className="space-y-4">
|
||||
{UI_PREVIEWS[comp.fileName]!.map((preview) => (
|
||||
<div key={preview.label}>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">{preview.label}</p>
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg border border-dashed border-gray-300 dark:border-gray-600">
|
||||
{preview.render()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'code' && (
|
||||
<>
|
||||
{isLoadingSource && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-500">소스코드 로딩 중...</span>
|
||||
</div>
|
||||
)}
|
||||
{source && <SourceCodeViewer source={source} />}
|
||||
{!isLoadingSource && !source && (
|
||||
<p className="text-sm text-gray-500 text-center py-4">소스코드를 불러올 수 없습니다.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
category,
|
||||
components,
|
||||
tier,
|
||||
expandedCard,
|
||||
onCardToggle,
|
||||
}: {
|
||||
category: string;
|
||||
components: ComponentEntry[];
|
||||
tier: string;
|
||||
expandedCard: string | null;
|
||||
onCardToggle: (filePath: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// Group by subcategory
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ComponentEntry[]>();
|
||||
for (const comp of components) {
|
||||
const key = comp.subcategory || '__root__';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(comp);
|
||||
}
|
||||
return map;
|
||||
}, [components]);
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 w-full text-left mb-2"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
)}
|
||||
<TierBadge tier={tier} />
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{category}
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500 ml-auto">
|
||||
{components.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="pl-6 space-y-3">
|
||||
{[...groups.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([sub, comps]) => (
|
||||
<div key={sub}>
|
||||
{sub !== '__root__' && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1.5 pl-1">
|
||||
{sub}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-1.5">
|
||||
{comps.map((comp) => (
|
||||
<ComponentCard
|
||||
key={comp.filePath}
|
||||
comp={comp}
|
||||
isExpanded={expandedCard === comp.filePath}
|
||||
onToggle={() => onCardToggle(comp.filePath)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComponentRegistryClient({ registry }: ComponentRegistryClientProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTier, setActiveTier] = useState('전체');
|
||||
const [expandedCard, setExpandedCard] = useState<string | null>(null);
|
||||
|
||||
const handleCardToggle = useCallback((filePath: string) => {
|
||||
setExpandedCard((prev) => (prev === filePath ? null : filePath));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let comps = registry.components;
|
||||
|
||||
if (activeTier !== '전체') {
|
||||
comps = comps.filter((c) => c.tier === activeTier);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
const q = searchTerm.toLowerCase();
|
||||
comps = comps.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.fileName.toLowerCase().includes(q) ||
|
||||
c.filePath.toLowerCase().includes(q) ||
|
||||
(c.propsName && c.propsName.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
return comps;
|
||||
}, [registry.components, activeTier, searchTerm]);
|
||||
|
||||
// Group filtered components by category
|
||||
const groupedByCategory = useMemo(() => {
|
||||
const map = new Map<string, { tier: string; components: ComponentEntry[] }>();
|
||||
for (const comp of filtered) {
|
||||
if (!map.has(comp.category)) {
|
||||
map.set(comp.category, { tier: comp.tier, components: [] });
|
||||
}
|
||||
map.get(comp.category)!.components.push(comp);
|
||||
}
|
||||
return [...map.entries()].sort(([, a], [, b]) => {
|
||||
const tierOrder = ALL_TIERS.indexOf(a.tier) - ALL_TIERS.indexOf(b.tier);
|
||||
if (tierOrder !== 0) return tierOrder;
|
||||
return a.tier.localeCompare(b.tier);
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
// Tier counts for chips
|
||||
const tierCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { '전체': registry.components.length };
|
||||
for (const comp of registry.components) {
|
||||
counts[comp.tier] = (counts[comp.tier] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [registry.components]);
|
||||
|
||||
// Count of previewable components
|
||||
const previewCount = useMemo(() => {
|
||||
return registry.components.filter(
|
||||
(c) => c.tier === 'ui' && UI_PREVIEWS[c.fileName]
|
||||
).length;
|
||||
}, [registry.components]);
|
||||
|
||||
const generatedDate = new Date(registry.generatedAt).toLocaleString('ko-KR');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Layers className="w-6 h-6 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Component Registry
|
||||
</h1>
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
{registry.totalCount}개
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
생성: {generatedDate} · <code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-xs">npm run gen:components</code>로 갱신
|
||||
· 카드 클릭: 소스코드 보기 · UI 프리뷰: {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 (
|
||||
<button
|
||||
key={tier}
|
||||
onClick={() => setActiveTier(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>
|
||||
|
||||
{/* Results count */}
|
||||
{(searchTerm || activeTier !== '전체') && (
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{filtered.length}개 결과
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Component List */}
|
||||
<div className="space-y-2">
|
||||
{groupedByCategory.map(([category, { tier, components }]) => (
|
||||
<CategorySection
|
||||
key={category}
|
||||
category={category}
|
||||
tier={tier}
|
||||
components={components}
|
||||
expandedCard={expandedCard}
|
||||
onCardToggle={handleCardToggle}
|
||||
/>
|
||||
))}
|
||||
</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 */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
데이터 소스: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">src/generated/component-registry.json</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use server';
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export async function getComponentSource(filePath: string): Promise<{ source: string | null; error: string | null }> {
|
||||
// filePath는 "src/components/ui/button.tsx" 형태
|
||||
if (!filePath.startsWith('src/components/') || !filePath.endsWith('.tsx')) {
|
||||
return { source: null, error: 'Invalid file path' };
|
||||
}
|
||||
|
||||
try {
|
||||
const fullPath = join(process.cwd(), filePath);
|
||||
const source = await readFile(fullPath, 'utf-8');
|
||||
return { source, error: null };
|
||||
} catch {
|
||||
return { source: null, error: 'File not found' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import registry from '@/generated/component-registry.json';
|
||||
import ComponentRegistryClient, { type RegistryData } from './ComponentRegistryClient';
|
||||
|
||||
export default function ComponentRegistryPage() {
|
||||
return <ComponentRegistryClient registry={registry as unknown as RegistryData} />;
|
||||
}
|
||||
1191
src/app/[locale]/(protected)/dev/component-registry/previews.tsx
Normal file
1191
src/app/[locale]/(protected)/dev/component-registry/previews.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user