Merge branch 'main' into codex/ensure-codebase-is-mui-theme-driven

This commit is contained in:
2025-12-27 17:57:56 +00:00
committed by GitHub
14 changed files with 823 additions and 597 deletions

View File

@@ -1,333 +1,156 @@
import { useState, useEffect, useCallback, useId } from 'react'
import { useCallback, useId, useMemo, useState } from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Badge } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Separator } from '@/components/ui'
import {
Tree,
Plus,
Trash,
GearSix,
ArrowsOutCardinal,
CaretDown,
CaretRight,
Cursor
import {
ArrowsOutCardinal,
Cursor,
Plus,
Tree,
} from '@phosphor-icons/react'
import { Database, ComponentNode } from '@/lib/database'
import { Database, type ComponentNode } from '@/lib/database'
import { componentCatalog } from '@/lib/components/component-catalog'
import { toast } from 'sonner'
import type { PageConfig } from '@/lib/level-types'
import { ComponentConfigDialog } from './ComponentConfigDialog'
interface TreeNodeProps {
node: ComponentNode
hierarchy: Record<string, ComponentNode>
selectedNodeId: string | null
expandedNodes: Set<string>
onSelect: (nodeId: string) => void
onToggle: (nodeId: string) => void
onDelete: (nodeId: string) => void
onConfig: (nodeId: string) => void
onDragStart: (nodeId: string) => void
onDragOver: (e: React.DragEvent, nodeId: string) => void
onDrop: (e: React.DragEvent, targetNodeId: string) => void
draggingNodeId: string | null
}
function TreeNode({
node,
hierarchy,
selectedNodeId,
expandedNodes,
onSelect,
onToggle,
onDelete,
onConfig,
onDragStart,
onDragOver,
onDrop,
draggingNodeId,
}: TreeNodeProps) {
const hasChildren = node.childIds.length > 0
const isExpanded = expandedNodes.has(node.id)
const isSelected = selectedNodeId === node.id
const isDragging = draggingNodeId === node.id
const componentDef = componentCatalog.find(c => c.type === node.type)
return (
<div className="select-none">
<div
draggable
onDragStart={() => onDragStart(node.id)}
onDragOver={(e) => onDragOver(e, node.id)}
onDrop={(e) => onDrop(e, node.id)}
className={`
flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer
hover:bg-accent transition-colors group
${isSelected ? 'bg-accent' : ''}
${isDragging ? 'opacity-50' : ''}
`}
onClick={() => onSelect(node.id)}
>
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation()
onToggle(node.id)
}}
className="hover:bg-secondary rounded p-0.5"
>
{isExpanded ? <CaretDown size={14} /> : <CaretRight size={14} />}
</button>
) : (
<div className="w-[14px]" />
)}
<div className="text-muted-foreground">
<Tree size={16} />
</div>
<span className="flex-1 text-sm font-medium">{node.type}</span>
<Badge variant="outline" className="text-xs">
{node.order}
</Badge>
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation()
onConfig(node.id)
}}
>
<GearSix size={14} />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation()
onDelete(node.id)
}}
>
<Trash size={14} />
</Button>
</div>
</div>
{hasChildren && isExpanded && (
<div className="ml-4 border-l border-border pl-2">
{node.childIds
.sort((a, b) => hierarchy[a].order - hierarchy[b].order)
.map((childId) => (
<TreeNode
key={childId}
node={hierarchy[childId]}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
onSelect={onSelect}
onToggle={onToggle}
onDelete={onDelete}
onConfig={onConfig}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
draggingNodeId={draggingNodeId}
/>
))}
</div>
)}
</div>
)
}
import { TreeNode } from './modules/TreeNode'
import { useHierarchyData } from './modules/useHierarchyData'
import { useHierarchyDragDrop } from './modules/useHierarchyDragDrop'
export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: boolean }) {
const [pages, setPages] = useState<PageConfig[]>([])
const [selectedPageId, setSelectedPageId] = useState<string>('')
const [hierarchy, setHierarchy] = useState<Record<string, ComponentNode>>({})
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
const { pages, selectedPageId, setSelectedPageId, hierarchy, loadHierarchy } = useHierarchyData()
const {
selectedNodeId,
setSelectedNodeId,
expandedNodes,
draggingNodeId,
handleToggleNode,
handleExpandAll,
handleCollapseAll,
handleDragStart,
handleDragOver,
handleDrop,
expandNode,
} = useHierarchyDragDrop({ hierarchy, loadHierarchy })
const [configNodeId, setConfigNodeId] = useState<string | null>(null)
const componentIdPrefix = useId()
const loadPages = useCallback(async () => {
const loadedPages = await Database.getPages()
setPages(loadedPages)
if (loadedPages.length > 0 && !selectedPageId) {
setSelectedPageId(loadedPages[0].id)
}
}, [selectedPageId])
const rootNodes = useMemo(
() =>
Object.values(hierarchy)
.filter(node => node.pageId === selectedPageId && !node.parentId)
.sort((a, b) => a.order - b.order),
[hierarchy, selectedPageId]
)
const loadHierarchy = useCallback(async () => {
const allHierarchy = await Database.getComponentHierarchy()
setHierarchy(allHierarchy)
}, [])
useEffect(() => {
loadPages()
loadHierarchy()
}, [loadPages, loadHierarchy])
useEffect(() => {
if (selectedPageId) {
loadHierarchy()
}
}, [selectedPageId, loadHierarchy])
const getRootNodes = () => {
return Object.values(hierarchy)
.filter(node => node.pageId === selectedPageId && !node.parentId)
.sort((a, b) => a.order - b.order)
}
const handleAddComponent = async (componentType: string, parentId?: string) => {
if (!selectedPageId) {
toast.error('Please select a page first')
return
}
const componentDef = componentCatalog.find(c => c.type === componentType)
if (!componentDef) return
const newNode: ComponentNode = {
id: `node_${componentIdPrefix}_${Object.keys(hierarchy).length}`,
type: componentType,
parentId: parentId,
childIds: [],
order: parentId
? hierarchy[parentId]?.childIds.length || 0
: getRootNodes().length,
pageId: selectedPageId,
}
if (parentId && hierarchy[parentId]) {
await Database.updateComponentNode(parentId, {
childIds: [...hierarchy[parentId].childIds, newNode.id],
})
}
await Database.addComponentNode(newNode)
await loadHierarchy()
setExpandedNodes(prev => new Set([...prev, parentId || '']))
toast.success(`Added ${componentType}`)
}
const handleDeleteNode = async (nodeId: string) => {
if (!confirm('Delete this component and all its children?')) return
const node = hierarchy[nodeId]
if (!node) return
const deleteRecursive = async (id: string) => {
const n = hierarchy[id]
if (!n) return
for (const childId of n.childIds) {
await deleteRecursive(childId)
const handleAddComponent = useCallback(
async (componentType: string, parentId?: string) => {
if (!selectedPageId) {
toast.error('Please select a page first')
return
}
await Database.deleteComponentNode(id)
}
if (node.parentId && hierarchy[node.parentId]) {
const parent = hierarchy[node.parentId]
await Database.updateComponentNode(node.parentId, {
childIds: parent.childIds.filter(id => id !== nodeId),
})
}
const componentDef = componentCatalog.find(c => c.type === componentType)
if (!componentDef) return
await deleteRecursive(nodeId)
await loadHierarchy()
toast.success('Component deleted')
}
const handleToggleNode = (nodeId: string) => {
setExpandedNodes(prev => {
const next = new Set(prev)
if (next.has(nodeId)) {
next.delete(nodeId)
} else {
next.add(nodeId)
const newNode: ComponentNode = {
id: `node_${componentIdPrefix}_${Object.keys(hierarchy).length}`,
type: componentType,
parentId: parentId,
childIds: [],
order: parentId ? hierarchy[parentId]?.childIds.length || 0 : rootNodes.length,
pageId: selectedPageId,
}
return next
})
}
const handleDragStart = (nodeId: string) => {
setDraggingNodeId(nodeId)
}
if (parentId && hierarchy[parentId]) {
await Database.updateComponentNode(parentId, {
childIds: [...hierarchy[parentId].childIds, newNode.id],
})
}
const handleDragOver = (e: React.DragEvent, nodeId: string) => {
e.preventDefault()
e.stopPropagation()
}
await Database.addComponentNode(newNode)
await loadHierarchy()
expandNode(parentId)
toast.success(`Added ${componentType}`)
},
[componentIdPrefix, expandNode, hierarchy, loadHierarchy, rootNodes.length, selectedPageId]
)
const handleDrop = async (e: React.DragEvent, targetNodeId: string) => {
e.preventDefault()
e.stopPropagation()
const handleDeleteNode = useCallback(
async (nodeId: string) => {
if (!confirm('Delete this component and all its children?')) return
if (!draggingNodeId || draggingNodeId === targetNodeId) {
setDraggingNodeId(null)
return
}
const node = hierarchy[nodeId]
if (!node) return
const draggedNode = hierarchy[draggingNodeId]
const targetNode = hierarchy[targetNodeId]
const deleteRecursive = async (id: string) => {
const n = hierarchy[id]
if (!n) return
if (!draggedNode || !targetNode) {
setDraggingNodeId(null)
return
}
for (const childId of n.childIds) {
await deleteRecursive(childId)
}
await Database.deleteComponentNode(id)
}
if (targetNode.childIds.includes(draggingNodeId)) {
setDraggingNodeId(null)
return
}
if (node.parentId && hierarchy[node.parentId]) {
const parent = hierarchy[node.parentId]
await Database.updateComponentNode(node.parentId, {
childIds: parent.childIds.filter(id => id !== nodeId),
})
}
const componentDef = componentCatalog.find(c => c.type === targetNode.type)
if (!componentDef?.allowsChildren) {
toast.error(`${targetNode.type} cannot contain children`)
setDraggingNodeId(null)
return
}
await deleteRecursive(nodeId)
await loadHierarchy()
toast.success('Component deleted')
},
[hierarchy, loadHierarchy]
)
if (draggedNode.parentId) {
const oldParent = hierarchy[draggedNode.parentId]
await Database.updateComponentNode(draggedNode.parentId, {
childIds: oldParent.childIds.filter(id => id !== draggingNodeId),
})
}
await Database.updateComponentNode(targetNodeId, {
childIds: [...targetNode.childIds, draggingNodeId],
})
await Database.updateComponentNode(draggingNodeId, {
parentId: targetNodeId,
order: targetNode.childIds.length,
})
setDraggingNodeId(null)
setExpandedNodes(prev => new Set([...prev, targetNodeId]))
await loadHierarchy()
toast.success('Component moved')
}
const handleExpandAll = () => {
setExpandedNodes(new Set(Object.keys(hierarchy)))
}
const handleCollapseAll = () => {
setExpandedNodes(new Set())
}
const renderTree = useMemo(
() =>
rootNodes.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Cursor size={48} className="mb-4" />
<p>No components yet. Add one from the catalog!</p>
</div>
) : (
<div className="space-y-1">
{rootNodes.map((node) => (
<TreeNode
key={node.id}
node={node}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
onSelect={setSelectedNodeId}
onToggle={handleToggleNode}
onDelete={handleDeleteNode}
onConfig={setConfigNodeId}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
draggingNodeId={draggingNodeId}
/>
))}
</div>
),
[
expandedNodes,
handleDeleteNode,
handleDragOver,
handleDragStart,
handleDrop,
handleToggleNode,
hierarchy,
rootNodes,
selectedNodeId,
draggingNodeId,
setConfigNodeId,
setSelectedNodeId,
]
)
return (
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-12rem)]">
@@ -368,32 +191,7 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
<CardContent className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
{selectedPageId ? (
getRootNodes().length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Cursor size={48} className="mb-4" />
<p>No components yet. Add one from the catalog!</p>
</div>
) : (
<div className="space-y-1">
{getRootNodes().map((node) => (
<TreeNode
key={node.id}
node={node}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
onSelect={setSelectedNodeId}
onToggle={handleToggleNode}
onDelete={handleDeleteNode}
onConfig={setConfigNodeId}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
draggingNodeId={draggingNodeId}
/>
))}
</div>
)
renderTree
) : (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<p>Select a page to edit its component hierarchy</p>

View File

@@ -0,0 +1,139 @@
import type React from 'react'
import { CaretDown, CaretRight, GearSix, Trash, Tree } from '@phosphor-icons/react'
import { Badge, Button } from '@/components/ui'
import { componentCatalog } from '@/lib/components/component-catalog'
import type { ComponentNode } from '@/lib/database'
export interface TreeNodeProps {
node: ComponentNode
hierarchy: Record<string, ComponentNode>
selectedNodeId: string | null
expandedNodes: Set<string>
onSelect: (nodeId: string) => void
onToggle: (nodeId: string) => void
onDelete: (nodeId: string) => void
onConfig: (nodeId: string) => void
onDragStart: (nodeId: string) => void
onDragOver: (e: React.DragEvent, nodeId: string) => void
onDrop: (e: React.DragEvent, targetNodeId: string) => void
draggingNodeId: string | null
}
export function TreeNode({
node,
hierarchy,
selectedNodeId,
expandedNodes,
onSelect,
onToggle,
onDelete,
onConfig,
onDragStart,
onDragOver,
onDrop,
draggingNodeId,
}: TreeNodeProps) {
const hasChildren = node.childIds.length > 0
const isExpanded = expandedNodes.has(node.id)
const isSelected = selectedNodeId === node.id
const isDragging = draggingNodeId === node.id
const componentDef = componentCatalog.find(c => c.type === node.type)
return (
<div className="select-none">
<div
draggable
onDragStart={() => onDragStart(node.id)}
onDragOver={(e) => onDragOver(e, node.id)}
onDrop={(e) => onDrop(e, node.id)}
className={`
flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer
hover:bg-accent transition-colors group
${isSelected ? 'bg-accent' : ''}
${isDragging ? 'opacity-50' : ''}
`}
onClick={() => onSelect(node.id)}
>
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation()
onToggle(node.id)
}}
className="hover:bg-secondary rounded p-0.5"
>
{isExpanded ? <CaretDown size={14} /> : <CaretRight size={14} />}
</button>
) : (
<div className="w-[14px]" />
)}
<div className="text-muted-foreground">
<Tree size={16} />
</div>
<span className="flex-1 text-sm font-medium">{node.type}</span>
<Badge variant="outline" className="text-xs">
{node.order}
</Badge>
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation()
onConfig(node.id)
}}
>
<GearSix size={14} />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation()
onDelete(node.id)
}}
>
<Trash size={14} />
</Button>
</div>
{componentDef?.allowsChildren && (
<div className="text-xs text-muted-foreground">can nest</div>
)}
</div>
{hasChildren && isExpanded && (
<div className="pl-6 space-y-1">
{node.childIds
.map((childId) => hierarchy[childId])
.filter(Boolean)
.sort((a, b) => a.order - b.order)
.map((child) => (
<TreeNode
key={child.id}
node={child}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
onSelect={onSelect}
onToggle={onToggle}
onDelete={onDelete}
onConfig={onConfig}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
draggingNodeId={draggingNodeId}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState } from 'react'
import { Database, type ComponentNode } from '@/lib/database'
import type { PageConfig } from '@/lib/level-types'
interface UseHierarchyDataResult {
pages: PageConfig[]
selectedPageId: string
setSelectedPageId: (pageId: string) => void
hierarchy: Record<string, ComponentNode>
loadHierarchy: () => Promise<void>
}
export function useHierarchyData(): UseHierarchyDataResult {
const [pages, setPages] = useState<PageConfig[]>([])
const [selectedPageId, setSelectedPageId] = useState<string>('')
const [hierarchy, setHierarchy] = useState<Record<string, ComponentNode>>({})
const loadPages = useCallback(async () => {
const loadedPages = await Database.getPages()
setPages(loadedPages)
if (loadedPages.length > 0 && !selectedPageId) {
setSelectedPageId(loadedPages[0].id)
}
}, [selectedPageId])
const loadHierarchy = useCallback(async () => {
const allHierarchy = await Database.getComponentHierarchy()
setHierarchy(allHierarchy)
}, [])
useEffect(() => {
loadPages()
loadHierarchy()
}, [loadPages, loadHierarchy])
useEffect(() => {
if (selectedPageId) {
loadHierarchy()
}
}, [selectedPageId, loadHierarchy])
return {
pages,
selectedPageId,
setSelectedPageId,
hierarchy,
loadHierarchy,
}
}

View File

@@ -0,0 +1,119 @@
import type React from 'react'
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { componentCatalog } from '@/lib/components/component-catalog'
import { Database, type ComponentNode } from '@/lib/database'
interface UseHierarchyDragDropProps {
hierarchy: Record<string, ComponentNode>
loadHierarchy: () => Promise<void>
}
export function useHierarchyDragDrop({ hierarchy, loadHierarchy }: UseHierarchyDragDropProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
const handleToggleNode = useCallback((nodeId: string) => {
setExpandedNodes(prev => {
const next = new Set(prev)
if (next.has(nodeId)) {
next.delete(nodeId)
} else {
next.add(nodeId)
}
return next
})
}, [])
const handleExpandAll = useCallback(() => {
setExpandedNodes(new Set(Object.keys(hierarchy)))
}, [hierarchy])
const handleCollapseAll = useCallback(() => {
setExpandedNodes(new Set())
}, [])
const handleDragStart = useCallback((nodeId: string) => {
setDraggingNodeId(nodeId)
}, [])
const handleDragOver = useCallback((e: React.DragEvent, nodeId: string) => {
e.preventDefault()
e.stopPropagation()
setExpandedNodes(prev => new Set([...prev, nodeId]))
}, [])
const handleDrop = useCallback(
async (e: React.DragEvent, targetNodeId: string) => {
e.preventDefault()
e.stopPropagation()
if (!draggingNodeId || draggingNodeId === targetNodeId) {
setDraggingNodeId(null)
return
}
const draggedNode = hierarchy[draggingNodeId]
const targetNode = hierarchy[targetNodeId]
if (!draggedNode || !targetNode) {
setDraggingNodeId(null)
return
}
if (targetNode.childIds.includes(draggingNodeId)) {
setDraggingNodeId(null)
return
}
const componentDef = componentCatalog.find(c => c.type === targetNode.type)
if (!componentDef?.allowsChildren) {
toast.error(`${targetNode.type} cannot contain children`)
setDraggingNodeId(null)
return
}
if (draggedNode.parentId) {
const oldParent = hierarchy[draggedNode.parentId]
await Database.updateComponentNode(draggedNode.parentId, {
childIds: oldParent.childIds.filter(id => id !== draggingNodeId),
})
}
await Database.updateComponentNode(targetNodeId, {
childIds: [...targetNode.childIds, draggingNodeId],
})
await Database.updateComponentNode(draggingNodeId, {
parentId: targetNodeId,
order: targetNode.childIds.length,
})
setDraggingNodeId(null)
setExpandedNodes(prev => new Set([...prev, targetNodeId]))
await loadHierarchy()
toast.success('Component moved')
},
[draggingNodeId, hierarchy, loadHierarchy]
)
const expandNode = useCallback((nodeId?: string | null) => {
if (!nodeId) return
setExpandedNodes(prev => new Set([...prev, nodeId]))
}, [])
return {
selectedNodeId,
setSelectedNodeId,
expandedNodes,
draggingNodeId,
handleToggleNode,
handleExpandAll,
handleCollapseAll,
handleDragStart,
handleDragOver,
handleDrop,
expandNode,
}
}

View File

@@ -1,60 +1,44 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Badge } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Separator } from '@/components/ui'
import { useState } from 'react'
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator } from '@/components/ui'
import { toast } from 'sonner'
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/packages/core/package-catalog'
import type { PackageManifest, InstalledPackage } from '@/lib/package-types'
import { installPackage, listInstalledPackages, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
import { Package, Download, Trash, Power, MagnifyingGlass, Star, Tag, User, TrendUp, Funnel, Export, ArrowSquareIn } from '@phosphor-icons/react'
import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
import { ArrowSquareIn, Download, Export, Package, Star, Tag, Trash, User } from '@phosphor-icons/react'
import { PackageImportExport } from './PackageImportExport'
import { PackageFilters } from './package-manager/PackageFilters'
import { PackageTabs } from './package-manager/PackageTabs'
import { usePackages } from './package-manager/usePackages'
interface PackageManagerProps {
onClose?: () => void
}
export function PackageManager({ onClose }: PackageManagerProps) {
const [packages, setPackages] = useState<PackageManifest[]>([])
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
const {
filteredPackages,
installedList,
availableList,
installedPackages,
categories,
searchQuery,
categoryFilter,
sortBy,
setSearchQuery,
setCategoryFilter,
setSortBy,
loadPackages,
getCatalogEntry,
} = usePackages()
const [selectedPackage, setSelectedPackage] = useState<PackageCatalogData | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<'name' | 'downloads' | 'rating'>('downloads')
const [showDetails, setShowDetails] = useState(false)
const [installing, setInstalling] = useState(false)
const [showImportExport, setShowImportExport] = useState(false)
const [importExportMode, setImportExportMode] = useState<'import' | 'export'>('export')
useEffect(() => {
loadPackages()
}, [])
const loadPackages = async () => {
const installed = await listInstalledPackages()
setInstalledPackages(installed)
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => {
const packageData = pkg()
return {
...packageData.manifest,
installed: installed.some(ip => ip.packageId === packageData.manifest.id),
}
})
setPackages(allPackages)
}
const handleInstallPackage = async (packageId: string) => {
setInstalling(true)
try {
const packageEntry = PACKAGE_CATALOG[packageId]?.()
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return
@@ -75,7 +59,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
const handleUninstallPackage = async (packageId: string) => {
try {
const packageEntry = PACKAGE_CATALOG[packageId]?.()
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return
@@ -103,28 +87,18 @@ export function PackageManager({ onClose }: PackageManagerProps) {
}
}
const filteredPackages = packages
.filter(pkg => {
const matchesSearch =
pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
pkg.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
pkg.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
const openPackageDetails = (packageId: string) => {
const catalogEntry = getCatalogEntry(packageId)
if (!catalogEntry) return
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
const installedPackage = installedPackages.find(pkg => pkg.packageId === packageId)
return matchesSearch && matchesCategory
setSelectedPackage({
...catalogEntry,
manifest: { ...catalogEntry.manifest, installed: Boolean(installedPackage) },
})
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name)
if (sortBy === 'downloads') return b.downloadCount - a.downloadCount
if (sortBy === 'rating') return b.rating - a.rating
return 0
})
const categories = ['all', ...Array.from(new Set(packages.map(p => p.category)))]
const installedList = packages.filter(p => p.installed)
const availableList = packages.filter(p => !p.installed)
setShowDetails(true)
}
return (
<div className="flex flex-col h-full">
@@ -139,8 +113,8 @@ export function PackageManager({ onClose }: PackageManagerProps) {
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
onClick={() => {
setImportExportMode('import')
setShowImportExport(true)
@@ -149,8 +123,8 @@ export function PackageManager({ onClose }: PackageManagerProps) {
<ArrowSquareIn size={16} className="mr-2" />
Import
</Button>
<Button
variant="outline"
<Button
variant="outline"
onClick={() => {
setImportExportMode('export')
setShowImportExport(true)
@@ -167,127 +141,25 @@ export function PackageManager({ onClose }: PackageManagerProps) {
</div>
</div>
<PackageFilters
searchQuery={searchQuery}
categoryFilter={categoryFilter}
sortBy={sortBy}
categories={categories}
onSearchChange={setSearchQuery}
onCategoryChange={setCategoryFilter}
onSortChange={setSortBy}
/>
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="all" className="h-full flex flex-col">
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all">All Packages</TabsTrigger>
<TabsTrigger value="installed">
Installed ({installedList.length})
</TabsTrigger>
<TabsTrigger value="available">
Available ({availableList.length})
</TabsTrigger>
</TabsList>
</div>
<div className="px-6 py-4 space-y-3 border-b">
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={20} />
<Input
placeholder="Search packages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-3">
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[180px]">
<Funnel size={16} className="mr-2" />
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>
{cat === 'all' ? 'All Categories' : cat.charAt(0).toUpperCase() + cat.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}>
<SelectTrigger className="w-[180px]">
<TrendUp size={16} className="mr-2" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="downloads">Most Downloaded</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="name">Name</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<TabsContent value="all" className="flex-1 m-0">
<ScrollArea className="h-full">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredPackages.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled={pkg.installed}
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}
/>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="installed" className="flex-1 m-0">
<ScrollArea className="h-full">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{installedList.length === 0 ? (
<div className="col-span-full text-center py-12">
<Package size={48} className="mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">No packages installed yet</p>
</div>
) : (
installedList.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled={true}
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}
/>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="available" className="flex-1 m-0">
<ScrollArea className="h-full">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availableList.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled={false}
installedPackage={undefined}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}
/>
))}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<PackageTabs
filteredPackages={filteredPackages}
installedList={installedList}
availableList={availableList}
installedPackages={installedPackages}
onSelectPackage={openPackageDetails}
onTogglePackage={handleTogglePackage}
/>
</div>
<Dialog open={showDetails} onOpenChange={setShowDetails}>
@@ -416,7 +288,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
</DialogContent>
</Dialog>
<PackageImportExport
<PackageImportExport
open={showImportExport}
onOpenChange={(open) => {
setShowImportExport(open)
@@ -429,67 +301,3 @@ export function PackageManager({ onClose }: PackageManagerProps) {
</div>
)
}
interface PackageCardProps {
package: PackageManifest
isInstalled: boolean
installedPackage?: InstalledPackage
onViewDetails: () => void
onToggle: (packageId: string, enabled: boolean) => void
}
function PackageCard({ package: pkg, isInstalled, installedPackage, onViewDetails, onToggle }: PackageCardProps) {
return (
<Card className="flex flex-col hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-2xl flex-shrink-0">
{pkg.icon}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{pkg.name}</CardTitle>
<CardDescription className="line-clamp-2 mt-1">{pkg.description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center gap-2 mb-3">
<Badge variant="secondary">{pkg.category}</Badge>
{isInstalled && (
<Badge variant={installedPackage?.enabled ? 'default' : 'outline'}>
{installedPackage?.enabled ? 'Active' : 'Disabled'}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Download size={14} />
<span>{pkg.downloadCount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1">
<Star size={14} weight="fill" className="text-yellow-500" />
<span>{pkg.rating}</span>
</div>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={onViewDetails} className="flex-1">
View Details
</Button>
{isInstalled && installedPackage && (
<Button
variant="ghost"
size="icon"
onClick={() => onToggle(pkg.id, !installedPackage.enabled)}
title={installedPackage.enabled ? 'Disable' : 'Enable'}
>
<Power size={18} weight={installedPackage.enabled ? 'fill' : 'regular'} />
</Button>
)}
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,67 @@
import { Badge, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui'
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
import { Download, Power, Star } from '@phosphor-icons/react'
interface PackageCardProps {
package: PackageManifest
isInstalled: boolean
installedPackage?: InstalledPackage
onViewDetails: () => void
onToggle: (packageId: string, enabled: boolean) => void
}
export function PackageCard({ package: pkg, isInstalled, installedPackage, onViewDetails, onToggle }: PackageCardProps) {
return (
<Card className="flex flex-col hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-2xl flex-shrink-0">
{pkg.icon}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{pkg.name}</CardTitle>
<CardDescription className="line-clamp-2 mt-1">{pkg.description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center gap-2 mb-3">
<Badge variant="secondary">{pkg.category}</Badge>
{isInstalled && (
<Badge variant={installedPackage?.enabled ? 'default' : 'outline'}>
{installedPackage?.enabled ? 'Active' : 'Disabled'}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Download size={14} />
<span>{pkg.downloadCount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1">
<Star size={14} weight="fill" className="text-yellow-500" />
<span>{pkg.rating}</span>
</div>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={onViewDetails} className="flex-1">
View Details
</Button>
{isInstalled && installedPackage && (
<Button
variant="ghost"
size="icon"
onClick={() => onToggle(pkg.id, !installedPackage.enabled)}
title={installedPackage.enabled ? 'Disable' : 'Enable'}
>
<Power size={18} weight={installedPackage.enabled ? 'fill' : 'regular'} />
</Button>
)}
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,65 @@
import { Funnel, MagnifyingGlass, TrendUp } from '@phosphor-icons/react'
import { Input } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
interface PackageFiltersProps {
searchQuery: string
categoryFilter: string
sortBy: 'name' | 'downloads' | 'rating'
categories: string[]
onSearchChange: (value: string) => void
onCategoryChange: (value: string) => void
onSortChange: (value: 'name' | 'downloads' | 'rating') => void
}
export function PackageFilters({
searchQuery,
categoryFilter,
sortBy,
categories,
onSearchChange,
onCategoryChange,
onSortChange,
}: PackageFiltersProps) {
return (
<div className="px-6 py-4 space-y-3 border-b">
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={20} />
<Input
placeholder="Search packages..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-3">
<Select value={categoryFilter} onValueChange={onCategoryChange}>
<SelectTrigger className="w-[180px]">
<Funnel size={16} className="mr-2" />
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>
{cat === 'all' ? 'All Categories' : cat.charAt(0).toUpperCase() + cat.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value) => onSortChange(value as any)}>
<SelectTrigger className="w-[180px]">
<TrendUp size={16} className="mr-2" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="downloads">Most Downloaded</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="name">Name</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
import { Package } from '@phosphor-icons/react'
import { PackageCard } from './PackageCard'
interface PackageTabsProps {
filteredPackages: PackageManifest[]
installedList: PackageManifest[]
availableList: PackageManifest[]
installedPackages: InstalledPackage[]
onSelectPackage: (packageId: string) => void
onTogglePackage: (packageId: string, enabled: boolean) => Promise<void>
}
export function PackageTabs({
filteredPackages,
installedList,
availableList,
installedPackages,
onSelectPackage,
onTogglePackage,
}: PackageTabsProps) {
const renderPackageCards = (packages: PackageManifest[], isInstalled: (pkg: PackageManifest) => boolean) => (
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{packages.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled={isInstalled(pkg)}
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => onSelectPackage(pkg.id)}
onToggle={onTogglePackage}
/>
))}
</div>
)
return (
<Tabs defaultValue="all" className="h-full flex flex-col">
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all">All Packages</TabsTrigger>
<TabsTrigger value="installed">Installed ({installedList.length})</TabsTrigger>
<TabsTrigger value="available">Available ({availableList.length})</TabsTrigger>
</TabsList>
</div>
<TabsContent value="all" className="flex-1 m-0">
<ScrollArea className="h-full">
{renderPackageCards(filteredPackages, (pkg) => pkg.installed)}
</ScrollArea>
</TabsContent>
<TabsContent value="installed" className="flex-1 m-0">
<ScrollArea className="h-full">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{installedList.length === 0 ? (
<div className="col-span-full text-center py-12">
<Package size={48} className="mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">No packages installed yet</p>
</div>
) : (
installedList.map(pkg => (
<PackageCard
key={pkg.id}
package={pkg}
isInstalled
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => onSelectPackage(pkg.id)}
onToggle={onTogglePackage}
/>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="available" className="flex-1 m-0">
<ScrollArea className="h-full">
{renderPackageCards(availableList, () => false)}
</ScrollArea>
</TabsContent>
</Tabs>
)
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useMemo, useState } from 'react'
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/packages/core/package-catalog'
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
import { listInstalledPackages } from '@/lib/api/packages'
export interface UsePackagesResult {
packages: PackageManifest[]
installedPackages: InstalledPackage[]
filteredPackages: PackageManifest[]
installedList: PackageManifest[]
availableList: PackageManifest[]
categories: string[]
searchQuery: string
categoryFilter: string
sortBy: 'name' | 'downloads' | 'rating'
setSearchQuery: (query: string) => void
setCategoryFilter: (category: string) => void
setSortBy: (sort: 'name' | 'downloads' | 'rating') => void
loadPackages: () => Promise<void>
getCatalogEntry: (packageId: string) => PackageCatalogData | null
}
export function usePackages(): UsePackagesResult {
const [packages, setPackages] = useState<PackageManifest[]>([])
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<'name' | 'downloads' | 'rating'>('downloads')
useEffect(() => {
loadPackages()
}, [])
const loadPackages = async () => {
const installed = await listInstalledPackages()
setInstalledPackages(installed)
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => {
const packageData = pkg()
return {
...packageData.manifest,
installed: installed.some(ip => ip.packageId === packageData.manifest.id),
}
})
setPackages(allPackages)
}
const filteredPackages = useMemo(() => {
const matchesSearch = (pkg: PackageManifest) =>
pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
pkg.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
pkg.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
const matchesCategory = (pkg: PackageManifest) => categoryFilter === 'all' || pkg.category === categoryFilter
return packages
.filter(pkg => matchesSearch(pkg) && matchesCategory(pkg))
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name)
if (sortBy === 'downloads') return b.downloadCount - a.downloadCount
if (sortBy === 'rating') return b.rating - a.rating
return 0
})
}, [packages, searchQuery, categoryFilter, sortBy])
const categories = useMemo(
() => ['all', ...Array.from(new Set(packages.map(p => p.category)))],
[packages],
)
const installedList = useMemo(() => packages.filter(p => p.installed), [packages])
const availableList = useMemo(() => packages.filter(p => !p.installed), [packages])
const getCatalogEntry = (packageId: string) => PACKAGE_CATALOG[packageId]?.() ?? null
return {
packages,
installedPackages,
filteredPackages,
installedList,
availableList,
categories,
searchQuery,
categoryFilter,
sortBy,
setSearchQuery,
setCategoryFilter,
setSortBy,
loadPackages,
getCatalogEntry,
}
}

View File

@@ -1,12 +1,12 @@
import type { DatabaseSchema } from '../types'
import { getUsers } from '../users'
import { getWorkflows } from '../workflows'
import { getLuaScripts } from '../lua-scripts'
import { getPages } from '../pages'
import { getSchemas } from '../schemas'
import { getAppConfig } from '../app-config'
import { getComments } from '../comments'
import { getComponentHierarchy, getComponentConfigs } from '../components'
import type { DatabaseSchema } from '../../types'
import { getUsers } from '../../users'
import { getWorkflows } from '../../workflows'
import { getLuaScripts } from '../../lua-scripts'
import { getPages } from '../../pages'
import { getSchemas } from '../../schemas'
import { getAppConfig } from '../../app-config'
import { getComments } from '../../comments'
import { getComponentConfigs, getComponentHierarchy } from '../../components'
/**
* Export database contents as JSON string

View File

@@ -0,0 +1 @@
export { exportDatabase } from './export-database'

View File

@@ -1,12 +1,12 @@
import type { DatabaseSchema } from '../types'
import { setUsers } from '../users'
import { setWorkflows } from '../workflows'
import { setLuaScripts } from '../lua-scripts'
import { setPages } from '../pages'
import { setSchemas } from '../schemas'
import { setAppConfig } from '../app-config'
import { setComments } from '../comments'
import { setComponentHierarchy, setComponentConfigs } from '../components'
import type { DatabaseSchema } from '../../types'
import { setUsers } from '../../users'
import { setWorkflows } from '../../workflows'
import { setLuaScripts } from '../../lua-scripts'
import { setPages } from '../../pages'
import { setSchemas } from '../../schemas'
import { setAppConfig } from '../../app-config'
import { setComments } from '../../comments'
import { setComponentConfigs, setComponentHierarchy } from '../../components'
/**
* Import database contents from JSON string

View File

@@ -0,0 +1 @@
export { importDatabase } from './import-database'

View File

@@ -1,4 +1,4 @@
export { clearDatabase } from './clear-database'
export { exportDatabase } from './export-database'
export { importDatabase } from './import-database'
export { exportDatabase } from './export'
export { importDatabase } from './import'
export { seedDefaultData } from './seed-default-data'