From df9193ffe6cc28d40aca616c4b623dc295df3aeb Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 17:50:50 +0000 Subject: [PATCH 1/6] 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/6] 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/6] 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, + } +} From 15d8fa4affdab0749574d805c8b834f901f8bec5 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 17:57:45 +0000 Subject: [PATCH 4/6] chore: sync mui theme mode with document --- .../src/app/providers/providers-component.tsx | 26 ++++++++++++------- .../nextjs/src/app/providers/theme-context.ts | 1 + 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/frontends/nextjs/src/app/providers/providers-component.tsx b/frontends/nextjs/src/app/providers/providers-component.tsx index 8ef029c35..471c09f14 100644 --- a/frontends/nextjs/src/app/providers/providers-component.tsx +++ b/frontends/nextjs/src/app/providers/providers-component.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { CssBaseline, ThemeProvider as MuiThemeProvider } from '@mui/material' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { lightTheme, darkTheme } from '@/theme/mui-theme' @@ -21,17 +21,25 @@ export function Providers({ children }: { children: React.ReactNode }) { const [mode, setMode] = useState('system') - const theme = useMemo(() => { + const resolvedMode = useMemo>(() => { if (mode === 'system') { - // Detect system preference - const isDark = typeof window !== 'undefined' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - : false - return isDark ? darkTheme : lightTheme + return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' } - return mode === 'dark' ? darkTheme : lightTheme + + return mode }, [mode]) + const theme = useMemo(() => (resolvedMode === 'dark' ? darkTheme : lightTheme), [resolvedMode]) + + useEffect(() => { + const root = document.documentElement + + root.dataset.theme = resolvedMode + root.style.colorScheme = resolvedMode + }, [resolvedMode]) + const toggleTheme = () => { setMode(current => { if (current === 'light') return 'dark' @@ -41,7 +49,7 @@ export function Providers({ children }: { children: React.ReactNode }) { } return ( - + diff --git a/frontends/nextjs/src/app/providers/theme-context.ts b/frontends/nextjs/src/app/providers/theme-context.ts index e04ff0479..7be3b2fc6 100644 --- a/frontends/nextjs/src/app/providers/theme-context.ts +++ b/frontends/nextjs/src/app/providers/theme-context.ts @@ -4,6 +4,7 @@ export type ThemeMode = 'light' | 'dark' | 'system' export interface ThemeContextType { mode: ThemeMode + resolvedMode: Exclude setMode: (mode: ThemeMode) => void toggleTheme: () => void } From ed704f93aa3919b2ee787821b175e9b4524ce1c0 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 17:58:29 +0000 Subject: [PATCH 5/6] refactor: segment component catalog --- frontends/nextjs/package-lock.json | 10 - .../nextjs/src/lib/component-registry.ts | 1 + .../src/lib/components/builder-types.ts | 1 - .../nextjs/src/lib/components/catalog/data.ts | 13 + .../src/lib/components/catalog/display.ts | 73 ++++ .../src/lib/components/catalog/feedback.ts | 39 ++ .../src/lib/components/catalog/inputs.ts | 116 ++++++ .../src/lib/components/catalog/layout.ts | 56 +++ .../src/lib/components/catalog/typography.ts | 60 +++ .../lib/components/component-catalog.test.ts | 34 ++ .../src/lib/components/component-catalog.ts | 347 +----------------- .../src/lib/components/component-registry.ts | 8 +- .../core/initialize-component-registry.ts | 2 +- .../component-registry/core/registry-class.ts | 14 +- .../component-registry/core/types.ts | 2 +- .../getters/get-all-components.ts | 4 +- .../getters/get-component-registry.ts | 4 +- .../getters/get-component.ts | 9 +- .../getters/get-components-by-category.ts | 11 +- .../getters/has-component.ts | 2 +- .../register/load-from-catalog.ts | 6 +- .../register/register-component.ts | 4 +- .../register/register-components.ts | 6 +- .../nextjs/src/lib/components/types/index.ts | 1 + 24 files changed, 439 insertions(+), 384 deletions(-) create mode 100644 frontends/nextjs/src/lib/component-registry.ts delete mode 100644 frontends/nextjs/src/lib/components/builder-types.ts create mode 100644 frontends/nextjs/src/lib/components/catalog/data.ts create mode 100644 frontends/nextjs/src/lib/components/catalog/display.ts create mode 100644 frontends/nextjs/src/lib/components/catalog/feedback.ts create mode 100644 frontends/nextjs/src/lib/components/catalog/inputs.ts create mode 100644 frontends/nextjs/src/lib/components/catalog/layout.ts create mode 100644 frontends/nextjs/src/lib/components/catalog/typography.ts create mode 100644 frontends/nextjs/src/lib/components/component-catalog.test.ts create mode 100644 frontends/nextjs/src/lib/components/types/index.ts diff --git a/frontends/nextjs/package-lock.json b/frontends/nextjs/package-lock.json index 53b0ce34b..1fb379c9e 100644 --- a/frontends/nextjs/package-lock.json +++ b/frontends/nextjs/package-lock.json @@ -5743,16 +5743,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jszip": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.1.tgz", - "integrity": "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==", - "deprecated": "This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.", - "license": "MIT", - "dependencies": { - "jszip": "*" - } - }, "node_modules/@types/node": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", diff --git a/frontends/nextjs/src/lib/component-registry.ts b/frontends/nextjs/src/lib/component-registry.ts new file mode 100644 index 000000000..0b83c42d9 --- /dev/null +++ b/frontends/nextjs/src/lib/component-registry.ts @@ -0,0 +1 @@ +export * from './components/component-registry' diff --git a/frontends/nextjs/src/lib/components/builder-types.ts b/frontends/nextjs/src/lib/components/builder-types.ts deleted file mode 100644 index 346266bd4..000000000 --- a/frontends/nextjs/src/lib/components/builder-types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../builder-types' diff --git a/frontends/nextjs/src/lib/components/catalog/data.ts b/frontends/nextjs/src/lib/components/catalog/data.ts new file mode 100644 index 000000000..b10eeab85 --- /dev/null +++ b/frontends/nextjs/src/lib/components/catalog/data.ts @@ -0,0 +1,13 @@ +import type { ComponentDefinition } from '../types' + +export const dataComponents: ComponentDefinition[] = [ + { + type: 'Table', + label: 'Table', + icon: 'Table', + category: 'Data', + allowsChildren: true, + defaultProps: {}, + propSchema: [], + }, +] diff --git a/frontends/nextjs/src/lib/components/catalog/display.ts b/frontends/nextjs/src/lib/components/catalog/display.ts new file mode 100644 index 000000000..767730089 --- /dev/null +++ b/frontends/nextjs/src/lib/components/catalog/display.ts @@ -0,0 +1,73 @@ +import type { ComponentDefinition } from '../types' + +export const displayComponents: ComponentDefinition[] = [ + { + type: 'Card', + label: 'Card', + icon: 'Card', + category: 'Display', + allowsChildren: true, + defaultProps: { + className: 'p-6', + }, + propSchema: [ + { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'p-6' }, + ], + }, + { + type: 'Badge', + label: 'Badge', + icon: 'Seal', + category: 'Display', + allowsChildren: false, + defaultProps: { + children: 'Badge', + variant: 'default', + }, + propSchema: [ + { name: 'children', label: 'Text', type: 'string', defaultValue: 'Badge' }, + { + name: 'variant', + label: 'Variant', + type: 'select', + defaultValue: 'default', + options: [ + { value: 'default', label: 'Default' }, + { value: 'secondary', label: 'Secondary' }, + { value: 'outline', label: 'Outline' }, + ], + }, + ], + }, + { + type: 'Separator', + label: 'Separator', + icon: 'Minus', + category: 'Display', + allowsChildren: false, + defaultProps: {}, + propSchema: [], + }, + { + type: 'Avatar', + label: 'Avatar', + icon: 'UserCircle', + category: 'Display', + allowsChildren: false, + defaultProps: {}, + propSchema: [], + }, + { + type: 'IRCWebchat', + label: 'IRC Webchat', + icon: 'Chat', + category: 'Display', + allowsChildren: false, + defaultProps: { + channelName: 'general', + }, + propSchema: [ + { name: 'channelName', label: 'Channel Name', type: 'string', defaultValue: 'general' }, + ], + }, +] diff --git a/frontends/nextjs/src/lib/components/catalog/feedback.ts b/frontends/nextjs/src/lib/components/catalog/feedback.ts new file mode 100644 index 000000000..eb8ec6f21 --- /dev/null +++ b/frontends/nextjs/src/lib/components/catalog/feedback.ts @@ -0,0 +1,39 @@ +import type { ComponentDefinition } from '../types' + +export const feedbackComponents: ComponentDefinition[] = [ + { + type: 'Alert', + label: 'Alert', + icon: 'Warning', + category: 'Feedback', + allowsChildren: true, + defaultProps: { + variant: 'default', + }, + propSchema: [ + { + name: 'variant', + label: 'Variant', + type: 'select', + defaultValue: 'default', + options: [ + { value: 'default', label: 'Default' }, + { value: 'destructive', label: 'Destructive' }, + ], + }, + ], + }, + { + type: 'Progress', + label: 'Progress', + icon: 'CircleNotch', + category: 'Feedback', + allowsChildren: false, + defaultProps: { + value: 50, + }, + propSchema: [ + { name: 'value', label: 'Value', type: 'number', defaultValue: 50 }, + ], + }, +] diff --git a/frontends/nextjs/src/lib/components/catalog/inputs.ts b/frontends/nextjs/src/lib/components/catalog/inputs.ts new file mode 100644 index 000000000..8d0f7fbe3 --- /dev/null +++ b/frontends/nextjs/src/lib/components/catalog/inputs.ts @@ -0,0 +1,116 @@ +import type { ComponentDefinition } from '../types' + +export const inputComponents: ComponentDefinition[] = [ + { + type: 'Button', + label: 'Button', + icon: 'CursorClick', + category: 'Input', + allowsChildren: false, + defaultProps: { + children: 'Click me', + variant: 'default', + }, + propSchema: [ + { name: 'children', label: 'Text', type: 'string', defaultValue: 'Click me' }, + { + name: 'variant', + label: 'Variant', + type: 'select', + defaultValue: 'default', + options: [ + { value: 'default', label: 'Default' }, + { value: 'outline', label: 'Outline' }, + { value: 'ghost', label: 'Ghost' }, + { value: 'link', label: 'Link' }, + ], + }, + { + name: 'size', + label: 'Size', + type: 'select', + defaultValue: 'default', + options: [ + { value: 'sm', label: 'Small' }, + { value: 'default', label: 'Default' }, + { value: 'lg', label: 'Large' }, + ], + }, + ], + }, + { + type: 'Input', + label: 'Input', + icon: 'TextT', + category: 'Input', + allowsChildren: false, + defaultProps: { + placeholder: 'Enter text...', + type: 'text', + }, + propSchema: [ + { name: 'placeholder', label: 'Placeholder', type: 'string', defaultValue: 'Enter text...' }, + { + name: 'type', + label: 'Type', + type: 'select', + defaultValue: 'text', + options: [ + { value: 'text', label: 'Text' }, + { value: 'email', label: 'Email' }, + { value: 'password', label: 'Password' }, + { value: 'number', label: 'Number' }, + ], + }, + ], + }, + { + type: 'Textarea', + label: 'Textarea', + icon: 'TextAlignLeft', + category: 'Input', + allowsChildren: false, + defaultProps: { + placeholder: 'Enter text...', + rows: 4, + }, + propSchema: [ + { name: 'placeholder', label: 'Placeholder', type: 'string', defaultValue: 'Enter text...' }, + { name: 'rows', label: 'Rows', type: 'number', defaultValue: 4 }, + ], + }, + { + type: 'Switch', + label: 'Switch', + icon: 'ToggleRight', + category: 'Input', + allowsChildren: false, + defaultProps: {}, + propSchema: [], + }, + { + type: 'Checkbox', + label: 'Checkbox', + icon: 'CheckSquare', + category: 'Input', + allowsChildren: false, + defaultProps: {}, + propSchema: [], + }, + { + type: 'Slider', + label: 'Slider', + icon: 'SlidersHorizontal', + category: 'Input', + allowsChildren: false, + defaultProps: { + defaultValue: [50], + max: 100, + step: 1, + }, + propSchema: [ + { name: 'max', label: 'Maximum', type: 'number', defaultValue: 100 }, + { name: 'step', label: 'Step', type: 'number', defaultValue: 1 }, + ], + }, +] diff --git a/frontends/nextjs/src/lib/components/catalog/layout.ts b/frontends/nextjs/src/lib/components/catalog/layout.ts new file mode 100644 index 000000000..ffe73ef11 --- /dev/null +++ b/frontends/nextjs/src/lib/components/catalog/layout.ts @@ -0,0 +1,56 @@ +import type { ComponentDefinition } from '../types' + +export const layoutComponents: ComponentDefinition[] = [ + { + type: 'Container', + label: 'Container', + icon: 'FrameCorners', + category: 'Layout', + allowsChildren: true, + defaultProps: { + className: 'p-4', + }, + propSchema: [ + { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'p-4' }, + ], + }, + { + type: 'Flex', + label: 'Flex Box', + icon: 'Columns', + category: 'Layout', + allowsChildren: true, + defaultProps: { + className: 'flex gap-4', + }, + propSchema: [ + { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'flex gap-4' }, + ], + }, + { + type: 'Grid', + label: 'Grid', + icon: 'GridFour', + category: 'Layout', + allowsChildren: true, + defaultProps: { + className: 'grid grid-cols-2 gap-4', + }, + propSchema: [ + { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'grid grid-cols-2 gap-4' }, + ], + }, + { + type: 'Stack', + label: 'Stack', + icon: 'Stack', + category: 'Layout', + allowsChildren: true, + defaultProps: { + className: 'flex flex-col gap-2', + }, + propSchema: [ + { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'flex flex-col gap-2' }, + ], + }, +] diff --git a/frontends/nextjs/src/lib/components/catalog/typography.ts b/frontends/nextjs/src/lib/components/catalog/typography.ts new file mode 100644 index 000000000..1a4f25e2f --- /dev/null +++ b/frontends/nextjs/src/lib/components/catalog/typography.ts @@ -0,0 +1,60 @@ +import type { ComponentDefinition } from '../types' + +export const typographyComponents: ComponentDefinition[] = [ + { + type: 'Label', + label: 'Label', + icon: 'Tag', + category: 'Typography', + allowsChildren: false, + defaultProps: { + children: 'Label', + }, + propSchema: [ + { name: 'children', label: 'Text', type: 'string', defaultValue: 'Label' }, + ], + }, + { + type: 'Heading', + label: 'Heading', + icon: 'TextHOne', + category: 'Typography', + allowsChildren: false, + defaultProps: { + children: 'Heading', + level: '1', + className: 'text-3xl font-bold', + }, + propSchema: [ + { name: 'children', label: 'Text', type: 'string', defaultValue: 'Heading' }, + { + name: 'level', + label: 'Level', + type: 'select', + defaultValue: '1', + options: [ + { value: '1', label: 'H1' }, + { value: '2', label: 'H2' }, + { value: '3', label: 'H3' }, + { value: '4', label: 'H4' }, + ], + }, + { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'text-3xl font-bold' }, + ], + }, + { + type: 'Text', + label: 'Text', + icon: 'Article', + category: 'Typography', + allowsChildren: false, + defaultProps: { + children: 'Text content', + className: '', + }, + propSchema: [ + { name: 'children', label: 'Content', type: 'string', defaultValue: 'Text content' }, + { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: '' }, + ], + }, +] diff --git a/frontends/nextjs/src/lib/components/component-catalog.test.ts b/frontends/nextjs/src/lib/components/component-catalog.test.ts new file mode 100644 index 000000000..e3bbd7360 --- /dev/null +++ b/frontends/nextjs/src/lib/components/component-catalog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { componentCatalog } from './component-catalog' +import { dataComponents } from './catalog/data' +import { displayComponents } from './catalog/display' +import { feedbackComponents } from './catalog/feedback' +import { inputComponents } from './catalog/inputs' +import { layoutComponents } from './catalog/layout' +import { typographyComponents } from './catalog/typography' + +const catalogSections = [ + { name: 'layout', category: 'Layout', components: layoutComponents }, + { name: 'display', category: 'Display', components: displayComponents }, + { name: 'inputs', category: 'Input', components: inputComponents }, + { name: 'typography', category: 'Typography', components: typographyComponents }, + { name: 'feedback', category: 'Feedback', components: feedbackComponents }, + { name: 'data', category: 'Data', components: dataComponents }, +] as const + +describe('component catalog composition', () => { + it('includes every component from each section in order', () => { + const sectionTypes = catalogSections.flatMap(section => section.components.map(component => component.type)) + + expect(componentCatalog).toHaveLength(sectionTypes.length) + expect(componentCatalog.map(component => component.type)).toEqual(sectionTypes) + }) + + it('keeps components grouped under the correct category', () => { + catalogSections.forEach(section => { + section.components.forEach(component => { + expect(component.category).toBe(section.category) + }) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/components/component-catalog.ts b/frontends/nextjs/src/lib/components/component-catalog.ts index c565c9893..9e2132ab1 100644 --- a/frontends/nextjs/src/lib/components/component-catalog.ts +++ b/frontends/nextjs/src/lib/components/component-catalog.ts @@ -1,337 +1,16 @@ -import type { ComponentDefinition } from './builder-types' +import type { ComponentDefinition } from './types' +import { dataComponents } from './catalog/data' +import { displayComponents } from './catalog/display' +import { feedbackComponents } from './catalog/feedback' +import { inputComponents } from './catalog/inputs' +import { layoutComponents } from './catalog/layout' +import { typographyComponents } from './catalog/typography' export const componentCatalog: ComponentDefinition[] = [ - { - type: 'Container', - label: 'Container', - icon: 'FrameCorners', - category: 'Layout', - allowsChildren: true, - defaultProps: { - className: 'p-4', - }, - propSchema: [ - { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'p-4' }, - ], - }, - { - type: 'Flex', - label: 'Flex Box', - icon: 'Columns', - category: 'Layout', - allowsChildren: true, - defaultProps: { - className: 'flex gap-4', - }, - propSchema: [ - { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'flex gap-4' }, - ], - }, - { - type: 'Grid', - label: 'Grid', - icon: 'GridFour', - category: 'Layout', - allowsChildren: true, - defaultProps: { - className: 'grid grid-cols-2 gap-4', - }, - propSchema: [ - { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'grid grid-cols-2 gap-4' }, - ], - }, - { - type: 'Stack', - label: 'Stack', - icon: 'Stack', - category: 'Layout', - allowsChildren: true, - defaultProps: { - className: 'flex flex-col gap-2', - }, - propSchema: [ - { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'flex flex-col gap-2' }, - ], - }, - { - type: 'Card', - label: 'Card', - icon: 'Card', - category: 'Display', - allowsChildren: true, - defaultProps: { - className: 'p-6', - }, - propSchema: [ - { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'p-6' }, - ], - }, - { - type: 'Button', - label: 'Button', - icon: 'CursorClick', - category: 'Input', - allowsChildren: false, - defaultProps: { - children: 'Click me', - variant: 'default', - }, - propSchema: [ - { name: 'children', label: 'Text', type: 'string', defaultValue: 'Click me' }, - { - name: 'variant', - label: 'Variant', - type: 'select', - defaultValue: 'default', - options: [ - { value: 'default', label: 'Default' }, - { value: 'outline', label: 'Outline' }, - { value: 'ghost', label: 'Ghost' }, - { value: 'link', label: 'Link' }, - ], - }, - { - name: 'size', - label: 'Size', - type: 'select', - defaultValue: 'default', - options: [ - { value: 'sm', label: 'Small' }, - { value: 'default', label: 'Default' }, - { value: 'lg', label: 'Large' }, - ], - }, - ], - }, - { - type: 'Input', - label: 'Input', - icon: 'TextT', - category: 'Input', - allowsChildren: false, - defaultProps: { - placeholder: 'Enter text...', - type: 'text', - }, - propSchema: [ - { name: 'placeholder', label: 'Placeholder', type: 'string', defaultValue: 'Enter text...' }, - { - name: 'type', - label: 'Type', - type: 'select', - defaultValue: 'text', - options: [ - { value: 'text', label: 'Text' }, - { value: 'email', label: 'Email' }, - { value: 'password', label: 'Password' }, - { value: 'number', label: 'Number' }, - ], - }, - ], - }, - { - type: 'Textarea', - label: 'Textarea', - icon: 'TextAlignLeft', - category: 'Input', - allowsChildren: false, - defaultProps: { - placeholder: 'Enter text...', - rows: 4, - }, - propSchema: [ - { name: 'placeholder', label: 'Placeholder', type: 'string', defaultValue: 'Enter text...' }, - { name: 'rows', label: 'Rows', type: 'number', defaultValue: 4 }, - ], - }, - { - type: 'Label', - label: 'Label', - icon: 'Tag', - category: 'Typography', - allowsChildren: false, - defaultProps: { - children: 'Label', - }, - propSchema: [ - { name: 'children', label: 'Text', type: 'string', defaultValue: 'Label' }, - ], - }, - { - type: 'Heading', - label: 'Heading', - icon: 'TextHOne', - category: 'Typography', - allowsChildren: false, - defaultProps: { - children: 'Heading', - level: '1', - className: 'text-3xl font-bold', - }, - propSchema: [ - { name: 'children', label: 'Text', type: 'string', defaultValue: 'Heading' }, - { - name: 'level', - label: 'Level', - type: 'select', - defaultValue: '1', - options: [ - { value: '1', label: 'H1' }, - { value: '2', label: 'H2' }, - { value: '3', label: 'H3' }, - { value: '4', label: 'H4' }, - ], - }, - { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: 'text-3xl font-bold' }, - ], - }, - { - type: 'Text', - label: 'Text', - icon: 'Article', - category: 'Typography', - allowsChildren: false, - defaultProps: { - children: 'Text content', - className: '', - }, - propSchema: [ - { name: 'children', label: 'Content', type: 'string', defaultValue: 'Text content' }, - { name: 'className', label: 'CSS Classes', type: 'string', defaultValue: '' }, - ], - }, - { - type: 'Badge', - label: 'Badge', - icon: 'Seal', - category: 'Display', - allowsChildren: false, - defaultProps: { - children: 'Badge', - variant: 'default', - }, - propSchema: [ - { name: 'children', label: 'Text', type: 'string', defaultValue: 'Badge' }, - { - name: 'variant', - label: 'Variant', - type: 'select', - defaultValue: 'default', - options: [ - { value: 'default', label: 'Default' }, - { value: 'secondary', label: 'Secondary' }, - { value: 'outline', label: 'Outline' }, - ], - }, - ], - }, - { - type: 'Switch', - label: 'Switch', - icon: 'ToggleRight', - category: 'Input', - allowsChildren: false, - defaultProps: {}, - propSchema: [], - }, - { - type: 'Checkbox', - label: 'Checkbox', - icon: 'CheckSquare', - category: 'Input', - allowsChildren: false, - defaultProps: {}, - propSchema: [], - }, - { - type: 'Separator', - label: 'Separator', - icon: 'Minus', - category: 'Display', - allowsChildren: false, - defaultProps: {}, - propSchema: [], - }, - { - type: 'Alert', - label: 'Alert', - icon: 'Warning', - category: 'Feedback', - allowsChildren: true, - defaultProps: { - variant: 'default', - }, - propSchema: [ - { - name: 'variant', - label: 'Variant', - type: 'select', - defaultValue: 'default', - options: [ - { value: 'default', label: 'Default' }, - { value: 'destructive', label: 'Destructive' }, - ], - }, - ], - }, - { - type: 'Progress', - label: 'Progress', - icon: 'CircleNotch', - category: 'Feedback', - allowsChildren: false, - defaultProps: { - value: 50, - }, - propSchema: [ - { name: 'value', label: 'Value', type: 'number', defaultValue: 50 }, - ], - }, - { - type: 'Slider', - label: 'Slider', - icon: 'SlidersHorizontal', - category: 'Input', - allowsChildren: false, - defaultProps: { - defaultValue: [50], - max: 100, - step: 1, - }, - propSchema: [ - { name: 'max', label: 'Maximum', type: 'number', defaultValue: 100 }, - { name: 'step', label: 'Step', type: 'number', defaultValue: 1 }, - ], - }, - { - type: 'Avatar', - label: 'Avatar', - icon: 'UserCircle', - category: 'Display', - allowsChildren: false, - defaultProps: {}, - propSchema: [], - }, - { - type: 'Table', - label: 'Table', - icon: 'Table', - category: 'Data', - allowsChildren: true, - defaultProps: {}, - propSchema: [], - }, - { - type: 'IRCWebchat', - label: 'IRC Webchat', - icon: 'Chat', - category: 'Display', - allowsChildren: false, - defaultProps: { - channelName: 'general', - }, - propSchema: [ - { name: 'channelName', label: 'Channel Name', type: 'string', defaultValue: 'general' }, - ], - }, + ...layoutComponents, + ...displayComponents, + ...inputComponents, + ...typographyComponents, + ...feedbackComponents, + ...dataComponents, ] diff --git a/frontends/nextjs/src/lib/components/component-registry.ts b/frontends/nextjs/src/lib/components/component-registry.ts index 3da82e93b..f3f39daab 100644 --- a/frontends/nextjs/src/lib/components/component-registry.ts +++ b/frontends/nextjs/src/lib/components/component-registry.ts @@ -1,5 +1,5 @@ -export type { ComponentTypeDefinition } from './component-registry/types' +export type { ComponentTypeDefinition } from './component-registry/core/types' -export { ComponentRegistry } from './component-registry/registry-class' -export { getComponentRegistry } from './component-registry/get-component-registry' -export { initializeComponentRegistry } from './component-registry/initialize-component-registry' +export { ComponentRegistry } from './component-registry/core/registry-class' +export { getComponentRegistry } from './component-registry/getters/get-component-registry' +export { initializeComponentRegistry } from './component-registry/core/initialize-component-registry' diff --git a/frontends/nextjs/src/lib/components/component-registry/core/initialize-component-registry.ts b/frontends/nextjs/src/lib/components/component-registry/core/initialize-component-registry.ts index b06ff76c1..c72f9dbab 100644 --- a/frontends/nextjs/src/lib/components/component-registry/core/initialize-component-registry.ts +++ b/frontends/nextjs/src/lib/components/component-registry/core/initialize-component-registry.ts @@ -1,4 +1,4 @@ -import { getComponentRegistry } from './get-component-registry' +import { getComponentRegistry } from '../getters/get-component-registry' export async function initializeComponentRegistry(): Promise { getComponentRegistry() diff --git a/frontends/nextjs/src/lib/components/component-registry/core/registry-class.ts b/frontends/nextjs/src/lib/components/component-registry/core/registry-class.ts index abce0a41b..bc00493d0 100644 --- a/frontends/nextjs/src/lib/components/component-registry/core/registry-class.ts +++ b/frontends/nextjs/src/lib/components/component-registry/core/registry-class.ts @@ -1,12 +1,12 @@ import type { ComponentTypeDefinition } from './types' import { createComponentRegistryState } from './registry-state' -import { getAllComponents } from './get-all-components' -import { getComponent } from './get-component' -import { getComponentsByCategory } from './get-components-by-category' -import { hasComponent } from './has-component' -import { loadFromCatalog } from './load-from-catalog' -import { registerComponent } from './register-component' -import { registerComponents } from './register-components' +import { getAllComponents } from '../getters/get-all-components' +import { getComponent } from '../getters/get-component' +import { getComponentsByCategory } from '../getters/get-components-by-category' +import { hasComponent } from '../getters/has-component' +import { loadFromCatalog } from '../register/load-from-catalog' +import { registerComponent } from '../register/register-component' +import { registerComponents } from '../register/register-components' export class ComponentRegistry { private state = createComponentRegistryState() diff --git a/frontends/nextjs/src/lib/components/component-registry/core/types.ts b/frontends/nextjs/src/lib/components/component-registry/core/types.ts index a4b5dbfa3..472db5471 100644 --- a/frontends/nextjs/src/lib/components/component-registry/core/types.ts +++ b/frontends/nextjs/src/lib/components/component-registry/core/types.ts @@ -1,4 +1,4 @@ -import type { ComponentDefinition } from '../builder-types' +import type { ComponentDefinition } from '../../types' export interface ComponentTypeDefinition extends ComponentDefinition { renderingLogic?: { diff --git a/frontends/nextjs/src/lib/components/component-registry/getters/get-all-components.ts b/frontends/nextjs/src/lib/components/component-registry/getters/get-all-components.ts index a6a245658..758e61f58 100644 --- a/frontends/nextjs/src/lib/components/component-registry/getters/get-all-components.ts +++ b/frontends/nextjs/src/lib/components/component-registry/getters/get-all-components.ts @@ -1,5 +1,5 @@ -import type { ComponentRegistryState } from './registry-state' -import type { ComponentTypeDefinition } from './types' +import type { ComponentRegistryState } from '../core/registry-state' +import type { ComponentTypeDefinition } from '../core/types' export function getAllComponents(state: ComponentRegistryState): ComponentTypeDefinition[] { return Array.from(state.components.values()) diff --git a/frontends/nextjs/src/lib/components/component-registry/getters/get-component-registry.ts b/frontends/nextjs/src/lib/components/component-registry/getters/get-component-registry.ts index 2d25b9abc..45729d039 100644 --- a/frontends/nextjs/src/lib/components/component-registry/getters/get-component-registry.ts +++ b/frontends/nextjs/src/lib/components/component-registry/getters/get-component-registry.ts @@ -1,5 +1,5 @@ -import { ComponentRegistry } from './registry-class' -import { componentRegistryState } from './registry-singleton' +import { ComponentRegistry } from '../core/registry-class' +import { componentRegistryState } from '../core/registry-singleton' export function getComponentRegistry(): ComponentRegistry { if (!componentRegistryState.instance) { diff --git a/frontends/nextjs/src/lib/components/component-registry/getters/get-component.ts b/frontends/nextjs/src/lib/components/component-registry/getters/get-component.ts index 374204466..1da88e5cb 100644 --- a/frontends/nextjs/src/lib/components/component-registry/getters/get-component.ts +++ b/frontends/nextjs/src/lib/components/component-registry/getters/get-component.ts @@ -1,9 +1,6 @@ -import type { ComponentRegistryState } from './registry-state' -import type { ComponentTypeDefinition } from './types' +import type { ComponentRegistryState } from '../core/registry-state' +import type { ComponentTypeDefinition } from '../core/types' -export function getComponent( - state: ComponentRegistryState, - type: string -): ComponentTypeDefinition | undefined { +export function getComponent(state: ComponentRegistryState, type: string): ComponentTypeDefinition | undefined { return state.components.get(type) } diff --git a/frontends/nextjs/src/lib/components/component-registry/getters/get-components-by-category.ts b/frontends/nextjs/src/lib/components/component-registry/getters/get-components-by-category.ts index 9792c4e2e..7eab4451b 100644 --- a/frontends/nextjs/src/lib/components/component-registry/getters/get-components-by-category.ts +++ b/frontends/nextjs/src/lib/components/component-registry/getters/get-components-by-category.ts @@ -1,9 +1,6 @@ -import type { ComponentRegistryState } from './registry-state' -import type { ComponentTypeDefinition } from './types' +import type { ComponentRegistryState } from '../core/registry-state' +import type { ComponentTypeDefinition } from '../core/types' -export function getComponentsByCategory( - state: ComponentRegistryState, - category: string -): ComponentTypeDefinition[] { - return Array.from(state.components.values()).filter(comp => comp.category === category) +export function getComponentsByCategory(state: ComponentRegistryState, category: string): ComponentTypeDefinition[] { + return Array.from(state.components.values()).filter(component => component.category === category) } diff --git a/frontends/nextjs/src/lib/components/component-registry/getters/has-component.ts b/frontends/nextjs/src/lib/components/component-registry/getters/has-component.ts index fbe676fd0..8c6fc67c8 100644 --- a/frontends/nextjs/src/lib/components/component-registry/getters/has-component.ts +++ b/frontends/nextjs/src/lib/components/component-registry/getters/has-component.ts @@ -1,4 +1,4 @@ -import type { ComponentRegistryState } from './registry-state' +import type { ComponentRegistryState } from '../core/registry-state' export function hasComponent(state: ComponentRegistryState, type: string): boolean { return state.components.has(type) diff --git a/frontends/nextjs/src/lib/components/component-registry/register/load-from-catalog.ts b/frontends/nextjs/src/lib/components/component-registry/register/load-from-catalog.ts index 679ea776b..afe9272b2 100644 --- a/frontends/nextjs/src/lib/components/component-registry/register/load-from-catalog.ts +++ b/frontends/nextjs/src/lib/components/component-registry/register/load-from-catalog.ts @@ -1,6 +1,6 @@ -import { componentCatalog } from '../component-catalog' -import type { ComponentRegistryState } from './registry-state' -import type { ComponentTypeDefinition } from './types' +import { componentCatalog } from '../../component-catalog' +import type { ComponentRegistryState } from '../core/registry-state' +import type { ComponentTypeDefinition } from '../core/types' export function loadFromCatalog(state: ComponentRegistryState): void { componentCatalog.forEach(comp => { diff --git a/frontends/nextjs/src/lib/components/component-registry/register/register-component.ts b/frontends/nextjs/src/lib/components/component-registry/register/register-component.ts index d9f73dbb5..1b0c95184 100644 --- a/frontends/nextjs/src/lib/components/component-registry/register/register-component.ts +++ b/frontends/nextjs/src/lib/components/component-registry/register/register-component.ts @@ -1,5 +1,5 @@ -import type { ComponentRegistryState } from './registry-state' -import type { ComponentTypeDefinition } from './types' +import type { ComponentRegistryState } from '../core/registry-state' +import type { ComponentTypeDefinition } from '../core/types' export function registerComponent(state: ComponentRegistryState, component: ComponentTypeDefinition): void { state.components.set(component.type, component) diff --git a/frontends/nextjs/src/lib/components/component-registry/register/register-components.ts b/frontends/nextjs/src/lib/components/component-registry/register/register-components.ts index 0eff19567..e7001873e 100644 --- a/frontends/nextjs/src/lib/components/component-registry/register/register-components.ts +++ b/frontends/nextjs/src/lib/components/component-registry/register/register-components.ts @@ -1,7 +1,7 @@ -import type { ComponentRegistryState } from './registry-state' -import type { ComponentTypeDefinition } from './types' +import type { ComponentRegistryState } from '../core/registry-state' +import type { ComponentTypeDefinition } from '../core/types' import { registerComponent } from './register-component' export function registerComponents(state: ComponentRegistryState, components: ComponentTypeDefinition[]): void { - components.forEach(comp => registerComponent(state, comp)) + components.forEach(component => registerComponent(state, component)) } diff --git a/frontends/nextjs/src/lib/components/types/index.ts b/frontends/nextjs/src/lib/components/types/index.ts new file mode 100644 index 000000000..bc9bfb617 --- /dev/null +++ b/frontends/nextjs/src/lib/components/types/index.ts @@ -0,0 +1 @@ +export * from '@/lib/types/builder-types' From 76b1ce9486c76fe596e4cc7a098c0f342c3c1aaf Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 17:59:35 +0000 Subject: [PATCH 6/6] refactor: modularize lua block metadata --- frontends/nextjs/package-lock.json | 10 - .../components/editors/lua/blocks/basics.ts | 49 +++ .../src/components/editors/lua/blocks/data.ts | 36 +++ .../editors/lua/blocks/functions.ts | 26 ++ .../components/editors/lua/blocks/index.ts | 33 ++ .../components/editors/lua/blocks/logic.ts | 37 +++ .../components/editors/lua/blocks/loops.ts | 27 ++ .../lua/hooks/luaBlockSerialization.ts | 105 +++++++ .../lua/hooks/useBlockDefinitions.test.ts | 66 ++++ .../editors/lua/hooks/useBlockDefinitions.ts | 292 +----------------- 10 files changed, 392 insertions(+), 289 deletions(-) create mode 100644 frontends/nextjs/src/components/editors/lua/blocks/basics.ts create mode 100644 frontends/nextjs/src/components/editors/lua/blocks/data.ts create mode 100644 frontends/nextjs/src/components/editors/lua/blocks/functions.ts create mode 100644 frontends/nextjs/src/components/editors/lua/blocks/index.ts create mode 100644 frontends/nextjs/src/components/editors/lua/blocks/logic.ts create mode 100644 frontends/nextjs/src/components/editors/lua/blocks/loops.ts create mode 100644 frontends/nextjs/src/components/editors/lua/hooks/luaBlockSerialization.ts create mode 100644 frontends/nextjs/src/components/editors/lua/hooks/useBlockDefinitions.test.ts diff --git a/frontends/nextjs/package-lock.json b/frontends/nextjs/package-lock.json index 53b0ce34b..1fb379c9e 100644 --- a/frontends/nextjs/package-lock.json +++ b/frontends/nextjs/package-lock.json @@ -5743,16 +5743,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jszip": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.1.tgz", - "integrity": "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==", - "deprecated": "This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.", - "license": "MIT", - "dependencies": { - "jszip": "*" - } - }, "node_modules/@types/node": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", diff --git a/frontends/nextjs/src/components/editors/lua/blocks/basics.ts b/frontends/nextjs/src/components/editors/lua/blocks/basics.ts new file mode 100644 index 000000000..477af142d --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/blocks/basics.ts @@ -0,0 +1,49 @@ +import type { BlockDefinition } from '../types' + +export const basicBlocks: BlockDefinition[] = [ + { + type: 'log', + label: 'Log message', + description: 'Send a message to the Lua console', + category: 'Basics', + fields: [ + { + name: 'message', + label: 'Message', + placeholder: '"Hello from Lua"', + type: 'text', + defaultValue: '"Hello from Lua"', + }, + ], + }, + { + type: 'return', + label: 'Return', + description: 'Return a value from the script', + category: 'Basics', + fields: [ + { + name: 'value', + label: 'Value', + placeholder: 'true', + type: 'text', + defaultValue: 'true', + }, + ], + }, + { + type: 'comment', + label: 'Comment', + description: 'Add a comment to explain a step', + category: 'Basics', + fields: [ + { + name: 'text', + label: 'Comment', + placeholder: 'Explain what happens here', + type: 'text', + defaultValue: 'Explain what happens here', + }, + ], + }, +] diff --git a/frontends/nextjs/src/components/editors/lua/blocks/data.ts b/frontends/nextjs/src/components/editors/lua/blocks/data.ts new file mode 100644 index 000000000..7b18dcd67 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/blocks/data.ts @@ -0,0 +1,36 @@ +import type { BlockDefinition } from '../types' + +export const dataBlocks: BlockDefinition[] = [ + { + type: 'set_variable', + label: 'Set variable', + description: 'Create or update a variable', + category: 'Data', + fields: [ + { + name: 'scope', + label: 'Scope', + type: 'select', + defaultValue: 'local', + options: [ + { label: 'local', value: 'local' }, + { label: 'global', value: 'global' }, + ], + }, + { + name: 'name', + label: 'Variable name', + placeholder: 'count', + type: 'text', + defaultValue: 'count', + }, + { + name: 'value', + label: 'Value', + placeholder: '0', + type: 'text', + defaultValue: '0', + }, + ], + }, +] diff --git a/frontends/nextjs/src/components/editors/lua/blocks/functions.ts b/frontends/nextjs/src/components/editors/lua/blocks/functions.ts new file mode 100644 index 000000000..2aaf364dd --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/blocks/functions.ts @@ -0,0 +1,26 @@ +import type { BlockDefinition } from '../types' + +export const functionBlocks: BlockDefinition[] = [ + { + type: 'call', + label: 'Call function', + description: 'Invoke a Lua function', + category: 'Functions', + fields: [ + { + name: 'function', + label: 'Function name', + placeholder: 'my_function', + type: 'text', + defaultValue: 'my_function', + }, + { + name: 'args', + label: 'Arguments', + placeholder: 'context.data', + type: 'text', + defaultValue: 'context.data', + }, + ], + }, +] diff --git a/frontends/nextjs/src/components/editors/lua/blocks/index.ts b/frontends/nextjs/src/components/editors/lua/blocks/index.ts new file mode 100644 index 000000000..33cf9167d --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/blocks/index.ts @@ -0,0 +1,33 @@ +import type { BlockCategory, BlockDefinition } from '../types' +import { basicBlocks } from './basics' +import { dataBlocks } from './data' +import { functionBlocks } from './functions' +import { logicBlocks } from './logic' +import { loopBlocks } from './loops' + +export const BLOCK_DEFINITIONS: BlockDefinition[] = [ + ...basicBlocks, + ...logicBlocks, + ...loopBlocks, + ...dataBlocks, + ...functionBlocks, +] + +const createCategoryIndex = (): Record => ({ + Basics: [], + Logic: [], + Loops: [], + Data: [], + Functions: [], +}) + +export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => { + const categories = createCategoryIndex() + definitions.forEach((definition) => { + categories[definition.category].push(definition) + }) + return categories +} + +export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) => + new Map(definitions.map((definition) => [definition.type, definition])) diff --git a/frontends/nextjs/src/components/editors/lua/blocks/logic.ts b/frontends/nextjs/src/components/editors/lua/blocks/logic.ts new file mode 100644 index 000000000..872b9249b --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/blocks/logic.ts @@ -0,0 +1,37 @@ +import type { BlockDefinition } from '../types' + +export const logicBlocks: BlockDefinition[] = [ + { + type: 'if', + label: 'If', + description: 'Run blocks when a condition is true', + category: 'Logic', + fields: [ + { + name: 'condition', + label: 'Condition', + placeholder: 'context.data.isActive', + type: 'text', + defaultValue: 'context.data.isActive', + }, + ], + hasChildren: true, + }, + { + type: 'if_else', + label: 'If / Else', + description: 'Branch execution with else fallback', + category: 'Logic', + fields: [ + { + name: 'condition', + label: 'Condition', + placeholder: 'context.data.count > 5', + type: 'text', + defaultValue: 'context.data.count > 5', + }, + ], + hasChildren: true, + hasElseChildren: true, + }, +] diff --git a/frontends/nextjs/src/components/editors/lua/blocks/loops.ts b/frontends/nextjs/src/components/editors/lua/blocks/loops.ts new file mode 100644 index 000000000..157c25f6b --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/blocks/loops.ts @@ -0,0 +1,27 @@ +import type { BlockDefinition } from '../types' + +export const loopBlocks: BlockDefinition[] = [ + { + type: 'repeat', + label: 'Repeat loop', + description: 'Run nested blocks multiple times', + category: 'Loops', + fields: [ + { + name: 'iterator', + label: 'Iterator', + placeholder: 'i', + type: 'text', + defaultValue: 'i', + }, + { + name: 'count', + label: 'Times', + placeholder: '3', + type: 'number', + defaultValue: '3', + }, + ], + hasChildren: true, + }, +] diff --git a/frontends/nextjs/src/components/editors/lua/hooks/luaBlockSerialization.ts b/frontends/nextjs/src/components/editors/lua/hooks/luaBlockSerialization.ts new file mode 100644 index 000000000..cb4aeff4a --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/hooks/luaBlockSerialization.ts @@ -0,0 +1,105 @@ +import type { LuaBlock } from '../types' + +export const BLOCKS_METADATA_PREFIX = '--@blocks ' + +const indent = (depth: number) => ' '.repeat(depth) + +const getFieldValue = (block: LuaBlock, fieldName: string, fallback: string) => { + const value = block.fields[fieldName] + if (value === undefined || value === null) return fallback + const normalized = String(value).trim() + return normalized.length > 0 ? normalized : fallback +} + +const renderBlocks = (blocks: LuaBlock[], depth: number, renderBlock: (block: LuaBlock, depth: number) => string) => + blocks + .map((block) => renderBlock(block, depth)) + .filter(Boolean) + .join('\n') + +const renderChildBlocks = ( + blocks: LuaBlock[] | undefined, + depth: number, + renderBlock: (block: LuaBlock, depth: number) => string +) => { + if (!blocks || blocks.length === 0) { + return `${indent(depth)}-- add blocks here` + } + return renderBlocks(blocks, depth, renderBlock) +} + +export const buildLuaFromBlocks = (blocks: LuaBlock[]) => { + const renderBlock = (block: LuaBlock, depth: number): string => { + switch (block.type) { + case 'log': { + const message = getFieldValue(block, 'message', '""') + return `${indent(depth)}log(${message})` + } + case 'set_variable': { + const scope = getFieldValue(block, 'scope', 'local') + const name = getFieldValue(block, 'name', 'value') + const value = getFieldValue(block, 'value', 'nil') + const keyword = scope === 'local' ? 'local ' : '' + return `${indent(depth)}${keyword}${name} = ${value}` + } + case 'if': { + const condition = getFieldValue(block, 'condition', 'true') + const body = renderChildBlocks(block.children, depth + 1, renderBlock) + return `${indent(depth)}if ${condition} then\n${body}\n${indent(depth)}end` + } + case 'if_else': { + const condition = getFieldValue(block, 'condition', 'true') + const thenBody = renderChildBlocks(block.children, depth + 1, renderBlock) + const elseBody = renderChildBlocks(block.elseChildren, depth + 1, renderBlock) + return `${indent(depth)}if ${condition} then\n${thenBody}\n${indent(depth)}else\n${elseBody}\n${indent(depth)}end` + } + case 'repeat': { + const iterator = getFieldValue(block, 'iterator', 'i') + const count = getFieldValue(block, 'count', '1') + const body = renderChildBlocks(block.children, depth + 1, renderBlock) + return `${indent(depth)}for ${iterator} = 1, ${count} do\n${body}\n${indent(depth)}end` + } + case 'return': { + const value = getFieldValue(block, 'value', 'nil') + return `${indent(depth)}return ${value}` + } + case 'call': { + const functionName = getFieldValue(block, 'function', 'my_function') + const args = getFieldValue(block, 'args', '') + const argsSection = args ? args : '' + return `${indent(depth)}${functionName}(${argsSection})` + } + case 'comment': { + const text = getFieldValue(block, 'text', '') + return `${indent(depth)}-- ${text}` + } + default: + return '' + } + } + + const metadata = `${BLOCKS_METADATA_PREFIX}${JSON.stringify({ version: 1, blocks })}` + const body = renderBlocks(blocks, 0, renderBlock) + if (!body.trim()) { + return `${metadata}\n-- empty block workspace\n` + } + return `${metadata}\n${body}\n` +} + +export const decodeBlocksMetadata = (code: string): LuaBlock[] | null => { + const metadataLine = code + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith(BLOCKS_METADATA_PREFIX)) + + if (!metadataLine) return null + + const json = metadataLine.slice(BLOCKS_METADATA_PREFIX.length) + try { + const parsed = JSON.parse(json) + if (!parsed || !Array.isArray(parsed.blocks)) return null + return parsed.blocks as LuaBlock[] + } catch { + return null + } +} diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useBlockDefinitions.test.ts b/frontends/nextjs/src/components/editors/lua/hooks/useBlockDefinitions.test.ts new file mode 100644 index 000000000..1db4308c0 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/hooks/useBlockDefinitions.test.ts @@ -0,0 +1,66 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useBlockDefinitions } from './useBlockDefinitions' +import { BLOCKS_METADATA_PREFIX, buildLuaFromBlocks, decodeBlocksMetadata } from './luaBlockSerialization' +import type { LuaBlock } from '../types' + +describe('useBlockDefinitions', () => { + it('aggregates block metadata by category', () => { + const { result } = renderHook(() => useBlockDefinitions()) + + expect(result.current.blockDefinitions).toHaveLength(8) + expect(result.current.blocksByCategory.Basics.map((block) => block.type)).toEqual( + expect.arrayContaining(['log', 'return', 'comment']) + ) + expect(result.current.blocksByCategory.Data.map((block) => block.type)).toEqual(['set_variable']) + expect(result.current.blocksByCategory.Logic.map((block) => block.type)).toEqual( + expect.arrayContaining(['if', 'if_else']) + ) + expect(result.current.blocksByCategory.Loops.map((block) => block.type)).toEqual(['repeat']) + expect(result.current.blocksByCategory.Functions.map((block) => block.type)).toEqual(['call']) + }) +}) + +describe('lua block serialization', () => { + const sampleBlocks: LuaBlock[] = [ + { + id: 'if-block', + type: 'if_else', + fields: { condition: 'context.data.count > 5' }, + children: [ + { + id: 'log-then', + type: 'log', + fields: { message: '"High count"' }, + }, + ], + elseChildren: [ + { + id: 'reset-count', + type: 'set_variable', + fields: { scope: 'local', name: 'count', value: '0' }, + }, + ], + }, + ] + + it('serializes Lua with metadata header', () => { + const lua = buildLuaFromBlocks(sampleBlocks) + + expect(lua.startsWith(BLOCKS_METADATA_PREFIX)).toBe(true) + expect(lua).toContain('if context.data.count > 5 then') + expect(lua).toContain('log("High count")') + expect(lua).toContain('local count = 0') + }) + + it('round-trips block metadata through serialization', () => { + const lua = buildLuaFromBlocks(sampleBlocks) + const parsed = decodeBlocksMetadata(lua) + + expect(parsed).toEqual(sampleBlocks) + }) + + it('returns null when metadata is missing', () => { + expect(decodeBlocksMetadata('-- some lua code without metadata')).toBeNull() + }) +}) diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useBlockDefinitions.ts b/frontends/nextjs/src/components/editors/lua/hooks/useBlockDefinitions.ts index e67ebe916..c729bc936 100644 --- a/frontends/nextjs/src/components/editors/lua/hooks/useBlockDefinitions.ts +++ b/frontends/nextjs/src/components/editors/lua/hooks/useBlockDefinitions.ts @@ -1,196 +1,22 @@ import { useCallback, useMemo } from 'react' +import { BLOCK_DEFINITIONS, buildBlockDefinitionMap, groupBlockDefinitionsByCategory } from '../blocks' import type { BlockCategory, BlockDefinition, LuaBlock, LuaBlockType } from '../types' - -const BLOCKS_METADATA_PREFIX = '--@blocks ' - -const BLOCK_DEFINITIONS: BlockDefinition[] = [ - { - type: 'log', - label: 'Log message', - description: 'Send a message to the Lua console', - category: 'Basics', - fields: [ - { - name: 'message', - label: 'Message', - placeholder: '"Hello from Lua"', - type: 'text', - defaultValue: '"Hello from Lua"', - }, - ], - }, - { - type: 'set_variable', - label: 'Set variable', - description: 'Create or update a variable', - category: 'Data', - fields: [ - { - name: 'scope', - label: 'Scope', - type: 'select', - defaultValue: 'local', - options: [ - { label: 'local', value: 'local' }, - { label: 'global', value: 'global' }, - ], - }, - { - name: 'name', - label: 'Variable name', - placeholder: 'count', - type: 'text', - defaultValue: 'count', - }, - { - name: 'value', - label: 'Value', - placeholder: '0', - type: 'text', - defaultValue: '0', - }, - ], - }, - { - type: 'if', - label: 'If', - description: 'Run blocks when a condition is true', - category: 'Logic', - fields: [ - { - name: 'condition', - label: 'Condition', - placeholder: 'context.data.isActive', - type: 'text', - defaultValue: 'context.data.isActive', - }, - ], - hasChildren: true, - }, - { - type: 'if_else', - label: 'If / Else', - description: 'Branch execution with else fallback', - category: 'Logic', - fields: [ - { - name: 'condition', - label: 'Condition', - placeholder: 'context.data.count > 5', - type: 'text', - defaultValue: 'context.data.count > 5', - }, - ], - hasChildren: true, - hasElseChildren: true, - }, - { - type: 'repeat', - label: 'Repeat loop', - description: 'Run nested blocks multiple times', - category: 'Loops', - fields: [ - { - name: 'iterator', - label: 'Iterator', - placeholder: 'i', - type: 'text', - defaultValue: 'i', - }, - { - name: 'count', - label: 'Times', - placeholder: '3', - type: 'number', - defaultValue: '3', - }, - ], - hasChildren: true, - }, - { - type: 'call', - label: 'Call function', - description: 'Invoke a Lua function', - category: 'Functions', - fields: [ - { - name: 'function', - label: 'Function name', - placeholder: 'my_function', - type: 'text', - defaultValue: 'my_function', - }, - { - name: 'args', - label: 'Arguments', - placeholder: 'context.data', - type: 'text', - defaultValue: 'context.data', - }, - ], - }, - { - type: 'return', - label: 'Return', - description: 'Return a value from the script', - category: 'Basics', - fields: [ - { - name: 'value', - label: 'Value', - placeholder: 'true', - type: 'text', - defaultValue: 'true', - }, - ], - }, - { - type: 'comment', - label: 'Comment', - description: 'Add a comment to explain a step', - category: 'Basics', - fields: [ - { - name: 'text', - label: 'Comment', - placeholder: 'Explain what happens here', - type: 'text', - defaultValue: 'Explain what happens here', - }, - ], - }, -] +import { buildLuaFromBlocks as serializeBlocks, decodeBlocksMetadata as parseBlocksMetadata } from './luaBlockSerialization' const createBlockId = () => `block_${Date.now()}_${Math.random().toString(16).slice(2)}` -const indent = (depth: number) => ' '.repeat(depth) - -const renderBlocks = (blocks: LuaBlock[], depth: number, renderBlock: (block: LuaBlock, depth: number) => string) => - blocks - .map((block) => renderBlock(block, depth)) - .filter(Boolean) - .join('\n') - export function useBlockDefinitions() { + const blockDefinitions = useMemo(() => BLOCK_DEFINITIONS, []) + const blockDefinitionMap = useMemo( - () => new Map(BLOCK_DEFINITIONS.map((definition) => [definition.type, definition])), - [] + () => buildBlockDefinitionMap(blockDefinitions), + [blockDefinitions] ) - const blocksByCategory = useMemo>(() => { - const initial: Record = { - Basics: [], - Logic: [], - Loops: [], - Data: [], - Functions: [], - } - - return BLOCK_DEFINITIONS.reduce((acc, definition) => { - acc[definition.category] = [...(acc[definition.category] || []), definition] - return acc - }, initial) - }, []) + const blocksByCategory = useMemo>( + () => groupBlockDefinitionsByCategory(blockDefinitions), + [blockDefinitions] + ) const createBlock = useCallback( (type: LuaBlockType): LuaBlock => { @@ -226,104 +52,12 @@ export function useBlockDefinitions() { [] ) - const getFieldValue = useCallback((block: LuaBlock, fieldName: string, fallback: string) => { - const value = block.fields[fieldName] - if (value === undefined || value === null) return fallback - const normalized = String(value).trim() - return normalized.length > 0 ? normalized : fallback - }, []) + const buildLuaFromBlocks = useCallback((blocks: LuaBlock[]) => serializeBlocks(blocks), []) - const renderChildBlocks = useCallback( - (blocks: LuaBlock[] | undefined, depth: number, renderBlock: (block: LuaBlock, depth: number) => string) => { - if (!blocks || blocks.length === 0) { - return `${indent(depth)}-- add blocks here` - } - return renderBlocks(blocks, depth, renderBlock) - }, - [] - ) - - const buildLuaFromBlocks = useCallback( - (blocks: LuaBlock[]) => { - const renderBlock = (block: LuaBlock, depth: number): string => { - switch (block.type) { - case 'log': { - const message = getFieldValue(block, 'message', '""') - return `${indent(depth)}log(${message})` - } - case 'set_variable': { - const scope = getFieldValue(block, 'scope', 'local') - const name = getFieldValue(block, 'name', 'value') - const value = getFieldValue(block, 'value', 'nil') - const keyword = scope === 'local' ? 'local ' : '' - return `${indent(depth)}${keyword}${name} = ${value}` - } - case 'if': { - const condition = getFieldValue(block, 'condition', 'true') - const body = renderChildBlocks(block.children, depth + 1, renderBlock) - return `${indent(depth)}if ${condition} then\n${body}\n${indent(depth)}end` - } - case 'if_else': { - const condition = getFieldValue(block, 'condition', 'true') - const thenBody = renderChildBlocks(block.children, depth + 1, renderBlock) - const elseBody = renderChildBlocks(block.elseChildren, depth + 1, renderBlock) - return `${indent(depth)}if ${condition} then\n${thenBody}\n${indent(depth)}else\n${elseBody}\n${indent(depth)}end` - } - case 'repeat': { - const iterator = getFieldValue(block, 'iterator', 'i') - const count = getFieldValue(block, 'count', '1') - const body = renderChildBlocks(block.children, depth + 1, renderBlock) - return `${indent(depth)}for ${iterator} = 1, ${count} do\n${body}\n${indent(depth)}end` - } - case 'return': { - const value = getFieldValue(block, 'value', 'nil') - return `${indent(depth)}return ${value}` - } - case 'call': { - const functionName = getFieldValue(block, 'function', 'my_function') - const args = getFieldValue(block, 'args', '') - const argsSection = args ? args : '' - return `${indent(depth)}${functionName}(${argsSection})` - } - case 'comment': { - const text = getFieldValue(block, 'text', '') - return `${indent(depth)}-- ${text}` - } - default: - return '' - } - } - - const metadata = `${BLOCKS_METADATA_PREFIX}${JSON.stringify({ version: 1, blocks })}` - const body = renderBlocks(blocks, 0, renderBlock) - if (!body.trim()) { - return `${metadata}\n-- empty block workspace\n` - } - return `${metadata}\n${body}\n` - }, - [getFieldValue, renderChildBlocks] - ) - - const decodeBlocksMetadata = useCallback((code: string): LuaBlock[] | null => { - const metadataLine = code - .split('\n') - .map((line) => line.trim()) - .find((line) => line.startsWith(BLOCKS_METADATA_PREFIX)) - - if (!metadataLine) return null - - const json = metadataLine.slice(BLOCKS_METADATA_PREFIX.length) - try { - const parsed = JSON.parse(json) - if (!parsed || !Array.isArray(parsed.blocks)) return null - return parsed.blocks as LuaBlock[] - } catch { - return null - } - }, []) + const decodeBlocksMetadata = useCallback((code: string) => parseBlocksMetadata(code), []) return { - blockDefinitions: BLOCK_DEFINITIONS, + blockDefinitions, blockDefinitionMap, blocksByCategory, createBlock,