diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7b046f4..637aa3d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,26 @@ "Bash(ls:*)", "Bash(find:*)", "Bash(grep:*)", - "Bash(wc:*)" + "Bash(wc:*)", + "Bash(for file in accordion alert aspect-ratio avatar badge button card checkbox collapsible dialog hover-card input label popover progress radio-group resizable scroll-area separator skeleton sheet switch tabs textarea toggle tooltip)", + "Bash(do)", + "Bash([ -f \"src/config/pages/ui/$file.json\" ])", + "Bash(echo:*)", + "Bash(done)", + "Bash(for file in data-source-card editor-toolbar empty-editor-state monaco-editor-panel search-bar)", + "Bash([ -f \"src/config/pages/molecules/$file.json\" ])", + "Bash(for file in empty-canvas-state page-header schema-editor-canvas schema-editor-properties-panel schema-editor-sidebar schema-editor-status-bar schema-editor-toolbar toolbar-actions)", + "Bash([ -f \"src/config/pages/organisms/$file.json\" ])", + "Bash([ -f \"src/config/pages/atoms/input.json\" ])", + "Bash(npm run tsx:*)", + "Bash(npx tsx:*)", + "Bash(npm run test:e2e:*)", + "Bash(npx playwright:*)", + "Bash(timeout 15 npm run dev:*)", + "Bash(netstat:*)", + "Bash(findstr:*)", + "Bash(taskkill:*)", + "Bash(xargs:*)" ] } } diff --git a/e2e/codeforge.spec.ts b/e2e/codeforge.spec.ts index 4103c96..976a5b3 100644 --- a/e2e/codeforge.spec.ts +++ b/e2e/codeforge.spec.ts @@ -8,7 +8,10 @@ test.describe('CodeForge - Core Functionality', () => { }) test('should load the application successfully', async ({ page }) => { - await expect(page.locator('body')).toBeVisible() + // Check root has children (content rendered) + await page.waitForSelector('#root > *', { timeout: 10000 }) + const root = page.locator('#root') + await expect(root).toHaveCount(1) }) test('should display main navigation', async ({ page }) => { @@ -50,8 +53,8 @@ test.describe('CodeForge - Responsive Design', () => { await page.setViewportSize({ width: 375, height: 667 }) await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 10000 }) await page.waitForLoadState('networkidle', { timeout: 5000 }) - - await expect(page.locator('body')).toBeVisible() + + await page.waitForSelector('#root > *', { timeout: 10000 }) }) test('should work on tablet viewport', async ({ page }) => { @@ -59,7 +62,7 @@ test.describe('CodeForge - Responsive Design', () => { await page.setViewportSize({ width: 768, height: 1024 }) await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 10000 }) await page.waitForLoadState('networkidle', { timeout: 5000 }) - - await expect(page.locator('body')).toBeVisible() + + await page.waitForSelector('#root > *', { timeout: 10000 }) }) }) diff --git a/e2e/debug.spec.ts b/e2e/debug.spec.ts new file mode 100644 index 0000000..cb284d4 --- /dev/null +++ b/e2e/debug.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test' + +test('debug page load', async ({ page }) => { + const errors: string[] = [] + const pageErrors: Error[] = [] + + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()) + } + }) + + page.on('pageerror', (error) => { + pageErrors.push(error) + }) + + await page.goto('/', { waitUntil: 'networkidle', timeout: 15000 }) + + // Wait a bit + await page.waitForTimeout(2000) + + // Get page content + const html = await page.content() + const rootHTML = await page.locator('#root').innerHTML().catch(() => 'ERROR GETTING ROOT') + + console.log('=== PAGE ERRORS ===') + pageErrors.forEach(err => console.log(err.message)) + + console.log('\n=== CONSOLE ERRORS ===') + errors.forEach(err => console.log(err)) + + console.log('\n=== ROOT CONTENT ===') + console.log(rootHTML.substring(0, 500)) + + console.log('\n=== ROOT VISIBLE ===') + const rootVisible = await page.locator('#root').isVisible().catch(() => false) + console.log('Root visible:', rootVisible) + + console.log('\n=== ROOT HAS CHILDREN ===') + const childCount = await page.locator('#root > *').count() + console.log('Child count:', childCount) +}) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 7dc40c5..a45084e 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -4,8 +4,12 @@ test.describe('CodeForge - Smoke Tests', () => { test('app loads successfully', async ({ page }) => { test.setTimeout(20000) await page.goto('/', { waitUntil: 'networkidle', timeout: 15000 }) - - await expect(page.locator('body')).toBeVisible({ timeout: 5000 }) + + // Check that the app has rendered content (more reliable than checking visibility) + const root = page.locator('#root') + await expect(root).toHaveCount(1, { timeout: 5000 }) + // Wait for any content to be rendered + await page.waitForSelector('#root > *', { timeout: 10000 }) }) test('can navigate to dashboard tab', async ({ page }) => { diff --git a/json-components-registry.json b/json-components-registry.json index 38ca69e..4ea0db4 100644 --- a/json-components-registry.json +++ b/json-components-registry.json @@ -4118,8 +4118,8 @@ "icons": 38, "organisms": 16, "primitive": 6, - "wrappers": 10, - "custom": 20 + "custom": 20, + "wrappers": 10 } } } diff --git a/scripts/analyze-pure-json-candidates.ts b/scripts/analyze-pure-json-candidates.ts new file mode 100644 index 0000000..c23431d --- /dev/null +++ b/scripts/analyze-pure-json-candidates.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') + +const componentsToAnalyze = { + molecules: ['DataSourceCard', 'EditorToolbar', 'EmptyEditorState', 'MonacoEditorPanel', 'SearchBar'], + organisms: ['EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel', + 'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions'], +} + +async function analyzeComponent(category: string, component: string): Promise { + const tsFile = path.join(rootDir, `src/components/${category}/${component}.tsx`) + const content = await fs.readFile(tsFile, 'utf-8') + + // Check if it's pure composition (only uses UI primitives) + const hasBusinessLogic = /useState|useEffect|useCallback|useMemo|useReducer|useRef/.test(content) + const hasComplexLogic = /if\s*\(.*\{|switch\s*\(|for\s*\(|while\s*\(/.test(content) + const hasCustomFunctions = /function\s+\w+\(|const\s+\w+\s*=\s*\([^)]*\)\s*=>/.test(content) + + // Extract what it imports + const imports = content.match(/import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"]/g) || [] + const importedComponents = imports.flatMap(imp => { + const match = imp.match(/\{([^}]+)\}/) + return match ? match[1].split(',').map(s => s.trim()) : [] + }) + + // Check if it only imports from ui/atoms (pure composition) + const onlyUIPrimitives = imports.every(imp => + imp.includes('@/components/ui/') || + imp.includes('@/components/atoms/') || + imp.includes('@/lib/utils') || + imp.includes('lucide-react') || + imp.includes('@phosphor-icons') + ) + + const lineCount = content.split('\n').length + + console.log(`\nšŸ“„ ${component}`) + console.log(` Lines: ${lineCount}`) + console.log(` Has hooks: ${hasBusinessLogic ? 'āŒ' : 'āœ…'}`) + console.log(` Has complex logic: ${hasComplexLogic ? 'āŒ' : 'āœ…'}`) + console.log(` Only UI primitives: ${onlyUIPrimitives ? 'āœ…' : 'āŒ'}`) + console.log(` Imports: ${importedComponents.slice(0, 5).join(', ')}${importedComponents.length > 5 ? '...' : ''}`) + + if (!hasBusinessLogic && onlyUIPrimitives && lineCount < 100) { + console.log(` šŸŽÆ CANDIDATE FOR PURE JSON`) + } +} + +async function main() { + console.log('šŸ” Analyzing components for pure JSON conversion...\n') + console.log('Looking for components that:') + console.log(' - No hooks (useState, useEffect, etc.)') + console.log(' - No complex logic') + console.log(' - Only import UI primitives') + console.log(' - Are simple compositions\n') + + for (const [category, components] of Object.entries(componentsToAnalyze)) { + console.log(`\n═══ ${category.toUpperCase()} ═══`) + for (const component of components) { + try { + await analyzeComponent(category, component) + } catch (e) { + console.log(`\nšŸ“„ ${component}`) + console.log(` āš ļø Could not analyze: ${e}`) + } + } + } + + console.log('\n\n✨ Analysis complete!') +} + +main().catch(console.error) diff --git a/scripts/find-component-imports.ts b/scripts/find-component-imports.ts new file mode 100644 index 0000000..329c586 --- /dev/null +++ b/scripts/find-component-imports.ts @@ -0,0 +1,141 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') + +// Components we want to remove (restored dependencies) +const targetComponents = { + ui: ['accordion', 'alert', 'aspect-ratio', 'avatar', 'badge', 'button', 'card', + 'checkbox', 'collapsible', 'dialog', 'hover-card', 'input', 'label', + 'popover', 'progress', 'radio-group', 'resizable', 'scroll-area', + 'separator', 'skeleton', 'sheet', 'switch', 'tabs', 'textarea', 'toggle', 'tooltip'], + molecules: ['DataSourceCard', 'EditorToolbar', 'EmptyEditorState', 'MonacoEditorPanel', 'SearchBar'], + organisms: ['EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel', + 'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions'], + atoms: ['Input'] +} + +interface ImportInfo { + file: string + line: number + importStatement: string + importedComponents: string[] + fromPath: string +} + +async function findAllImports(): Promise { + const imports: ImportInfo[] = [] + + const searchDirs = [ + 'src/components', + 'src/pages', + 'src/lib', + 'src' + ] + + for (const dir of searchDirs) { + const dirPath = path.join(rootDir, dir) + try { + await processDirectory(dirPath, imports) + } catch (e) { + // Directory might not exist, skip + } + } + + return imports +} + +async function processDirectory(dir: string, imports: ImportInfo[]): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory() && !entry.name.includes('node_modules')) { + await processDirectory(fullPath, imports) + } else if (entry.isFile() && (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts'))) { + await processFile(fullPath, imports) + } + } +} + +async function processFile(filePath: string, imports: ImportInfo[]): Promise { + const content = await fs.readFile(filePath, 'utf-8') + const lines = content.split('\n') + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Check for imports from our target components + for (const [category, components] of Object.entries(targetComponents)) { + for (const component of components) { + const patterns = [ + `from ['"]@/components/${category}/${component}['"]`, + `from ['"]./${component}['"]`, + `from ['"]../${component}['"]`, + ] + + for (const pattern of patterns) { + if (new RegExp(pattern).test(line)) { + // Extract imported components + const importMatch = line.match(/import\s+(?:\{([^}]+)\}|(\w+))\s+from/) + const importedComponents = importMatch + ? (importMatch[1] || importMatch[2]).split(',').map(s => s.trim()) + : [] + + imports.push({ + file: filePath.replace(rootDir, '').replace(/\\/g, '/'), + line: i + 1, + importStatement: line.trim(), + importedComponents, + fromPath: component + }) + } + } + } + } + } +} + +async function main() { + console.log('šŸ” Finding all imports of target components...\n') + + const imports = await findAllImports() + + if (imports.length === 0) { + console.log('āœ… No imports found! Components can be safely deleted.') + return + } + + console.log(`āŒ Found ${imports.length} imports that need refactoring:\n`) + + const byFile: Record = {} + for (const imp of imports) { + if (!byFile[imp.file]) byFile[imp.file] = [] + byFile[imp.file].push(imp) + } + + for (const [file, fileImports] of Object.entries(byFile)) { + console.log(`šŸ“„ ${file}`) + for (const imp of fileImports) { + console.log(` Line ${imp.line}: ${imp.importStatement}`) + console.log(` → Imports: ${imp.importedComponents.join(', ')}`) + } + console.log() + } + + console.log('\nšŸ“Š Summary by category:') + const byCategory: Record = {} + for (const imp of imports) { + const key = imp.fromPath + byCategory[key] = (byCategory[key] || 0) + 1 + } + + for (const [component, count] of Object.entries(byCategory).sort((a, b) => b[1] - a[1])) { + console.log(` ${component}: ${count} imports`) + } +} + +main().catch(console.error) diff --git a/scripts/identify-pure-json-components.ts b/scripts/identify-pure-json-components.ts new file mode 100644 index 0000000..25189af --- /dev/null +++ b/scripts/identify-pure-json-components.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') + +// Components we restored (the ones we want to potentially convert to JSON) +const restoredComponents = { + ui: ['accordion', 'alert', 'aspect-ratio', 'avatar', 'badge', 'button', 'card', + 'checkbox', 'collapsible', 'dialog', 'hover-card', 'input', 'label', + 'popover', 'progress', 'radio-group', 'resizable', 'scroll-area', + 'separator', 'skeleton', 'sheet', 'switch', 'tabs', 'textarea', 'toggle', 'tooltip'], + molecules: ['DataSourceCard', 'EditorToolbar', 'EmptyEditorState', 'MonacoEditorPanel', 'SearchBar'], + organisms: ['EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel', + 'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions'], + atoms: ['Input'], +} + +interface ComponentAnalysis { + name: string + category: string + pureJSONEligible: boolean + reasons: string[] + complexity: 'simple' | 'medium' | 'complex' + hasHooks: boolean + hasConditionalLogic: boolean + hasHelperFunctions: boolean + hasComplexProps: boolean + importsCustomComponents: boolean + onlyImportsUIorAtoms: boolean +} + +async function analyzeComponent(category: string, component: string): Promise { + const tsFile = path.join(rootDir, `src/components/${category}/${component}.tsx`) + const content = await fs.readFile(tsFile, 'utf-8') + + const hasHooks = /useState|useEffect|useCallback|useMemo|useReducer|useRef|useContext/.test(content) + const hasConditionalLogic = /\?|if\s*\(|switch\s*\(/.test(content) + const hasHelperFunctions = /(?:const|function)\s+\w+\s*=\s*\([^)]*\)\s*=>/.test(content) && /return\s+\(/.test(content.split('return (')[0] || '') + const hasComplexProps = /\.\w+\s*\?/.test(content) || /Object\./.test(content) || /Array\./.test(content) + + // Check imports + const importLines = content.match(/import\s+.*?\s+from\s+['"](.*?)['"]/g) || [] + const importsCustomComponents = importLines.some(line => + /@\/components\/(molecules|organisms)/.test(line) + ) + const onlyImportsUIorAtoms = importLines.every(line => { + if (!line.includes('@/components/')) return true + return /@\/components\/(ui|atoms)/.test(line) + }) + + const reasons: string[] = [] + if (hasHooks) reasons.push('Has React hooks') + if (hasHelperFunctions) reasons.push('Has helper functions') + if (hasComplexProps) reasons.push('Has complex prop access') + if (importsCustomComponents) reasons.push('Imports molecules/organisms') + if (!onlyImportsUIorAtoms && !importsCustomComponents) reasons.push('Imports non-UI components') + + // Determine if eligible for pure JSON + const pureJSONEligible = !hasHooks && !hasHelperFunctions && !hasComplexProps && onlyImportsUIorAtoms + + // Complexity scoring + let complexity: 'simple' | 'medium' | 'complex' = 'simple' + if (hasHooks || hasHelperFunctions || hasComplexProps) { + complexity = 'complex' + } else if (hasConditionalLogic || importsCustomComponents) { + complexity = 'medium' + } + + return { + name: component, + category, + pureJSONEligible, + reasons, + complexity, + hasHooks, + hasConditionalLogic, + hasHelperFunctions, + hasComplexProps, + importsCustomComponents, + onlyImportsUIorAtoms, + } +} + +async function main() { + console.log('šŸ” Analyzing restored components for pure JSON eligibility...\\n') + + const eligible: ComponentAnalysis[] = [] + const ineligible: ComponentAnalysis[] = [] + + for (const [category, components] of Object.entries(restoredComponents)) { + for (const component of components) { + try { + const analysis = await analyzeComponent(category, component) + if (analysis.pureJSONEligible) { + eligible.push(analysis) + } else { + ineligible.push(analysis) + } + } catch (e) { + console.log(`āš ļø ${component} - Could not analyze: ${e}`) + } + } + } + + console.log(`\\nāœ… ELIGIBLE FOR PURE JSON (${eligible.length} components)\\n`) + for (const comp of eligible) { + console.log(` ${comp.name} (${comp.category})`) + console.log(` Complexity: ${comp.complexity}`) + console.log(` Conditional: ${comp.hasConditionalLogic ? 'Yes' : 'No'}`) + } + + console.log(`\\nāŒ MUST STAY TYPESCRIPT (${ineligible.length} components)\\n`) + for (const comp of ineligible) { + console.log(` ${comp.name} (${comp.category})`) + console.log(` Complexity: ${comp.complexity}`) + console.log(` Reasons: ${comp.reasons.join(', ')}`) + } + + console.log(`\\nšŸ“Š Summary:`) + console.log(` Eligible for JSON: ${eligible.length}`) + console.log(` Must stay TypeScript: ${ineligible.length}`) + console.log(` Conversion rate: ${Math.round(eligible.length / (eligible.length + ineligible.length) * 100)}%`) +} + +main().catch(console.error) diff --git a/scripts/refactor-to-dynamic-imports.ts b/scripts/refactor-to-dynamic-imports.ts new file mode 100644 index 0000000..4e9679d --- /dev/null +++ b/scripts/refactor-to-dynamic-imports.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') + +/** + * Strategy: Replace static imports with dynamic component loading + * + * Before: + * import { Button } from '@/components/ui/button' + * + * + * After: + * import { getComponent } from '@/lib/component-loader' + * const Button = getComponent('Button') + * + */ + +interface RefactorTask { + file: string + replacements: Array<{ + oldImport: string + newImport: string + components: string[] + }> +} + +const targetComponents = { + ui: ['button', 'card', 'badge', 'label', 'input', 'separator', 'scroll-area', + 'tabs', 'dialog', 'textarea', 'tooltip', 'switch', 'alert', 'skeleton', + 'progress', 'collapsible', 'resizable', 'popover', 'hover-card', 'checkbox', + 'accordion', 'aspect-ratio', 'avatar', 'radio-group', 'sheet', 'toggle'], + molecules: ['DataSourceCard', 'EditorToolbar', 'EmptyEditorState', 'MonacoEditorPanel', 'SearchBar'], + organisms: ['EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel', + 'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions'], + atoms: ['Input'] +} + +async function refactorFile(filePath: string): Promise { + let content = await fs.readFile(filePath, 'utf-8') + let modified = false + + // Find all imports to replace + const importLines: string[] = [] + const componentsToLoad = new Set() + + for (const [category, components] of Object.entries(targetComponents)) { + for (const component of components) { + const patterns = [ + new RegExp(`import\\s+\\{([^}]+)\\}\\s+from\\s+['"]@/components/${category}/${component}['"]`, 'g'), + new RegExp(`import\\s+(\\w+)\\s+from\\s+['"]@/components/${category}/${component}['"]`, 'g'), + ] + + for (const pattern of patterns) { + const matches = content.matchAll(pattern) + for (const match of matches) { + const importedItems = match[1].split(',').map(s => s.trim().split(' as ')[0].trim()) + importedItems.forEach(item => componentsToLoad.add(item)) + + // Remove the import line + content = content.replace(match[0], '') + modified = true + } + } + } + } + + if (!modified) return false + + // Add dynamic component loader import at top + const loaderImport = `import { loadComponent } from '@/lib/component-loader'\n` + + // Add component loading statements + const componentLoads = Array.from(componentsToLoad) + .map(comp => `const ${comp} = loadComponent('${comp}')`) + .join('\n') + + // Find first import statement location + const firstImportMatch = content.match(/^import\s/m) + if (firstImportMatch && firstImportMatch.index !== undefined) { + content = content.slice(0, firstImportMatch.index) + + loaderImport + '\n' + + componentLoads + '\n\n' + + content.slice(firstImportMatch.index) + } + + await fs.writeFile(filePath, content) + return true +} + +async function createComponentLoader() { + const loaderPath = path.join(rootDir, 'src/lib/component-loader.ts') + + const loaderContent = `/** + * Dynamic Component Loader + * Loads components from the registry at runtime instead of static imports + */ + +import { ComponentType, lazy } from 'react' + +const componentCache = new Map>() + +export function loadComponent(componentName: string): ComponentType { + if (componentCache.has(componentName)) { + return componentCache.get(componentName)! + } + + // Try to load from different sources + const loaders = [ + () => import(\`@/components/ui/\${componentName.toLowerCase()}\`), + () => import(\`@/components/atoms/\${componentName}\`), + () => import(\`@/components/molecules/\${componentName}\`), + () => import(\`@/components/organisms/\${componentName}\`), + ] + + // Create lazy component + const LazyComponent = lazy(async () => { + for (const loader of loaders) { + try { + const module = await loader() + return { default: module[componentName] || module.default } + } catch (e) { + continue + } + } + throw new Error(\`Component \${componentName} not found\`) + }) + + componentCache.set(componentName, LazyComponent) + return LazyComponent +} + +export function getComponent(componentName: string): ComponentType { + return loadComponent(componentName) +} +` + + await fs.writeFile(loaderPath, loaderContent) + console.log('āœ… Created component-loader.ts') +} + +async function main() { + console.log('šŸš€ Starting AGGRESSIVE refactoring to eliminate static imports...\n') + console.log('āš ļø WARNING: This is a MAJOR refactoring affecting 975+ import statements!\n') + console.log('Press Ctrl+C now if you want to reconsider...\n') + + await new Promise(resolve => setTimeout(resolve, 3000)) + + console.log('šŸ”§ Creating dynamic component loader...') + await createComponentLoader() + + console.log('\nšŸ“ This approach requires significant testing and may break things.') + console.log(' Recommendation: Manual refactoring of high-value components instead.\n') +} + +main().catch(console.error) diff --git a/src/components/atoms/ActionIcon.tsx b/src/components/atoms/ActionIcon.tsx new file mode 100644 index 0000000..ea64248 --- /dev/null +++ b/src/components/atoms/ActionIcon.tsx @@ -0,0 +1,22 @@ +import { Plus, Pencil, Trash, Copy, Download, Upload } from '@phosphor-icons/react' + +interface ActionIconProps { + action: 'add' | 'edit' | 'delete' | 'copy' | 'download' | 'upload' + size?: number + weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' + className?: string +} + +export function ActionIcon({ action, size = 16, weight = 'regular', className = '' }: ActionIconProps) { + const iconMap = { + add: Plus, + edit: Pencil, + delete: Trash, + copy: Copy, + download: Download, + upload: Upload, + } + + const IconComponent = iconMap[action] + return +} diff --git a/src/components/atoms/Alert.tsx b/src/components/atoms/Alert.tsx new file mode 100644 index 0000000..f456881 --- /dev/null +++ b/src/components/atoms/Alert.tsx @@ -0,0 +1,51 @@ +import { ReactNode } from 'react' +import { Info, Warning, CheckCircle, XCircle } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface AlertProps { + variant?: 'info' | 'warning' | 'success' | 'error' + title?: string + children: ReactNode + className?: string +} + +const variantConfig = { + info: { + icon: Info, + classes: 'bg-blue-50 border-blue-200 text-blue-900', + }, + warning: { + icon: Warning, + classes: 'bg-yellow-50 border-yellow-200 text-yellow-900', + }, + success: { + icon: CheckCircle, + classes: 'bg-green-50 border-green-200 text-green-900', + }, + error: { + icon: XCircle, + classes: 'bg-red-50 border-red-200 text-red-900', + }, +} + +export function Alert({ variant = 'info', title, children, className }: AlertProps) { + const config = variantConfig[variant] + const Icon = config.icon + + return ( +
+ +
+ {title &&
{title}
} +
{children}
+
+
+ ) +} diff --git a/src/components/atoms/AppLogo.tsx b/src/components/atoms/AppLogo.tsx new file mode 100644 index 0000000..7977dc4 --- /dev/null +++ b/src/components/atoms/AppLogo.tsx @@ -0,0 +1,9 @@ +import { Code } from '@phosphor-icons/react' + +export function AppLogo() { + return ( +
+ +
+ ) +} diff --git a/src/components/atoms/Avatar.tsx b/src/components/atoms/Avatar.tsx new file mode 100644 index 0000000..fd5083a --- /dev/null +++ b/src/components/atoms/Avatar.tsx @@ -0,0 +1,37 @@ +import { cn } from '@/lib/utils' + +interface AvatarProps { + src?: string + alt?: string + fallback?: string + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' + className?: string +} + +const sizeClasses = { + xs: 'w-6 h-6 text-xs', + sm: 'w-8 h-8 text-sm', + md: 'w-10 h-10 text-base', + lg: 'w-12 h-12 text-lg', + xl: 'w-16 h-16 text-xl', +} + +export function Avatar({ src, alt, fallback, size = 'md', className }: AvatarProps) { + const initials = fallback || alt?.slice(0, 2).toUpperCase() || '?' + + return ( +
+ {src ? ( + {alt} + ) : ( + {initials} + )} +
+ ) +} diff --git a/src/components/atoms/AvatarGroup.tsx b/src/components/atoms/AvatarGroup.tsx new file mode 100644 index 0000000..da7b7ed --- /dev/null +++ b/src/components/atoms/AvatarGroup.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/lib/utils' + +interface AvatarGroupProps { + avatars: { + src?: string + alt: string + fallback: string + }[] + max?: number + size?: 'xs' | 'sm' | 'md' | 'lg' + className?: string +} + +const sizeClasses = { + xs: 'h-6 w-6 text-xs', + sm: 'h-8 w-8 text-xs', + md: 'h-10 w-10 text-sm', + lg: 'h-12 w-12 text-base', +} + +export function AvatarGroup({ + avatars, + max = 5, + size = 'md', + className, +}: AvatarGroupProps) { + const displayAvatars = avatars.slice(0, max) + const remainingCount = Math.max(avatars.length - max, 0) + + return ( +
+ {displayAvatars.map((avatar, index) => ( +
+ {avatar.src ? ( + {avatar.alt} + ) : ( + {avatar.fallback} + )} +
+ ))} + {remainingCount > 0 && ( +
+ +{remainingCount} +
+ )} +
+ ) +} diff --git a/src/components/atoms/Breadcrumb.tsx b/src/components/atoms/Breadcrumb.tsx new file mode 100644 index 0000000..5134007 --- /dev/null +++ b/src/components/atoms/Breadcrumb.tsx @@ -0,0 +1,53 @@ +import { CaretRight } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface BreadcrumbItem { + label: string + href?: string + onClick?: () => void +} + +interface BreadcrumbNavProps { + items?: BreadcrumbItem[] + className?: string +} + +export function BreadcrumbNav({ items = [], className }: BreadcrumbNavProps) { + return ( + + ) +} + +export const Breadcrumb = BreadcrumbNav diff --git a/src/components/atoms/ButtonGroup.tsx b/src/components/atoms/ButtonGroup.tsx new file mode 100644 index 0000000..1141b89 --- /dev/null +++ b/src/components/atoms/ButtonGroup.tsx @@ -0,0 +1,33 @@ +import { cn } from '@/lib/utils' +import { ReactNode } from 'react' + +interface ButtonGroupProps { + children: ReactNode + orientation?: 'horizontal' | 'vertical' + className?: string +} + +export function ButtonGroup({ + children, + orientation = 'horizontal', + className, +}: ButtonGroupProps) { + return ( +
button]:rounded-none', + '[&>button:first-child]:rounded-l-md', + '[&>button:last-child]:rounded-r-md', + orientation === 'vertical' && '[&>button:first-child]:rounded-t-md [&>button:first-child]:rounded-l-none', + orientation === 'vertical' && '[&>button:last-child]:rounded-b-md [&>button:last-child]:rounded-r-none', + '[&>button:not(:last-child)]:border-r-0', + orientation === 'vertical' && '[&>button:not(:last-child)]:border-b-0 [&>button:not(:last-child)]:border-r', + className + )} + > + {children} +
+ ) +} diff --git a/src/components/atoms/Checkbox.tsx b/src/components/atoms/Checkbox.tsx new file mode 100644 index 0000000..e5cf5dd --- /dev/null +++ b/src/components/atoms/Checkbox.tsx @@ -0,0 +1,60 @@ +import { Check, Minus } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface CheckboxProps { + checked: boolean + onChange: (checked: boolean) => void + label?: string + indeterminate?: boolean + disabled?: boolean + size?: 'sm' | 'md' | 'lg' + className?: string +} + +export function Checkbox({ + checked, + onChange, + label, + indeterminate = false, + disabled = false, + size = 'md', + className +}: CheckboxProps) { + const sizeStyles = { + sm: 'w-4 h-4', + md: 'w-5 h-5', + lg: 'w-6 h-6', + } + + const iconSize = { + sm: 12, + md: 16, + lg: 20, + } + + return ( + + ) +} diff --git a/src/components/atoms/Chip.tsx b/src/components/atoms/Chip.tsx new file mode 100644 index 0000000..fb36da5 --- /dev/null +++ b/src/components/atoms/Chip.tsx @@ -0,0 +1,54 @@ +import { ReactNode } from 'react' +import { X } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface ChipProps { + children: ReactNode + variant?: 'default' | 'primary' | 'accent' | 'muted' + size?: 'sm' | 'md' + onRemove?: () => void + className?: string +} + +const variantClasses = { + default: 'bg-secondary text-secondary-foreground', + primary: 'bg-primary text-primary-foreground', + accent: 'bg-accent text-accent-foreground', + muted: 'bg-muted text-muted-foreground', +} + +const sizeClasses = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-3 py-1 text-sm', +} + +export function Chip({ + children, + variant = 'default', + size = 'md', + onRemove, + className +}: ChipProps) { + return ( + + {children} + {onRemove && ( + + )} + + ) +} diff --git a/src/components/atoms/Code.tsx b/src/components/atoms/Code.tsx new file mode 100644 index 0000000..0416b4c --- /dev/null +++ b/src/components/atoms/Code.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface CodeProps { + children: ReactNode + inline?: boolean + className?: string +} + +export function Code({ children, inline = true, className }: CodeProps) { + if (inline) { + return ( + + {children} + + ) + } + + return ( +
+      {children}
+    
+ ) +} diff --git a/src/components/atoms/ColorSwatch.tsx b/src/components/atoms/ColorSwatch.tsx new file mode 100644 index 0000000..28e38cb --- /dev/null +++ b/src/components/atoms/ColorSwatch.tsx @@ -0,0 +1,46 @@ +import { Check } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface ColorSwatchProps { + color: string + selected?: boolean + onClick?: () => void + size?: 'sm' | 'md' | 'lg' + label?: string + className?: string +} + +export function ColorSwatch({ + color, + selected = false, + onClick, + size = 'md', + label, + className +}: ColorSwatchProps) { + const sizeStyles = { + sm: 'w-6 h-6', + md: 'w-8 h-8', + lg: 'w-10 h-10', + } + + return ( +
+ + {label && {label}} +
+ ) +} diff --git a/src/components/atoms/Container.tsx b/src/components/atoms/Container.tsx new file mode 100644 index 0000000..e913ad5 --- /dev/null +++ b/src/components/atoms/Container.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface ContainerProps { + children: ReactNode + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' + className?: string +} + +const sizeClasses = { + sm: 'max-w-screen-sm', + md: 'max-w-screen-md', + lg: 'max-w-screen-lg', + xl: 'max-w-screen-xl', + full: 'max-w-full', +} + +export function Container({ children, size = 'xl', className }: ContainerProps) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/atoms/DataList.tsx b/src/components/atoms/DataList.tsx new file mode 100644 index 0000000..109cb41 --- /dev/null +++ b/src/components/atoms/DataList.tsx @@ -0,0 +1,55 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export interface DataListProps { + items: any[] + renderItem?: (item: any, index: number) => ReactNode + emptyMessage?: string + className?: string + itemClassName?: string + itemKey?: string +} + +export function DataList({ + items, + renderItem, + emptyMessage = 'No items', + className, + itemClassName, + itemKey, +}: DataListProps) { + if (items.length === 0) { + return ( +
+ {emptyMessage} +
+ ) + } + + const renderFallbackItem = (item: any) => { + if (itemKey && item && typeof item === 'object') { + const value = item[itemKey] + if (value !== undefined && value !== null) { + return typeof value === 'string' || typeof value === 'number' + ? value + : JSON.stringify(value) + } + } + + if (typeof item === 'string' || typeof item === 'number') { + return item + } + + return JSON.stringify(item) + } + + return ( +
+ {items.map((item, index) => ( +
+ {renderItem ? renderItem(item, index) : renderFallbackItem(item)} +
+ ))} +
+ ) +} diff --git a/src/components/atoms/Divider.tsx b/src/components/atoms/Divider.tsx new file mode 100644 index 0000000..9f87515 --- /dev/null +++ b/src/components/atoms/Divider.tsx @@ -0,0 +1,25 @@ +import { cn } from '@/lib/utils' + +interface DividerProps { + orientation?: 'horizontal' | 'vertical' + className?: string + decorative?: boolean +} + +export function Divider({ + orientation = 'horizontal', + className, + decorative = true +}: DividerProps) { + return ( +
+ ) +} diff --git a/src/components/atoms/Dot.tsx b/src/components/atoms/Dot.tsx new file mode 100644 index 0000000..c64addf --- /dev/null +++ b/src/components/atoms/Dot.tsx @@ -0,0 +1,53 @@ +import { cn } from '@/lib/utils' + +interface DotProps { + variant?: 'default' | 'primary' | 'accent' | 'success' | 'warning' | 'error' + size?: 'xs' | 'sm' | 'md' | 'lg' + pulse?: boolean + className?: string +} + +const variantClasses = { + default: 'bg-muted-foreground', + primary: 'bg-primary', + accent: 'bg-accent', + success: 'bg-green-500', + warning: 'bg-yellow-500', + error: 'bg-destructive', +} + +const sizeClasses = { + xs: 'w-1.5 h-1.5', + sm: 'w-2 h-2', + md: 'w-3 h-3', + lg: 'w-4 h-4', +} + +export function Dot({ + variant = 'default', + size = 'sm', + pulse = false, + className +}: DotProps) { + return ( + + + {pulse && ( + + )} + + ) +} diff --git a/src/components/atoms/Drawer.tsx b/src/components/atoms/Drawer.tsx new file mode 100644 index 0000000..d4138ad --- /dev/null +++ b/src/components/atoms/Drawer.tsx @@ -0,0 +1,80 @@ +import { X } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface DrawerProps { + isOpen: boolean + onClose: () => void + title?: string + children: React.ReactNode + position?: 'left' | 'right' | 'top' | 'bottom' + size?: 'sm' | 'md' | 'lg' + showCloseButton?: boolean + className?: string +} + +export function Drawer({ + isOpen, + onClose, + title, + children, + position = 'right', + size = 'md', + showCloseButton = true, + className, +}: DrawerProps) { + if (!isOpen) return null + + const positionStyles = { + left: 'left-0 top-0 h-full', + right: 'right-0 top-0 h-full', + top: 'top-0 left-0 w-full', + bottom: 'bottom-0 left-0 w-full', + } + + const sizeStyles = { + sm: position === 'left' || position === 'right' ? 'w-64' : 'h-64', + md: position === 'left' || position === 'right' ? 'w-96' : 'h-96', + lg: position === 'left' || position === 'right' ? 'w-[600px]' : 'h-[600px]', + } + + const slideAnimation = { + left: 'animate-in slide-in-from-left', + right: 'animate-in slide-in-from-right', + top: 'animate-in slide-in-from-top', + bottom: 'animate-in slide-in-from-bottom', + } + + return ( + <> +
+
+ {(title || showCloseButton) && ( +
+ {title &&

{title}

} + {showCloseButton && ( + + )} +
+ )} +
{children}
+
+ + ) +} diff --git a/src/components/atoms/EmptyStateIcon.tsx b/src/components/atoms/EmptyStateIcon.tsx new file mode 100644 index 0000000..9c2153b --- /dev/null +++ b/src/components/atoms/EmptyStateIcon.tsx @@ -0,0 +1,17 @@ +interface EmptyStateIconProps { + icon: React.ReactNode + variant?: 'default' | 'muted' +} + +export function EmptyStateIcon({ icon, variant = 'muted' }: EmptyStateIconProps) { + const variantClasses = { + default: 'from-primary/20 to-accent/20 text-primary', + muted: 'from-muted to-muted/50 text-muted-foreground', + } + + return ( +
+ {icon} +
+ ) +} diff --git a/src/components/atoms/FileIcon.tsx b/src/components/atoms/FileIcon.tsx new file mode 100644 index 0000000..385b75f --- /dev/null +++ b/src/components/atoms/FileIcon.tsx @@ -0,0 +1,19 @@ +import { FileCode, FileJs, FilePlus } from '@phosphor-icons/react' + +interface FileIconProps { + type?: 'code' | 'json' | 'plus' + size?: number + weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' + className?: string +} + +export function FileIcon({ type = 'code', size = 20, weight = 'regular', className = '' }: FileIconProps) { + const iconMap = { + code: FileCode, + json: FileJs, + plus: FilePlus, + } + + const IconComponent = iconMap[type] + return +} diff --git a/src/components/atoms/Flex.tsx b/src/components/atoms/Flex.tsx new file mode 100644 index 0000000..7ac4d32 --- /dev/null +++ b/src/components/atoms/Flex.tsx @@ -0,0 +1,83 @@ +import { cn } from '@/lib/utils' +import { ReactNode } from 'react' + +interface FlexProps { + children: ReactNode + direction?: 'row' | 'col' | 'row-reverse' | 'col-reverse' + align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline' + justify?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly' + gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' + wrap?: 'wrap' | 'nowrap' | 'wrap-reverse' + grow?: boolean + shrink?: boolean + className?: string +} + +const directionClasses = { + row: 'flex-row', + col: 'flex-col', + 'row-reverse': 'flex-row-reverse', + 'col-reverse': 'flex-col-reverse', +} + +const alignClasses = { + start: 'items-start', + center: 'items-center', + end: 'items-end', + stretch: 'items-stretch', + baseline: 'items-baseline', +} + +const justifyClasses = { + start: 'justify-start', + center: 'justify-center', + end: 'justify-end', + between: 'justify-between', + around: 'justify-around', + evenly: 'justify-evenly', +} + +const gapClasses = { + none: 'gap-0', + xs: 'gap-1', + sm: 'gap-2', + md: 'gap-4', + lg: 'gap-6', + xl: 'gap-8', +} + +const wrapClasses = { + wrap: 'flex-wrap', + nowrap: 'flex-nowrap', + 'wrap-reverse': 'flex-wrap-reverse', +} + +export function Flex({ + children, + direction = 'row', + align = 'stretch', + justify = 'start', + gap = 'md', + wrap = 'nowrap', + grow = false, + shrink = false, + className, +}: FlexProps) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/atoms/Grid.tsx b/src/components/atoms/Grid.tsx new file mode 100644 index 0000000..e2326d9 --- /dev/null +++ b/src/components/atoms/Grid.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from 'react' + +interface GridProps { + children: ReactNode + cols?: 1 | 2 | 3 | 4 | 6 | 12 + gap?: 1 | 2 | 3 | 4 | 6 | 8 + className?: string +} + +const colsClasses = { + 1: 'grid-cols-1', + 2: 'grid-cols-1 md:grid-cols-2', + 3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', + 4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4', + 6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6', + 12: 'grid-cols-3 md:grid-cols-6 lg:grid-cols-12', +} + +const gapClasses = { + 1: 'gap-1', + 2: 'gap-2', + 3: 'gap-3', + 4: 'gap-4', + 6: 'gap-6', + 8: 'gap-8', +} + +export function Grid({ children, cols = 1, gap = 4, className = '' }: GridProps) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/atoms/Heading.tsx b/src/components/atoms/Heading.tsx new file mode 100644 index 0000000..8f098dd --- /dev/null +++ b/src/components/atoms/Heading.tsx @@ -0,0 +1,24 @@ +import { ReactNode, createElement } from 'react' + +interface HeadingProps { + children: ReactNode + level?: 1 | 2 | 3 | 4 | 5 | 6 + className?: string +} + +const levelClasses = { + 1: 'text-4xl font-bold tracking-tight', + 2: 'text-3xl font-semibold tracking-tight', + 3: 'text-2xl font-semibold tracking-tight', + 4: 'text-xl font-semibold', + 5: 'text-lg font-medium', + 6: 'text-base font-medium', +} + +export function Heading({ children, level = 1, className = '' }: HeadingProps) { + return createElement( + `h${level}`, + { className: `${levelClasses[level]} ${className}` }, + children + ) +} diff --git a/src/components/atoms/HelperText.tsx b/src/components/atoms/HelperText.tsx new file mode 100644 index 0000000..ce0cd33 --- /dev/null +++ b/src/components/atoms/HelperText.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface HelperTextProps { + children: ReactNode + variant?: 'default' | 'error' | 'success' + className?: string +} + +const variantClasses = { + default: 'text-muted-foreground', + error: 'text-destructive', + success: 'text-green-600', +} + +export function HelperText({ children, variant = 'default', className }: HelperTextProps) { + return ( +

+ {children} +

+ ) +} diff --git a/src/components/atoms/IconText.tsx b/src/components/atoms/IconText.tsx new file mode 100644 index 0000000..6582089 --- /dev/null +++ b/src/components/atoms/IconText.tsx @@ -0,0 +1,36 @@ +import { cn } from '@/lib/utils' + +interface IconTextProps { + icon: React.ReactNode + children: React.ReactNode + gap?: 'sm' | 'md' | 'lg' + align?: 'start' | 'center' | 'end' + className?: string +} + +export function IconText({ + icon, + children, + gap = 'md', + align = 'center', + className +}: IconTextProps) { + const gapStyles = { + sm: 'gap-1', + md: 'gap-2', + lg: 'gap-3', + } + + const alignStyles = { + start: 'items-start', + center: 'items-center', + end: 'items-end', + } + + return ( +
+ {icon} + {children} +
+ ) +} diff --git a/src/components/atoms/IconWrapper.tsx b/src/components/atoms/IconWrapper.tsx new file mode 100644 index 0000000..27bcf09 --- /dev/null +++ b/src/components/atoms/IconWrapper.tsx @@ -0,0 +1,32 @@ +interface IconWrapperProps { + icon: React.ReactNode + size?: 'sm' | 'md' | 'lg' + variant?: 'default' | 'muted' | 'primary' | 'destructive' + className?: string +} + +export function IconWrapper({ + icon, + size = 'md', + variant = 'default', + className = '' +}: IconWrapperProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-5 h-5', + lg: 'w-6 h-6', + } + + const variantClasses = { + default: 'text-foreground', + muted: 'text-muted-foreground', + primary: 'text-primary', + destructive: 'text-destructive', + } + + return ( + + {icon} + + ) +} diff --git a/src/components/atoms/InfoBox.tsx b/src/components/atoms/InfoBox.tsx new file mode 100644 index 0000000..c49e752 --- /dev/null +++ b/src/components/atoms/InfoBox.tsx @@ -0,0 +1,41 @@ +import { cn } from '@/lib/utils' +import { Info, Warning, CheckCircle, XCircle } from '@phosphor-icons/react' + +interface InfoBoxProps { + type?: 'info' | 'warning' | 'success' | 'error' + title?: string + children: React.ReactNode + className?: string +} + +const iconMap = { + info: Info, + warning: Warning, + success: CheckCircle, + error: XCircle, +} + +const variantClasses = { + info: 'bg-blue-500/10 border-blue-500/20 text-blue-700 dark:text-blue-300', + warning: 'bg-yellow-500/10 border-yellow-500/20 text-yellow-700 dark:text-yellow-300', + success: 'bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-300', + error: 'bg-destructive/10 border-destructive/20 text-destructive', +} + +export function InfoBox({ type = 'info', title, children, className }: InfoBoxProps) { + const Icon = iconMap[type] + + return ( +
+ +
+ {title &&
{title}
} +
{children}
+
+
+ ) +} diff --git a/src/components/atoms/InfoPanel.tsx b/src/components/atoms/InfoPanel.tsx new file mode 100644 index 0000000..df64750 --- /dev/null +++ b/src/components/atoms/InfoPanel.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/lib/utils' +import { ReactNode } from 'react' + +interface InfoPanelProps { + children: ReactNode + variant?: 'info' | 'warning' | 'success' | 'error' | 'default' + title?: string + icon?: ReactNode + className?: string +} + +const variantClasses = { + default: 'bg-card border-border', + info: 'bg-blue-500/10 border-blue-500/20 text-blue-700 dark:text-blue-300', + warning: 'bg-yellow-500/10 border-yellow-500/20 text-yellow-700 dark:text-yellow-300', + success: 'bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-300', + error: 'bg-red-500/10 border-red-500/20 text-red-700 dark:text-red-300', +} + +export function InfoPanel({ + children, + variant = 'default', + title, + icon, + className, +}: InfoPanelProps) { + return ( +
+ {(title || icon) && ( +
+ {icon &&
{icon}
} + {title &&
{title}
} +
+ )} +
{children}
+
+ ) +} diff --git a/src/components/atoms/Kbd.tsx b/src/components/atoms/Kbd.tsx new file mode 100644 index 0000000..42e1e90 --- /dev/null +++ b/src/components/atoms/Kbd.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface KbdProps { + children: ReactNode + className?: string +} + +export function Kbd({ children, className }: KbdProps) { + return ( + + {children} + + ) +} diff --git a/src/components/atoms/KeyValue.tsx b/src/components/atoms/KeyValue.tsx new file mode 100644 index 0000000..d00d644 --- /dev/null +++ b/src/components/atoms/KeyValue.tsx @@ -0,0 +1,34 @@ +import { cn } from '@/lib/utils' + +interface KeyValueProps { + label: string + value: React.ReactNode + orientation?: 'horizontal' | 'vertical' + className?: string + labelClassName?: string + valueClassName?: string +} + +export function KeyValue({ + label, + value, + orientation = 'horizontal', + className, + labelClassName, + valueClassName +}: KeyValueProps) { + return ( +
+ + {label} + + + {value} + +
+ ) +} diff --git a/src/components/atoms/Label.tsx b/src/components/atoms/Label.tsx new file mode 100644 index 0000000..97e897c --- /dev/null +++ b/src/components/atoms/Label.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface LabelProps { + children: ReactNode + htmlFor?: string + required?: boolean + className?: string +} + +export function Label({ children, htmlFor, required, className }: LabelProps) { + return ( + + ) +} diff --git a/src/components/atoms/Link.tsx b/src/components/atoms/Link.tsx new file mode 100644 index 0000000..34a2089 --- /dev/null +++ b/src/components/atoms/Link.tsx @@ -0,0 +1,40 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface LinkProps { + href: string + children: ReactNode + variant?: 'default' | 'muted' | 'accent' | 'destructive' + external?: boolean + className?: string + onClick?: (e: React.MouseEvent) => void +} + +const variantClasses = { + default: 'text-foreground hover:text-primary underline-offset-4 hover:underline', + muted: 'text-muted-foreground hover:text-foreground underline-offset-4 hover:underline', + accent: 'text-accent hover:text-accent/80 underline-offset-4 hover:underline', + destructive: 'text-destructive hover:text-destructive/80 underline-offset-4 hover:underline', +} + +export function Link({ + href, + children, + variant = 'default', + external = false, + className, + onClick +}: LinkProps) { + const externalProps = external ? { target: '_blank', rel: 'noopener noreferrer' } : {} + + return ( + + {children} + + ) +} diff --git a/src/components/atoms/List.tsx b/src/components/atoms/List.tsx new file mode 100644 index 0000000..325a980 --- /dev/null +++ b/src/components/atoms/List.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react' + +interface ListProps { + items: T[] + renderItem: (item: T, index: number) => ReactNode + emptyMessage?: string + className?: string + itemClassName?: string +} + +export function List({ + items, + renderItem, + emptyMessage = 'No items to display', + className = '', + itemClassName = '' +}: ListProps) { + if (items.length === 0) { + return ( +
+ {emptyMessage} +
+ ) + } + + return ( +
+ {items.map((item, index) => ( +
+ {renderItem(item, index)} +
+ ))} +
+ ) +} diff --git a/src/components/atoms/ListItem.tsx b/src/components/atoms/ListItem.tsx new file mode 100644 index 0000000..6042eef --- /dev/null +++ b/src/components/atoms/ListItem.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/lib/utils' + +interface ListItemProps { + icon?: React.ReactNode + children: React.ReactNode + onClick?: () => void + active?: boolean + className?: string + endContent?: React.ReactNode +} + +export function ListItem({ icon, children, onClick, active, className, endContent }: ListItemProps) { + const isInteractive = !!onClick + + return ( +
+ {icon &&
{icon}
} +
{children}
+ {endContent &&
{endContent}
} +
+ ) +} diff --git a/src/components/atoms/LiveIndicator.tsx b/src/components/atoms/LiveIndicator.tsx new file mode 100644 index 0000000..a6ec0a6 --- /dev/null +++ b/src/components/atoms/LiveIndicator.tsx @@ -0,0 +1,49 @@ +import { cn } from '@/lib/utils' + +interface LiveIndicatorProps { + label?: string + showLabel?: boolean + size?: 'sm' | 'md' | 'lg' + className?: string +} + +export function LiveIndicator({ + label = 'LIVE', + showLabel = true, + size = 'md', + className, +}: LiveIndicatorProps) { + const sizeClasses = { + sm: 'text-xs gap-1.5', + md: 'text-sm gap-2', + lg: 'text-base gap-2.5', + } + + const dotSizeClasses = { + sm: 'w-2 h-2', + md: 'w-2.5 h-2.5', + lg: 'w-3 h-3', + } + + return ( +
+ + + + + {showLabel && ( + {label} + )} +
+ ) +} diff --git a/src/components/atoms/LoadingSpinner.tsx b/src/components/atoms/LoadingSpinner.tsx new file mode 100644 index 0000000..7420ca7 --- /dev/null +++ b/src/components/atoms/LoadingSpinner.tsx @@ -0,0 +1,20 @@ +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg' + className?: string +} + +export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) { + const sizeClasses = { + sm: 'w-4 h-4 border-2', + md: 'w-6 h-6 border-2', + lg: 'w-8 h-8 border-3', + } + + return ( +
+ ) +} diff --git a/src/components/atoms/LoadingState.tsx b/src/components/atoms/LoadingState.tsx new file mode 100644 index 0000000..87ab907 --- /dev/null +++ b/src/components/atoms/LoadingState.tsx @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +export interface LoadingStateProps { + message?: string + size?: 'sm' | 'md' | 'lg' + className?: string +} + +export function LoadingState({ + message = 'Loading...', + size = 'md', + className +}: LoadingStateProps) { + const sizeClasses = { + sm: 'w-4 h-4 border-2', + md: 'w-8 h-8 border-3', + lg: 'w-12 h-12 border-4', + } + + return ( +
+
+ {message && ( +

{message}

+ )} +
+ ) +} diff --git a/src/components/atoms/MetricDisplay.tsx b/src/components/atoms/MetricDisplay.tsx new file mode 100644 index 0000000..29549c9 --- /dev/null +++ b/src/components/atoms/MetricDisplay.tsx @@ -0,0 +1,52 @@ +import { cn } from '@/lib/utils' +import { TrendUp, TrendDown } from '@phosphor-icons/react' + +interface MetricDisplayProps { + label: string + value: string | number + trend?: { + value: number + direction: 'up' | 'down' + } + icon?: React.ReactNode + className?: string + variant?: 'default' | 'primary' | 'accent' +} + +export function MetricDisplay({ + label, + value, + trend, + icon, + className, + variant = 'default' +}: MetricDisplayProps) { + const variantClasses = { + default: 'text-foreground', + primary: 'text-primary', + accent: 'text-accent', + } + + return ( +
+
+ {icon && {icon}} + {label} +
+
+ + {value} + + {trend && ( + + {trend.direction === 'up' ? : } + {Math.abs(trend.value)}% + + )} +
+
+ ) +} diff --git a/src/components/atoms/Modal.tsx b/src/components/atoms/Modal.tsx new file mode 100644 index 0000000..2f2b737 --- /dev/null +++ b/src/components/atoms/Modal.tsx @@ -0,0 +1,64 @@ +import { X } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface ModalProps { + isOpen: boolean + onClose: () => void + title?: string + children: React.ReactNode + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' + showCloseButton?: boolean + className?: string +} + +export function Modal({ + isOpen, + onClose, + title, + children, + size = 'md', + showCloseButton = true, + className, +}: ModalProps) { + if (!isOpen) return null + + const sizeStyles = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + full: 'max-w-full m-4', + } + + return ( +
+
e.stopPropagation()} + > + {(title || showCloseButton) && ( +
+ {title &&

{title}

} + {showCloseButton && ( + + )} +
+ )} +
{children}
+
+
+ ) +} diff --git a/src/components/atoms/Notification.tsx b/src/components/atoms/Notification.tsx new file mode 100644 index 0000000..452ae42 --- /dev/null +++ b/src/components/atoms/Notification.tsx @@ -0,0 +1,67 @@ +import { Info, CheckCircle, Warning, XCircle } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface NotificationProps { + type: 'info' | 'success' | 'warning' | 'error' + title: string + message?: string + onClose?: () => void + className?: string +} + +export function Notification({ type, title, message, onClose, className }: NotificationProps) { + const config = { + info: { + icon: Info, + color: 'text-blue-500', + bg: 'bg-blue-500/10', + border: 'border-blue-500/20', + }, + success: { + icon: CheckCircle, + color: 'text-accent', + bg: 'bg-accent/10', + border: 'border-accent/20', + }, + warning: { + icon: Warning, + color: 'text-yellow-500', + bg: 'bg-yellow-500/10', + border: 'border-yellow-500/20', + }, + error: { + icon: XCircle, + color: 'text-destructive', + bg: 'bg-destructive/10', + border: 'border-destructive/20', + }, + } + + const { icon: Icon, color, bg, border } = config[type] + + return ( +
+ +
+

{title}

+ {message &&

{message}

} +
+ {onClose && ( + + )} +
+ ) +} diff --git a/src/components/atoms/PageHeader.tsx b/src/components/atoms/PageHeader.tsx new file mode 100644 index 0000000..cc53c6f --- /dev/null +++ b/src/components/atoms/PageHeader.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/lib/utils' + +interface BasicPageHeaderProps { + title: string + description?: string + actions?: React.ReactNode + className?: string +} + +export function BasicPageHeader({ title, description, actions, className }: BasicPageHeaderProps) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions && ( +
{actions}
+ )} +
+ ) +} diff --git a/src/components/atoms/ProgressBar.tsx b/src/components/atoms/ProgressBar.tsx new file mode 100644 index 0000000..680a4ec --- /dev/null +++ b/src/components/atoms/ProgressBar.tsx @@ -0,0 +1,62 @@ +import { cn } from '@/lib/utils' + +interface ProgressBarProps { + value: number + max?: number + size?: 'sm' | 'md' | 'lg' + variant?: 'default' | 'accent' | 'destructive' + showLabel?: boolean + className?: string +} + +const sizeClasses = { + sm: 'h-1', + md: 'h-2', + lg: 'h-3', +} + +const variantClasses = { + default: 'bg-primary', + accent: 'bg-accent', + destructive: 'bg-destructive', +} + +export function ProgressBar({ + value, + max = 100, + size = 'md', + variant = 'default', + showLabel = false, + className +}: ProgressBarProps) { + const percentage = Math.min(Math.max((value / max) * 100, 0), 100) + + return ( +
+
+
+
+ {showLabel && ( + + {Math.round(percentage)}% + + )} +
+ ) +} diff --git a/src/components/atoms/Pulse.tsx b/src/components/atoms/Pulse.tsx new file mode 100644 index 0000000..22b9eb3 --- /dev/null +++ b/src/components/atoms/Pulse.tsx @@ -0,0 +1,56 @@ +import { cn } from '@/lib/utils' + +interface PulseProps { + variant?: 'primary' | 'accent' | 'success' | 'warning' | 'error' + size?: 'sm' | 'md' | 'lg' + speed?: 'slow' | 'normal' | 'fast' + className?: string +} + +export function Pulse({ + variant = 'primary', + size = 'md', + speed = 'normal', + className, +}: PulseProps) { + const sizeClasses = { + sm: 'w-2 h-2', + md: 'w-3 h-3', + lg: 'w-4 h-4', + } + + const variantClasses = { + primary: 'bg-primary', + accent: 'bg-accent', + success: 'bg-green-500', + warning: 'bg-yellow-500', + error: 'bg-red-500', + } + + const speedClasses = { + slow: 'animate-pulse [animation-duration:3s]', + normal: 'animate-pulse', + fast: 'animate-pulse [animation-duration:0.5s]', + } + + return ( +
+ + +
+ ) +} diff --git a/src/components/atoms/Radio.tsx b/src/components/atoms/Radio.tsx new file mode 100644 index 0000000..7fec135 --- /dev/null +++ b/src/components/atoms/Radio.tsx @@ -0,0 +1,69 @@ +import { cn } from '@/lib/utils' + +interface RadioOption { + value: string + label: string + disabled?: boolean +} + +interface RadioGroupProps { + options: RadioOption[] + value: string + onChange: (value: string) => void + name: string + orientation?: 'horizontal' | 'vertical' + className?: string +} + +export function RadioGroup({ + options, + value, + onChange, + name, + orientation = 'vertical', + className +}: RadioGroupProps) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} diff --git a/src/components/atoms/Rating.tsx b/src/components/atoms/Rating.tsx new file mode 100644 index 0000000..4427246 --- /dev/null +++ b/src/components/atoms/Rating.tsx @@ -0,0 +1,71 @@ +import { Star } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface RatingProps { + value: number + onChange?: (value: number) => void + max?: number + size?: 'sm' | 'md' | 'lg' + readonly?: boolean + showValue?: boolean + className?: string +} + +export function Rating({ + value, + onChange, + max = 5, + size = 'md', + readonly = false, + showValue = false, + className +}: RatingProps) { + const sizeStyles = { + sm: 16, + md: 20, + lg: 24, + } + + const iconSize = sizeStyles[size] + + return ( +
+
+ {Array.from({ length: max }, (_, index) => { + const starValue = index + 1 + const isFilled = starValue <= value + const isHalfFilled = starValue - 0.5 === value + + return ( + + ) + })} +
+ {showValue && ( + + {value.toFixed(1)} / {max} + + )} +
+ ) +} diff --git a/src/components/atoms/ResponsiveGrid.tsx b/src/components/atoms/ResponsiveGrid.tsx new file mode 100644 index 0000000..e4e4073 --- /dev/null +++ b/src/components/atoms/ResponsiveGrid.tsx @@ -0,0 +1,57 @@ +import { cn } from '@/lib/utils' +import { ReactNode } from 'react' + +interface GridProps { + children: ReactNode + columns?: 1 | 2 | 3 | 4 | 5 | 6 + gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' + responsive?: boolean + className?: string +} + +const columnClasses = { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', +} + +const gapClasses = { + none: 'gap-0', + xs: 'gap-1', + sm: 'gap-2', + md: 'gap-4', + lg: 'gap-6', + xl: 'gap-8', +} + +const responsiveClasses = { + 2: 'grid-cols-1 sm:grid-cols-2', + 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', + 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4', + 5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5', + 6: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', +} + +export function ResponsiveGrid({ + children, + columns = 3, + gap = 'md', + responsive = true, + className, +}: GridProps) { + return ( +
1 ? responsiveClasses[columns] : columnClasses[columns], + gapClasses[gap], + className + )} + > + {children} +
+ ) +} diff --git a/src/components/atoms/ScrollArea.tsx b/src/components/atoms/ScrollArea.tsx new file mode 100644 index 0000000..01b7a2e --- /dev/null +++ b/src/components/atoms/ScrollArea.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' + +interface ScrollAreaProps { + children: ReactNode + className?: string + maxHeight?: string | number +} + +export function ScrollArea({ children, className, maxHeight }: ScrollAreaProps) { + return ( + + + {children} + + + + + + + + + + ) +} diff --git a/src/components/atoms/SearchInput.tsx b/src/components/atoms/SearchInput.tsx new file mode 100644 index 0000000..5258b32 --- /dev/null +++ b/src/components/atoms/SearchInput.tsx @@ -0,0 +1,46 @@ +import { MagnifyingGlass, X } from '@phosphor-icons/react' +import { Input } from './Input' + +interface BasicSearchInputProps { + value: string + onChange: (value: string) => void + placeholder?: string + onClear?: () => void + className?: string +} + +export function BasicSearchInput({ + value, + onChange, + placeholder = 'Search...', + onClear, + className, +}: BasicSearchInputProps) { + const handleClear = () => { + onChange('') + onClear?.() + } + + return ( + onChange(e.target.value)} + placeholder={placeholder} + className={className} + leftIcon={} + rightIcon={ + value && ( + + ) + } + /> + ) +} diff --git a/src/components/atoms/Section.tsx b/src/components/atoms/Section.tsx new file mode 100644 index 0000000..4fede6d --- /dev/null +++ b/src/components/atoms/Section.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface SectionProps { + children: ReactNode + spacing?: 'none' | 'sm' | 'md' | 'lg' | 'xl' + className?: string +} + +const spacingClasses = { + none: '', + sm: 'py-4', + md: 'py-8', + lg: 'py-12', + xl: 'py-16', +} + +export function Section({ children, spacing = 'md', className }: SectionProps) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/atoms/Select.tsx b/src/components/atoms/Select.tsx new file mode 100644 index 0000000..3d797db --- /dev/null +++ b/src/components/atoms/Select.tsx @@ -0,0 +1,69 @@ +import { cn } from '@/lib/utils' + +interface SelectOption { + value: string + label: string + disabled?: boolean +} + +interface SelectProps { + value: string + onChange: (value: string) => void + options: SelectOption[] + label?: string + placeholder?: string + error?: boolean + helperText?: string + disabled?: boolean + className?: string +} + +export function Select({ + value, + onChange, + options, + label, + placeholder = 'Select an option', + error, + helperText, + disabled, + className, +}: SelectProps) { + return ( +
+ {label && ( + + )} + + {helperText && ( +

+ {helperText} +

+ )} +
+ ) +} diff --git a/src/components/atoms/Skeleton.tsx b/src/components/atoms/Skeleton.tsx new file mode 100644 index 0000000..02ec9ec --- /dev/null +++ b/src/components/atoms/Skeleton.tsx @@ -0,0 +1,36 @@ +import { cn } from '@/lib/utils' + +interface SkeletonProps { + variant?: 'text' | 'rectangular' | 'circular' | 'rounded' + width?: string | number + height?: string | number + className?: string +} + +const variantClasses = { + text: 'rounded h-4', + rectangular: 'rounded-none', + circular: 'rounded-full', + rounded: 'rounded-lg', +} + +export function Skeleton({ + variant = 'rectangular', + width, + height, + className +}: SkeletonProps) { + return ( +
+ ) +} diff --git a/src/components/atoms/Slider.tsx b/src/components/atoms/Slider.tsx new file mode 100644 index 0000000..fae44b4 --- /dev/null +++ b/src/components/atoms/Slider.tsx @@ -0,0 +1,65 @@ +import { cn } from '@/lib/utils' + +interface SliderProps { + value: number + onChange: (value: number) => void + min?: number + max?: number + step?: number + label?: string + showValue?: boolean + disabled?: boolean + className?: string +} + +export function Slider({ + value, + onChange, + min = 0, + max = 100, + step = 1, + label, + showValue = false, + disabled = false, + className +}: SliderProps) { + const percentage = ((value - min) / (max - min)) * 100 + + return ( +
+ {(label || showValue) && ( +
+ {label && {label}} + {showValue && {value}} +
+ )} +
+ onChange(Number(e.target.value))} + disabled={disabled} + className={cn( + 'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer', + 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + disabled && 'opacity-50 cursor-not-allowed', + '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5', + '[&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full', + '[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-transform', + '[&::-webkit-slider-thumb]:hover:scale-110', + '[&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:bg-primary', + '[&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full', + '[&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:transition-transform', + '[&::-moz-range-thumb]:hover:scale-110' + )} + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)` + }} + /> +
+
+ ) +} diff --git a/src/components/atoms/Spacer.tsx b/src/components/atoms/Spacer.tsx new file mode 100644 index 0000000..6e96b5f --- /dev/null +++ b/src/components/atoms/Spacer.tsx @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +interface SpacerProps { + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' + axis?: 'horizontal' | 'vertical' | 'both' + className?: string +} + +const sizeClasses = { + xs: 1, + sm: 2, + md: 4, + lg: 8, + xl: 16, + '2xl': 24, +} + +export function Spacer({ size = 'md', axis = 'vertical', className }: SpacerProps) { + const spacing = sizeClasses[size] + + return ( +