Generated by Spark: Load feature component trees from json, make monolithic components atomic.

This commit is contained in:
2026-01-16 15:49:21 +00:00
committed by GitHub
parent 3818ee174c
commit 0f5ffb87cb
11 changed files with 439 additions and 172 deletions

View File

@@ -1,24 +1,10 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { ComponentTree, ComponentNode } from '@/types/project'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Plus, Tree, Trash, Pencil, Copy, FolderOpen } from '@phosphor-icons/react'
import { TreeFormDialog } from '@/components/molecules'
import { TreeListPanel } from '@/components/organisms'
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
import { TreeIcon } from '@/components/atoms'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface ComponentTreeManagerProps {
trees: ComponentTree[]
@@ -32,6 +18,7 @@ export function ComponentTreeManager({ trees, onTreesChange }: ComponentTreeMana
const [newTreeName, setNewTreeName] = useState('')
const [newTreeDescription, setNewTreeDescription] = useState('')
const [editingTree, setEditingTree] = useState<ComponentTree | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const selectedTree = trees.find(t => t.id === selectedTreeId)
@@ -123,92 +110,88 @@ export function ComponentTreeManager({ trees, onTreesChange }: ComponentTreeMana
setEditDialogOpen(true)
}
const handleExportJson = () => {
if (!selectedTree) {
toast.error('No tree selected to export')
return
}
try {
const json = JSON.stringify(selectedTree, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedTree.name.toLowerCase().replace(/\s+/g, '-')}-tree.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('Component tree exported as JSON')
} catch (error) {
console.error('Export failed:', error)
toast.error('Failed to export component tree')
}
}
const handleImportJson = () => {
fileInputRef.current?.click()
}
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const importedTree = JSON.parse(text) as ComponentTree
if (!importedTree.id || !importedTree.name || !Array.isArray(importedTree.rootNodes)) {
toast.error('Invalid component tree JSON format')
return
}
const newTree: ComponentTree = {
...importedTree,
id: `tree-${Date.now()}`,
createdAt: Date.now(),
updatedAt: Date.now(),
}
onTreesChange((current) => [...current, newTree])
setSelectedTreeId(newTree.id)
toast.success(`Component tree "${newTree.name}" imported successfully`)
} catch (error) {
console.error('Import failed:', error)
toast.error('Failed to import component tree. Please check the JSON format.')
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
return (
<div className="h-full flex">
<div className="w-80 border-r border-border bg-card p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Tree size={20} weight="duotone" />
Component Trees
</h2>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus size={16} />
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
className="hidden"
/>
<ScrollArea className="flex-1">
<div className="space-y-2">
{trees.map((tree) => (
<Card
key={tree.id}
className={`cursor-pointer transition-all ${
selectedTreeId === tree.id
? 'ring-2 ring-primary bg-accent'
: 'hover:bg-accent/50'
}`}
onClick={() => setSelectedTreeId(tree.id)}
>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-sm truncate">{tree.name}</CardTitle>
{tree.description && (
<CardDescription className="text-xs mt-1 line-clamp-2">
{tree.description}
</CardDescription>
)}
<div className="flex gap-2 mt-2">
<Badge variant="outline" className="text-xs">
{tree.rootNodes.length} components
</Badge>
</div>
</div>
</div>
<div className="flex gap-1 mt-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
onClick={() => openEditDialog(tree)}
title="Edit tree"
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDuplicateTree(tree)}
title="Duplicate tree"
>
<Copy size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteTree(tree.id)}
disabled={trees.length === 1}
title="Delete tree"
>
<Trash size={14} />
</Button>
</div>
</CardHeader>
</Card>
))}
</div>
</ScrollArea>
{trees.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<FolderOpen size={48} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">No component trees yet</p>
<Button size="sm" className="mt-2" onClick={() => setCreateDialogOpen(true)}>
Create First Tree
</Button>
</div>
</div>
)}
</div>
<TreeListPanel
trees={trees}
selectedTreeId={selectedTreeId}
onTreeSelect={setSelectedTreeId}
onTreeEdit={openEditDialog}
onTreeDuplicate={handleDuplicateTree}
onTreeDelete={handleDeleteTree}
onCreateNew={() => setCreateDialogOpen(true)}
onImportJson={handleImportJson}
onExportJson={handleExportJson}
/>
<div className="flex-1 overflow-hidden">
{selectedTree ? (
@@ -219,84 +202,38 @@ export function ComponentTreeManager({ trees, onTreesChange }: ComponentTreeMana
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Tree size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
<TreeIcon size={64} className="mx-auto mb-4 opacity-50" />
<p>Select a component tree to edit</p>
</div>
</div>
)}
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Component Tree</DialogTitle>
<DialogDescription>
Create a new component tree to organize your UI components
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="tree-name">Tree Name</Label>
<Input
id="tree-name"
value={newTreeName}
onChange={(e) => setNewTreeName(e.target.value)}
placeholder="e.g., Main App, Dashboard, Admin Panel"
/>
</div>
<div>
<Label htmlFor="tree-description">Description</Label>
<Textarea
id="tree-description"
value={newTreeDescription}
onChange={(e) => setNewTreeDescription(e.target.value)}
placeholder="Describe the purpose of this component tree"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateTree}>Create Tree</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TreeFormDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
title="Create Component Tree"
description="Create a new component tree to organize your UI components"
name={newTreeName}
treeDescription={newTreeDescription}
onNameChange={setNewTreeName}
onDescriptionChange={setNewTreeDescription}
onSubmit={handleCreateTree}
submitLabel="Create Tree"
/>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Component Tree</DialogTitle>
<DialogDescription>Update the component tree details</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-tree-name">Tree Name</Label>
<Input
id="edit-tree-name"
value={newTreeName}
onChange={(e) => setNewTreeName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="edit-tree-description">Description</Label>
<Textarea
id="edit-tree-description"
value={newTreeDescription}
onChange={(e) => setNewTreeDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleEditTree}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TreeFormDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
title="Edit Component Tree"
description="Update the component tree details"
name={newTreeName}
treeDescription={newTreeDescription}
onNameChange={setNewTreeName}
onDescriptionChange={setNewTreeDescription}
onSubmit={handleEditTree}
submitLabel="Save Changes"
/>
</div>
)
}

View File

@@ -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 <IconComponent size={size} weight={weight} className={className} />
}

View File

@@ -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 <IconComponent size={size} weight={weight} className={className} />
}

View File

@@ -0,0 +1,11 @@
import { Tree } from '@phosphor-icons/react'
interface TreeIconProps {
size?: number
weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
className?: string
}
export function TreeIcon({ size = 20, weight = 'duotone', className = '' }: TreeIconProps) {
return <Tree size={size} weight={weight} className={className} />
}

View File

@@ -5,3 +5,6 @@ export { ErrorBadge } from './ErrorBadge'
export { IconWrapper } from './IconWrapper'
export { LoadingSpinner } from './LoadingSpinner'
export { EmptyStateIcon } from './EmptyStateIcon'
export { TreeIcon } from './TreeIcon'
export { FileIcon } from './FileIcon'
export { ActionIcon } from './ActionIcon'

View File

@@ -0,0 +1,69 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ActionIcon } from '@/components/atoms'
import { ComponentTree } from '@/types/project'
interface TreeCardProps {
tree: ComponentTree
isSelected: boolean
onSelect: () => void
onEdit: () => void
onDuplicate: () => void
onDelete: () => void
disableDelete?: boolean
}
export function TreeCard({
tree,
isSelected,
onSelect,
onEdit,
onDuplicate,
onDelete,
disableDelete = false,
}: TreeCardProps) {
return (
<Card
className={`cursor-pointer transition-all ${
isSelected ? 'ring-2 ring-primary bg-accent' : 'hover:bg-accent/50'
}`}
onClick={onSelect}
>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-sm truncate">{tree.name}</CardTitle>
{tree.description && (
<CardDescription className="text-xs mt-1 line-clamp-2">
{tree.description}
</CardDescription>
)}
<div className="flex gap-2 mt-2">
<Badge variant="outline" className="text-xs">
{tree.rootNodes.length} components
</Badge>
</div>
</div>
</div>
<div className="flex gap-1 mt-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="ghost" onClick={onEdit} title="Edit tree">
<ActionIcon action="edit" size={14} />
</Button>
<Button size="sm" variant="ghost" onClick={onDuplicate} title="Duplicate tree">
<ActionIcon action="copy" size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onDelete}
disabled={disableDelete}
title="Delete tree"
>
<ActionIcon action="delete" size={14} />
</Button>
</div>
</CardHeader>
</Card>
)
}

View File

@@ -0,0 +1,76 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface TreeFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
name: string
treeDescription: string
onNameChange: (name: string) => void
onDescriptionChange: (description: string) => void
onSubmit: () => void
submitLabel?: string
}
export function TreeFormDialog({
open,
onOpenChange,
title,
description,
name,
treeDescription,
onNameChange,
onDescriptionChange,
onSubmit,
submitLabel = 'Save',
}: TreeFormDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="tree-name">Tree Name</Label>
<Input
id="tree-name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., Main App, Dashboard, Admin Panel"
/>
</div>
<div>
<Label htmlFor="tree-description">Description</Label>
<Textarea
id="tree-description"
value={treeDescription}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Describe the purpose of this component tree"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onSubmit}>{submitLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,52 @@
import { Button } from '@/components/ui/button'
import { TreeIcon, ActionIcon } from '@/components/atoms'
interface TreeListHeaderProps {
onCreateNew: () => void
onImportJson: () => void
onExportJson: () => void
hasSelectedTree?: boolean
}
export function TreeListHeader({
onCreateNew,
onImportJson,
onExportJson,
hasSelectedTree = false,
}: TreeListHeaderProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<TreeIcon size={20} />
Component Trees
</h2>
<Button size="sm" onClick={onCreateNew}>
<ActionIcon action="add" size={16} />
</Button>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={onImportJson}
className="flex-1 text-xs"
>
<ActionIcon action="upload" size={14} className="mr-1.5" />
Import JSON
</Button>
<Button
size="sm"
variant="outline"
onClick={onExportJson}
disabled={!hasSelectedTree}
className="flex-1 text-xs"
>
<ActionIcon action="download" size={14} className="mr-1.5" />
Export JSON
</Button>
</div>
</div>
)
}

View File

@@ -8,3 +8,6 @@ export { EmptyState } from './EmptyState'
export { LoadingState } from './LoadingState'
export { StatCard } from './StatCard'
export { LabelWithBadge } from './LabelWithBadge'
export { TreeCard } from './TreeCard'
export { TreeFormDialog } from './TreeFormDialog'
export { TreeListHeader } from './TreeListHeader'

View File

@@ -0,0 +1,74 @@
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { TreeCard, TreeListHeader } from '@/components/molecules'
import { EmptyState } from '@/components/molecules'
import { ComponentTree } from '@/types/project'
import { FolderOpen } from '@phosphor-icons/react'
interface TreeListPanelProps {
trees: ComponentTree[]
selectedTreeId: string | null
onTreeSelect: (treeId: string) => void
onTreeEdit: (tree: ComponentTree) => void
onTreeDuplicate: (tree: ComponentTree) => void
onTreeDelete: (treeId: string) => void
onCreateNew: () => void
onImportJson: () => void
onExportJson: () => void
}
export function TreeListPanel({
trees,
selectedTreeId,
onTreeSelect,
onTreeEdit,
onTreeDuplicate,
onTreeDelete,
onCreateNew,
onImportJson,
onExportJson,
}: TreeListPanelProps) {
return (
<div className="w-80 border-r border-border bg-card p-4 flex flex-col gap-4">
<TreeListHeader
onCreateNew={onCreateNew}
onImportJson={onImportJson}
onExportJson={onExportJson}
hasSelectedTree={!!selectedTreeId}
/>
{trees.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={<FolderOpen size={48} weight="duotone" />}
title="No component trees yet"
description="Create your first tree to get started"
action={
<Button size="sm" onClick={onCreateNew}>
Create First Tree
</Button>
}
/>
</div>
) : (
<ScrollArea className="flex-1">
<div className="space-y-2">
{trees.map((tree) => (
<TreeCard
key={tree.id}
tree={tree}
isSelected={selectedTreeId === tree.id}
onSelect={() => onTreeSelect(tree.id)}
onEdit={() => onTreeEdit(tree)}
onDuplicate={() => onTreeDuplicate(tree)}
onDelete={() => onTreeDelete(tree.id)}
disableDelete={trees.length === 1}
/>
))}
</div>
</ScrollArea>
)}
</div>
)
}

View File

@@ -2,3 +2,4 @@ export { NavigationMenu } from './NavigationMenu'
export { PageHeader } from './PageHeader'
export { ToolbarActions } from './ToolbarActions'
export { AppHeader } from './AppHeader'
export { TreeListPanel } from './TreeListPanel'