From df9193ffe6cc28d40aca616c4b623dc295df3aeb Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 17:50:50 +0000 Subject: [PATCH 1/3] refactor: split package manager components --- .../managers/package/PackageManager.tsx | 306 ++++-------------- .../package/package-manager/PackageCard.tsx | 67 ++++ .../package-manager/PackageFilters.tsx | 65 ++++ .../package/package-manager/PackageTabs.tsx | 85 +++++ .../package/package-manager/usePackages.ts | 94 ++++++ 5 files changed, 368 insertions(+), 249 deletions(-) create mode 100644 frontends/nextjs/src/components/managers/package/package-manager/PackageCard.tsx create mode 100644 frontends/nextjs/src/components/managers/package/package-manager/PackageFilters.tsx create mode 100644 frontends/nextjs/src/components/managers/package/package-manager/PackageTabs.tsx create mode 100644 frontends/nextjs/src/components/managers/package/package-manager/usePackages.ts diff --git a/frontends/nextjs/src/components/managers/package/PackageManager.tsx b/frontends/nextjs/src/components/managers/package/PackageManager.tsx index 3d1aeac7b..5bdeda96e 100644 --- a/frontends/nextjs/src/components/managers/package/PackageManager.tsx +++ b/frontends/nextjs/src/components/managers/package/PackageManager.tsx @@ -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([]) - const [installedPackages, setInstalledPackages] = useState([]) + const { + filteredPackages, + installedList, + availableList, + installedPackages, + categories, + searchQuery, + categoryFilter, + sortBy, + setSearchQuery, + setCategoryFilter, + setSortBy, + loadPackages, + getCatalogEntry, + } = usePackages() const [selectedPackage, setSelectedPackage] = useState(null) - const [searchQuery, setSearchQuery] = useState('') - const [categoryFilter, setCategoryFilter] = useState('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 (
@@ -139,8 +113,8 @@ export function PackageManager({ onClose }: PackageManagerProps) {
- -
+ +
- -
- - All Packages - - Installed ({installedList.length}) - - - Available ({availableList.length}) - - -
- -
-
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
- -
- - - -
-
- - - -
- {filteredPackages.map(pkg => ( - ip.packageId === pkg.id)} - onViewDetails={() => { - setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null) - setShowDetails(true) - }} - onToggle={handleTogglePackage} - /> - ))} -
-
-
- - - -
- {installedList.length === 0 ? ( -
- -

No packages installed yet

-
- ) : ( - installedList.map(pkg => ( - ip.packageId === pkg.id)} - onViewDetails={() => { - setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null) - setShowDetails(true) - }} - onToggle={handleTogglePackage} - /> - )) - )} -
-
-
- - - -
- {availableList.map(pkg => ( - { - setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null) - setShowDetails(true) - }} - onToggle={handleTogglePackage} - /> - ))} -
-
-
-
+
@@ -416,7 +288,7 @@ export function PackageManager({ onClose }: PackageManagerProps) { - { setShowImportExport(open) @@ -429,67 +301,3 @@ export function PackageManager({ onClose }: PackageManagerProps) { ) } - -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 ( - - -
-
- {pkg.icon} -
-
- {pkg.name} - {pkg.description} -
-
-
- - -
- {pkg.category} - {isInstalled && ( - - {installedPackage?.enabled ? 'Active' : 'Disabled'} - - )} -
- -
-
- - {pkg.downloadCount.toLocaleString()} -
-
- - {pkg.rating} -
-
-
- - - - {isInstalled && installedPackage && ( - - )} - -
- ) -} diff --git a/frontends/nextjs/src/components/managers/package/package-manager/PackageCard.tsx b/frontends/nextjs/src/components/managers/package/package-manager/PackageCard.tsx new file mode 100644 index 000000000..3bb4b0167 --- /dev/null +++ b/frontends/nextjs/src/components/managers/package/package-manager/PackageCard.tsx @@ -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 ( + + +
+
+ {pkg.icon} +
+
+ {pkg.name} + {pkg.description} +
+
+
+ + +
+ {pkg.category} + {isInstalled && ( + + {installedPackage?.enabled ? 'Active' : 'Disabled'} + + )} +
+ +
+
+ + {pkg.downloadCount.toLocaleString()} +
+
+ + {pkg.rating} +
+
+
+ + + + {isInstalled && installedPackage && ( + + )} + +
+ ) +} diff --git a/frontends/nextjs/src/components/managers/package/package-manager/PackageFilters.tsx b/frontends/nextjs/src/components/managers/package/package-manager/PackageFilters.tsx new file mode 100644 index 000000000..32632686c --- /dev/null +++ b/frontends/nextjs/src/components/managers/package/package-manager/PackageFilters.tsx @@ -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 ( +
+
+ + onSearchChange(e.target.value)} + className="pl-10" + /> +
+ +
+ + + +
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/package/package-manager/PackageTabs.tsx b/frontends/nextjs/src/components/managers/package/package-manager/PackageTabs.tsx new file mode 100644 index 000000000..b14140488 --- /dev/null +++ b/frontends/nextjs/src/components/managers/package/package-manager/PackageTabs.tsx @@ -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 +} + +export function PackageTabs({ + filteredPackages, + installedList, + availableList, + installedPackages, + onSelectPackage, + onTogglePackage, +}: PackageTabsProps) { + const renderPackageCards = (packages: PackageManifest[], isInstalled: (pkg: PackageManifest) => boolean) => ( +
+ {packages.map(pkg => ( + ip.packageId === pkg.id)} + onViewDetails={() => onSelectPackage(pkg.id)} + onToggle={onTogglePackage} + /> + ))} +
+ ) + + return ( + +
+ + All Packages + Installed ({installedList.length}) + Available ({availableList.length}) + +
+ + + + {renderPackageCards(filteredPackages, (pkg) => pkg.installed)} + + + + + +
+ {installedList.length === 0 ? ( +
+ +

No packages installed yet

+
+ ) : ( + installedList.map(pkg => ( + ip.packageId === pkg.id)} + onViewDetails={() => onSelectPackage(pkg.id)} + onToggle={onTogglePackage} + /> + )) + )} +
+
+
+ + + + {renderPackageCards(availableList, () => false)} + + +
+ ) +} diff --git a/frontends/nextjs/src/components/managers/package/package-manager/usePackages.ts b/frontends/nextjs/src/components/managers/package/package-manager/usePackages.ts new file mode 100644 index 000000000..b8d8d90d5 --- /dev/null +++ b/frontends/nextjs/src/components/managers/package/package-manager/usePackages.ts @@ -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 + getCatalogEntry: (packageId: string) => PackageCatalogData | null +} + +export function usePackages(): UsePackagesResult { + const [packages, setPackages] = useState([]) + const [installedPackages, setInstalledPackages] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [categoryFilter, setCategoryFilter] = useState('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, + } +} From 93092c3a21002a0ee715aa9a5d1b7976f94ccb27 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 17:51:19 +0000 Subject: [PATCH 2/3] refactor: organize database admin import/export helpers --- .../{ => export}/export-database.ts | 18 +++++++++--------- .../src/lib/db/database-admin/export/index.ts | 1 + .../{ => import}/import-database.ts | 18 +++++++++--------- .../src/lib/db/database-admin/import/index.ts | 1 + .../nextjs/src/lib/db/database-admin/index.ts | 4 ++-- 5 files changed, 22 insertions(+), 20 deletions(-) rename frontends/nextjs/src/lib/db/database-admin/{ => export}/export-database.ts (56%) create mode 100644 frontends/nextjs/src/lib/db/database-admin/export/index.ts rename frontends/nextjs/src/lib/db/database-admin/{ => import}/import-database.ts (65%) create mode 100644 frontends/nextjs/src/lib/db/database-admin/import/index.ts diff --git a/frontends/nextjs/src/lib/db/database-admin/export-database.ts b/frontends/nextjs/src/lib/db/database-admin/export/export-database.ts similarity index 56% rename from frontends/nextjs/src/lib/db/database-admin/export-database.ts rename to frontends/nextjs/src/lib/db/database-admin/export/export-database.ts index 9ffc6615b..4905ea62d 100644 --- a/frontends/nextjs/src/lib/db/database-admin/export-database.ts +++ b/frontends/nextjs/src/lib/db/database-admin/export/export-database.ts @@ -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 diff --git a/frontends/nextjs/src/lib/db/database-admin/export/index.ts b/frontends/nextjs/src/lib/db/database-admin/export/index.ts new file mode 100644 index 000000000..d5754d35c --- /dev/null +++ b/frontends/nextjs/src/lib/db/database-admin/export/index.ts @@ -0,0 +1 @@ +export { exportDatabase } from './export-database' diff --git a/frontends/nextjs/src/lib/db/database-admin/import-database.ts b/frontends/nextjs/src/lib/db/database-admin/import/import-database.ts similarity index 65% rename from frontends/nextjs/src/lib/db/database-admin/import-database.ts rename to frontends/nextjs/src/lib/db/database-admin/import/import-database.ts index ff6749189..1964968db 100644 --- a/frontends/nextjs/src/lib/db/database-admin/import-database.ts +++ b/frontends/nextjs/src/lib/db/database-admin/import/import-database.ts @@ -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 diff --git a/frontends/nextjs/src/lib/db/database-admin/import/index.ts b/frontends/nextjs/src/lib/db/database-admin/import/index.ts new file mode 100644 index 000000000..c070c403c --- /dev/null +++ b/frontends/nextjs/src/lib/db/database-admin/import/index.ts @@ -0,0 +1 @@ +export { importDatabase } from './import-database' diff --git a/frontends/nextjs/src/lib/db/database-admin/index.ts b/frontends/nextjs/src/lib/db/database-admin/index.ts index 23fedd11b..242dcc5ba 100644 --- a/frontends/nextjs/src/lib/db/database-admin/index.ts +++ b/frontends/nextjs/src/lib/db/database-admin/index.ts @@ -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' From 782ac21120cc0590e365488da06f792893a39df0 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 17:51:53 +0000 Subject: [PATCH 3/3] refactor: modularize component hierarchy editor --- .../component/ComponentHierarchyEditor.tsx | 454 +++++------------- .../managers/component/modules/TreeNode.tsx | 139 ++++++ .../component/modules/useHierarchyData.ts | 49 ++ .../component/modules/useHierarchyDragDrop.ts | 119 +++++ 4 files changed, 433 insertions(+), 328 deletions(-) create mode 100644 frontends/nextjs/src/components/managers/component/modules/TreeNode.tsx create mode 100644 frontends/nextjs/src/components/managers/component/modules/useHierarchyData.ts create mode 100644 frontends/nextjs/src/components/managers/component/modules/useHierarchyDragDrop.ts diff --git a/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx index fb8da16ef..00715843f 100644 --- a/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx +++ b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx @@ -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 - selectedNodeId: string | null - expandedNodes: Set - 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 ( -
-
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 ? ( - - ) : ( -
- )} - -
- -
- - {node.type} - - - {node.order} - - -
- - -
-
- - {hasChildren && isExpanded && ( -
- {node.childIds - .sort((a, b) => hierarchy[a].order - hierarchy[b].order) - .map((childId) => ( - - ))} -
- )} -
- ) -} +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([]) - const [selectedPageId, setSelectedPageId] = useState('') - const [hierarchy, setHierarchy] = useState>({}) - const [selectedNodeId, setSelectedNodeId] = useState(null) - const [expandedNodes, setExpandedNodes] = useState>(new Set()) - const [draggingNodeId, setDraggingNodeId] = useState(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(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 ? ( +
+ +

No components yet. Add one from the catalog!

+
+ ) : ( +
+ {rootNodes.map((node) => ( + + ))} +
+ ), + [ + expandedNodes, + handleDeleteNode, + handleDragOver, + handleDragStart, + handleDrop, + handleToggleNode, + hierarchy, + rootNodes, + selectedNodeId, + draggingNodeId, + setConfigNodeId, + setSelectedNodeId, + ] + ) return (
@@ -368,32 +191,7 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool {selectedPageId ? ( - getRootNodes().length === 0 ? ( -
- -

No components yet. Add one from the catalog!

-
- ) : ( -
- {getRootNodes().map((node) => ( - - ))} -
- ) + renderTree ) : (

Select a page to edit its component hierarchy

diff --git a/frontends/nextjs/src/components/managers/component/modules/TreeNode.tsx b/frontends/nextjs/src/components/managers/component/modules/TreeNode.tsx new file mode 100644 index 000000000..2a4b2f132 --- /dev/null +++ b/frontends/nextjs/src/components/managers/component/modules/TreeNode.tsx @@ -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 + selectedNodeId: string | null + expandedNodes: Set + 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 ( +
+
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 ? ( + + ) : ( +
+ )} + +
+ +
+ + {node.type} + + + {node.order} + + +
+ + +
+ + {componentDef?.allowsChildren && ( +
can nest
+ )} +
+ + {hasChildren && isExpanded && ( +
+ {node.childIds + .map((childId) => hierarchy[childId]) + .filter(Boolean) + .sort((a, b) => a.order - b.order) + .map((child) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontends/nextjs/src/components/managers/component/modules/useHierarchyData.ts b/frontends/nextjs/src/components/managers/component/modules/useHierarchyData.ts new file mode 100644 index 000000000..5e566b492 --- /dev/null +++ b/frontends/nextjs/src/components/managers/component/modules/useHierarchyData.ts @@ -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 + loadHierarchy: () => Promise +} + +export function useHierarchyData(): UseHierarchyDataResult { + const [pages, setPages] = useState([]) + const [selectedPageId, setSelectedPageId] = useState('') + const [hierarchy, setHierarchy] = useState>({}) + + 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, + } +} diff --git a/frontends/nextjs/src/components/managers/component/modules/useHierarchyDragDrop.ts b/frontends/nextjs/src/components/managers/component/modules/useHierarchyDragDrop.ts new file mode 100644 index 000000000..122bbccdc --- /dev/null +++ b/frontends/nextjs/src/components/managers/component/modules/useHierarchyDragDrop.ts @@ -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 + loadHierarchy: () => Promise +} + +export function useHierarchyDragDrop({ hierarchy, loadHierarchy }: UseHierarchyDragDropProps) { + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [expandedNodes, setExpandedNodes] = useState>(new Set()) + const [draggingNodeId, setDraggingNodeId] = useState(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, + } +}