mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 22:04:56 +00:00
Merge branch 'main' into codex/refactor-pagination-components-and-utilities
This commit is contained in:
10
frontends/nextjs/package-lock.json
generated
10
frontends/nextjs/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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<ThemeMode>('system')
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const resolvedMode = useMemo<Exclude<ThemeMode, 'system'>>(() => {
|
||||
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 (
|
||||
<ThemeContext.Provider value={{ mode, setMode, toggleTheme }}>
|
||||
<ThemeContext.Provider value={{ mode, resolvedMode, setMode, toggleTheme }}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
export interface ThemeContextType {
|
||||
mode: ThemeMode
|
||||
resolvedMode: Exclude<ThemeMode, 'system'>
|
||||
setMode: (mode: ThemeMode) => void
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
49
frontends/nextjs/src/components/editors/lua/blocks/basics.ts
Normal file
49
frontends/nextjs/src/components/editors/lua/blocks/basics.ts
Normal file
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
36
frontends/nextjs/src/components/editors/lua/blocks/data.ts
Normal file
36
frontends/nextjs/src/components/editors/lua/blocks/data.ts
Normal file
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
33
frontends/nextjs/src/components/editors/lua/blocks/index.ts
Normal file
33
frontends/nextjs/src/components/editors/lua/blocks/index.ts
Normal file
@@ -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<BlockCategory, BlockDefinition[]> => ({
|
||||
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]))
|
||||
37
frontends/nextjs/src/components/editors/lua/blocks/logic.ts
Normal file
37
frontends/nextjs/src/components/editors/lua/blocks/logic.ts
Normal file
@@ -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,
|
||||
},
|
||||
]
|
||||
27
frontends/nextjs/src/components/editors/lua/blocks/loops.ts
Normal file
27
frontends/nextjs/src/components/editors/lua/blocks/loops.ts
Normal file
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<LuaBlockType, BlockDefinition>(BLOCK_DEFINITIONS.map((definition) => [definition.type, definition])),
|
||||
[]
|
||||
() => buildBlockDefinitionMap(blockDefinitions),
|
||||
[blockDefinitions]
|
||||
)
|
||||
|
||||
const blocksByCategory = useMemo<Record<BlockCategory, BlockDefinition[]>>(() => {
|
||||
const initial: Record<BlockCategory, BlockDefinition[]> = {
|
||||
Basics: [],
|
||||
Logic: [],
|
||||
Loops: [],
|
||||
Data: [],
|
||||
Functions: [],
|
||||
}
|
||||
|
||||
return BLOCK_DEFINITIONS.reduce((acc, definition) => {
|
||||
acc[definition.category] = [...(acc[definition.category] || []), definition]
|
||||
return acc
|
||||
}, initial)
|
||||
}, [])
|
||||
const blocksByCategory = useMemo<Record<BlockCategory, BlockDefinition[]>>(
|
||||
() => 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,
|
||||
|
||||
@@ -1,333 +1,156 @@
|
||||
import { useState, useEffect, useCallback, useId } from 'react'
|
||||
import { useCallback, useId, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import {
|
||||
Tree,
|
||||
Plus,
|
||||
Trash,
|
||||
GearSix,
|
||||
ArrowsOutCardinal,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
Cursor
|
||||
import {
|
||||
ArrowsOutCardinal,
|
||||
Cursor,
|
||||
Plus,
|
||||
Tree,
|
||||
} from '@phosphor-icons/react'
|
||||
import { Database, ComponentNode } from '@/lib/database'
|
||||
import { Database, type ComponentNode } from '@/lib/database'
|
||||
import { componentCatalog } from '@/lib/components/component-catalog'
|
||||
import { toast } from 'sonner'
|
||||
import type { PageConfig } from '@/lib/level-types'
|
||||
import { ComponentConfigDialog } from './ComponentConfigDialog'
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: ComponentNode
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
selectedNodeId: string | null
|
||||
expandedNodes: Set<string>
|
||||
onSelect: (nodeId: string) => void
|
||||
onToggle: (nodeId: string) => void
|
||||
onDelete: (nodeId: string) => void
|
||||
onConfig: (nodeId: string) => void
|
||||
onDragStart: (nodeId: string) => void
|
||||
onDragOver: (e: React.DragEvent, nodeId: string) => void
|
||||
onDrop: (e: React.DragEvent, targetNodeId: string) => void
|
||||
draggingNodeId: string | null
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
hierarchy,
|
||||
selectedNodeId,
|
||||
expandedNodes,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onConfig,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
draggingNodeId,
|
||||
}: TreeNodeProps) {
|
||||
const hasChildren = node.childIds.length > 0
|
||||
const isExpanded = expandedNodes.has(node.id)
|
||||
const isSelected = selectedNodeId === node.id
|
||||
const isDragging = draggingNodeId === node.id
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === node.type)
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => onDragStart(node.id)}
|
||||
onDragOver={(e) => onDragOver(e, node.id)}
|
||||
onDrop={(e) => onDrop(e, node.id)}
|
||||
className={`
|
||||
flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer
|
||||
hover:bg-accent transition-colors group
|
||||
${isSelected ? 'bg-accent' : ''}
|
||||
${isDragging ? 'opacity-50' : ''}
|
||||
`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(node.id)
|
||||
}}
|
||||
className="hover:bg-secondary rounded p-0.5"
|
||||
>
|
||||
{isExpanded ? <CaretDown size={14} /> : <CaretRight size={14} />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[14px]" />
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Tree size={16} />
|
||||
</div>
|
||||
|
||||
<span className="flex-1 text-sm font-medium">{node.type}</span>
|
||||
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.order}
|
||||
</Badge>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfig(node.id)
|
||||
}}
|
||||
>
|
||||
<GearSix size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(node.id)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="ml-4 border-l border-border pl-2">
|
||||
{node.childIds
|
||||
.sort((a, b) => hierarchy[a].order - hierarchy[b].order)
|
||||
.map((childId) => (
|
||||
<TreeNode
|
||||
key={childId}
|
||||
node={hierarchy[childId]}
|
||||
hierarchy={hierarchy}
|
||||
selectedNodeId={selectedNodeId}
|
||||
expandedNodes={expandedNodes}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onConfig={onConfig}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
draggingNodeId={draggingNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { TreeNode } from './modules/TreeNode'
|
||||
import { useHierarchyData } from './modules/useHierarchyData'
|
||||
import { useHierarchyDragDrop } from './modules/useHierarchyDragDrop'
|
||||
|
||||
export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: boolean }) {
|
||||
const [pages, setPages] = useState<PageConfig[]>([])
|
||||
const [selectedPageId, setSelectedPageId] = useState<string>('')
|
||||
const [hierarchy, setHierarchy] = useState<Record<string, ComponentNode>>({})
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
|
||||
const { pages, selectedPageId, setSelectedPageId, hierarchy, loadHierarchy } = useHierarchyData()
|
||||
const {
|
||||
selectedNodeId,
|
||||
setSelectedNodeId,
|
||||
expandedNodes,
|
||||
draggingNodeId,
|
||||
handleToggleNode,
|
||||
handleExpandAll,
|
||||
handleCollapseAll,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
expandNode,
|
||||
} = useHierarchyDragDrop({ hierarchy, loadHierarchy })
|
||||
const [configNodeId, setConfigNodeId] = useState<string | null>(null)
|
||||
const componentIdPrefix = useId()
|
||||
|
||||
const loadPages = useCallback(async () => {
|
||||
const loadedPages = await Database.getPages()
|
||||
setPages(loadedPages)
|
||||
if (loadedPages.length > 0 && !selectedPageId) {
|
||||
setSelectedPageId(loadedPages[0].id)
|
||||
}
|
||||
}, [selectedPageId])
|
||||
const rootNodes = useMemo(
|
||||
() =>
|
||||
Object.values(hierarchy)
|
||||
.filter(node => node.pageId === selectedPageId && !node.parentId)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[hierarchy, selectedPageId]
|
||||
)
|
||||
|
||||
const loadHierarchy = useCallback(async () => {
|
||||
const allHierarchy = await Database.getComponentHierarchy()
|
||||
setHierarchy(allHierarchy)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPages()
|
||||
loadHierarchy()
|
||||
}, [loadPages, loadHierarchy])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPageId) {
|
||||
loadHierarchy()
|
||||
}
|
||||
}, [selectedPageId, loadHierarchy])
|
||||
|
||||
const getRootNodes = () => {
|
||||
return Object.values(hierarchy)
|
||||
.filter(node => node.pageId === selectedPageId && !node.parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
const handleAddComponent = async (componentType: string, parentId?: string) => {
|
||||
if (!selectedPageId) {
|
||||
toast.error('Please select a page first')
|
||||
return
|
||||
}
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === componentType)
|
||||
if (!componentDef) return
|
||||
|
||||
const newNode: ComponentNode = {
|
||||
id: `node_${componentIdPrefix}_${Object.keys(hierarchy).length}`,
|
||||
type: componentType,
|
||||
parentId: parentId,
|
||||
childIds: [],
|
||||
order: parentId
|
||||
? hierarchy[parentId]?.childIds.length || 0
|
||||
: getRootNodes().length,
|
||||
pageId: selectedPageId,
|
||||
}
|
||||
|
||||
if (parentId && hierarchy[parentId]) {
|
||||
await Database.updateComponentNode(parentId, {
|
||||
childIds: [...hierarchy[parentId].childIds, newNode.id],
|
||||
})
|
||||
}
|
||||
|
||||
await Database.addComponentNode(newNode)
|
||||
await loadHierarchy()
|
||||
setExpandedNodes(prev => new Set([...prev, parentId || '']))
|
||||
toast.success(`Added ${componentType}`)
|
||||
}
|
||||
|
||||
const handleDeleteNode = async (nodeId: string) => {
|
||||
if (!confirm('Delete this component and all its children?')) return
|
||||
|
||||
const node = hierarchy[nodeId]
|
||||
if (!node) return
|
||||
|
||||
const deleteRecursive = async (id: string) => {
|
||||
const n = hierarchy[id]
|
||||
if (!n) return
|
||||
|
||||
for (const childId of n.childIds) {
|
||||
await deleteRecursive(childId)
|
||||
const handleAddComponent = useCallback(
|
||||
async (componentType: string, parentId?: string) => {
|
||||
if (!selectedPageId) {
|
||||
toast.error('Please select a page first')
|
||||
return
|
||||
}
|
||||
await Database.deleteComponentNode(id)
|
||||
}
|
||||
|
||||
if (node.parentId && hierarchy[node.parentId]) {
|
||||
const parent = hierarchy[node.parentId]
|
||||
await Database.updateComponentNode(node.parentId, {
|
||||
childIds: parent.childIds.filter(id => id !== nodeId),
|
||||
})
|
||||
}
|
||||
const componentDef = componentCatalog.find(c => c.type === componentType)
|
||||
if (!componentDef) return
|
||||
|
||||
await deleteRecursive(nodeId)
|
||||
await loadHierarchy()
|
||||
toast.success('Component deleted')
|
||||
}
|
||||
|
||||
const handleToggleNode = (nodeId: string) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId)
|
||||
} else {
|
||||
next.add(nodeId)
|
||||
const newNode: ComponentNode = {
|
||||
id: `node_${componentIdPrefix}_${Object.keys(hierarchy).length}`,
|
||||
type: componentType,
|
||||
parentId: parentId,
|
||||
childIds: [],
|
||||
order: parentId ? hierarchy[parentId]?.childIds.length || 0 : rootNodes.length,
|
||||
pageId: selectedPageId,
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragStart = (nodeId: string) => {
|
||||
setDraggingNodeId(nodeId)
|
||||
}
|
||||
if (parentId && hierarchy[parentId]) {
|
||||
await Database.updateComponentNode(parentId, {
|
||||
childIds: [...hierarchy[parentId].childIds, newNode.id],
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, nodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
await Database.addComponentNode(newNode)
|
||||
await loadHierarchy()
|
||||
expandNode(parentId)
|
||||
toast.success(`Added ${componentType}`)
|
||||
},
|
||||
[componentIdPrefix, expandNode, hierarchy, loadHierarchy, rootNodes.length, selectedPageId]
|
||||
)
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetNodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const handleDeleteNode = useCallback(
|
||||
async (nodeId: string) => {
|
||||
if (!confirm('Delete this component and all its children?')) return
|
||||
|
||||
if (!draggingNodeId || draggingNodeId === targetNodeId) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
const node = hierarchy[nodeId]
|
||||
if (!node) return
|
||||
|
||||
const draggedNode = hierarchy[draggingNodeId]
|
||||
const targetNode = hierarchy[targetNodeId]
|
||||
const deleteRecursive = async (id: string) => {
|
||||
const n = hierarchy[id]
|
||||
if (!n) return
|
||||
|
||||
if (!draggedNode || !targetNode) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
for (const childId of n.childIds) {
|
||||
await deleteRecursive(childId)
|
||||
}
|
||||
await Database.deleteComponentNode(id)
|
||||
}
|
||||
|
||||
if (targetNode.childIds.includes(draggingNodeId)) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
if (node.parentId && hierarchy[node.parentId]) {
|
||||
const parent = hierarchy[node.parentId]
|
||||
await Database.updateComponentNode(node.parentId, {
|
||||
childIds: parent.childIds.filter(id => id !== nodeId),
|
||||
})
|
||||
}
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === targetNode.type)
|
||||
if (!componentDef?.allowsChildren) {
|
||||
toast.error(`${targetNode.type} cannot contain children`)
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
await deleteRecursive(nodeId)
|
||||
await loadHierarchy()
|
||||
toast.success('Component deleted')
|
||||
},
|
||||
[hierarchy, loadHierarchy]
|
||||
)
|
||||
|
||||
if (draggedNode.parentId) {
|
||||
const oldParent = hierarchy[draggedNode.parentId]
|
||||
await Database.updateComponentNode(draggedNode.parentId, {
|
||||
childIds: oldParent.childIds.filter(id => id !== draggingNodeId),
|
||||
})
|
||||
}
|
||||
|
||||
await Database.updateComponentNode(targetNodeId, {
|
||||
childIds: [...targetNode.childIds, draggingNodeId],
|
||||
})
|
||||
|
||||
await Database.updateComponentNode(draggingNodeId, {
|
||||
parentId: targetNodeId,
|
||||
order: targetNode.childIds.length,
|
||||
})
|
||||
|
||||
setDraggingNodeId(null)
|
||||
setExpandedNodes(prev => new Set([...prev, targetNodeId]))
|
||||
await loadHierarchy()
|
||||
toast.success('Component moved')
|
||||
}
|
||||
|
||||
const handleExpandAll = () => {
|
||||
setExpandedNodes(new Set(Object.keys(hierarchy)))
|
||||
}
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedNodes(new Set())
|
||||
}
|
||||
const renderTree = useMemo(
|
||||
() =>
|
||||
rootNodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<Cursor size={48} className="mb-4" />
|
||||
<p>No components yet. Add one from the catalog!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{rootNodes.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
hierarchy={hierarchy}
|
||||
selectedNodeId={selectedNodeId}
|
||||
expandedNodes={expandedNodes}
|
||||
onSelect={setSelectedNodeId}
|
||||
onToggle={handleToggleNode}
|
||||
onDelete={handleDeleteNode}
|
||||
onConfig={setConfigNodeId}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
draggingNodeId={draggingNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
expandedNodes,
|
||||
handleDeleteNode,
|
||||
handleDragOver,
|
||||
handleDragStart,
|
||||
handleDrop,
|
||||
handleToggleNode,
|
||||
hierarchy,
|
||||
rootNodes,
|
||||
selectedNodeId,
|
||||
draggingNodeId,
|
||||
setConfigNodeId,
|
||||
setSelectedNodeId,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-12rem)]">
|
||||
@@ -368,32 +191,7 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
|
||||
<CardContent className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full pr-4">
|
||||
{selectedPageId ? (
|
||||
getRootNodes().length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<Cursor size={48} className="mb-4" />
|
||||
<p>No components yet. Add one from the catalog!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{getRootNodes().map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
hierarchy={hierarchy}
|
||||
selectedNodeId={selectedNodeId}
|
||||
expandedNodes={expandedNodes}
|
||||
onSelect={setSelectedNodeId}
|
||||
onToggle={handleToggleNode}
|
||||
onDelete={handleDeleteNode}
|
||||
onConfig={setConfigNodeId}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
draggingNodeId={draggingNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
renderTree
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<p>Select a page to edit its component hierarchy</p>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type React from 'react'
|
||||
import { CaretDown, CaretRight, GearSix, Trash, Tree } from '@phosphor-icons/react'
|
||||
import { Badge, Button } from '@/components/ui'
|
||||
import { componentCatalog } from '@/lib/components/component-catalog'
|
||||
import type { ComponentNode } from '@/lib/database'
|
||||
|
||||
export interface TreeNodeProps {
|
||||
node: ComponentNode
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
selectedNodeId: string | null
|
||||
expandedNodes: Set<string>
|
||||
onSelect: (nodeId: string) => void
|
||||
onToggle: (nodeId: string) => void
|
||||
onDelete: (nodeId: string) => void
|
||||
onConfig: (nodeId: string) => void
|
||||
onDragStart: (nodeId: string) => void
|
||||
onDragOver: (e: React.DragEvent, nodeId: string) => void
|
||||
onDrop: (e: React.DragEvent, targetNodeId: string) => void
|
||||
draggingNodeId: string | null
|
||||
}
|
||||
|
||||
export function TreeNode({
|
||||
node,
|
||||
hierarchy,
|
||||
selectedNodeId,
|
||||
expandedNodes,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onConfig,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
draggingNodeId,
|
||||
}: TreeNodeProps) {
|
||||
const hasChildren = node.childIds.length > 0
|
||||
const isExpanded = expandedNodes.has(node.id)
|
||||
const isSelected = selectedNodeId === node.id
|
||||
const isDragging = draggingNodeId === node.id
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === node.type)
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => onDragStart(node.id)}
|
||||
onDragOver={(e) => onDragOver(e, node.id)}
|
||||
onDrop={(e) => onDrop(e, node.id)}
|
||||
className={`
|
||||
flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer
|
||||
hover:bg-accent transition-colors group
|
||||
${isSelected ? 'bg-accent' : ''}
|
||||
${isDragging ? 'opacity-50' : ''}
|
||||
`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(node.id)
|
||||
}}
|
||||
className="hover:bg-secondary rounded p-0.5"
|
||||
>
|
||||
{isExpanded ? <CaretDown size={14} /> : <CaretRight size={14} />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[14px]" />
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Tree size={16} />
|
||||
</div>
|
||||
|
||||
<span className="flex-1 text-sm font-medium">{node.type}</span>
|
||||
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.order}
|
||||
</Badge>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfig(node.id)
|
||||
}}
|
||||
>
|
||||
<GearSix size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(node.id)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{componentDef?.allowsChildren && (
|
||||
<div className="text-xs text-muted-foreground">can nest</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="pl-6 space-y-1">
|
||||
{node.childIds
|
||||
.map((childId) => hierarchy[childId])
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
hierarchy={hierarchy}
|
||||
selectedNodeId={selectedNodeId}
|
||||
expandedNodes={expandedNodes}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onConfig={onConfig}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
draggingNodeId={draggingNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Database, type ComponentNode } from '@/lib/database'
|
||||
import type { PageConfig } from '@/lib/level-types'
|
||||
|
||||
interface UseHierarchyDataResult {
|
||||
pages: PageConfig[]
|
||||
selectedPageId: string
|
||||
setSelectedPageId: (pageId: string) => void
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
loadHierarchy: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useHierarchyData(): UseHierarchyDataResult {
|
||||
const [pages, setPages] = useState<PageConfig[]>([])
|
||||
const [selectedPageId, setSelectedPageId] = useState<string>('')
|
||||
const [hierarchy, setHierarchy] = useState<Record<string, ComponentNode>>({})
|
||||
|
||||
const loadPages = useCallback(async () => {
|
||||
const loadedPages = await Database.getPages()
|
||||
setPages(loadedPages)
|
||||
if (loadedPages.length > 0 && !selectedPageId) {
|
||||
setSelectedPageId(loadedPages[0].id)
|
||||
}
|
||||
}, [selectedPageId])
|
||||
|
||||
const loadHierarchy = useCallback(async () => {
|
||||
const allHierarchy = await Database.getComponentHierarchy()
|
||||
setHierarchy(allHierarchy)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPages()
|
||||
loadHierarchy()
|
||||
}, [loadPages, loadHierarchy])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPageId) {
|
||||
loadHierarchy()
|
||||
}
|
||||
}, [selectedPageId, loadHierarchy])
|
||||
|
||||
return {
|
||||
pages,
|
||||
selectedPageId,
|
||||
setSelectedPageId,
|
||||
hierarchy,
|
||||
loadHierarchy,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { componentCatalog } from '@/lib/components/component-catalog'
|
||||
import { Database, type ComponentNode } from '@/lib/database'
|
||||
|
||||
interface UseHierarchyDragDropProps {
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
loadHierarchy: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useHierarchyDragDrop({ hierarchy, loadHierarchy }: UseHierarchyDragDropProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
|
||||
|
||||
const handleToggleNode = useCallback((nodeId: string) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId)
|
||||
} else {
|
||||
next.add(nodeId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleExpandAll = useCallback(() => {
|
||||
setExpandedNodes(new Set(Object.keys(hierarchy)))
|
||||
}, [hierarchy])
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setExpandedNodes(new Set())
|
||||
}, [])
|
||||
|
||||
const handleDragStart = useCallback((nodeId: string) => {
|
||||
setDraggingNodeId(nodeId)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, nodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setExpandedNodes(prev => new Set([...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent, targetNodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!draggingNodeId || draggingNodeId === targetNodeId) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const draggedNode = hierarchy[draggingNodeId]
|
||||
const targetNode = hierarchy[targetNodeId]
|
||||
|
||||
if (!draggedNode || !targetNode) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (targetNode.childIds.includes(draggingNodeId)) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === targetNode.type)
|
||||
if (!componentDef?.allowsChildren) {
|
||||
toast.error(`${targetNode.type} cannot contain children`)
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (draggedNode.parentId) {
|
||||
const oldParent = hierarchy[draggedNode.parentId]
|
||||
await Database.updateComponentNode(draggedNode.parentId, {
|
||||
childIds: oldParent.childIds.filter(id => id !== draggingNodeId),
|
||||
})
|
||||
}
|
||||
|
||||
await Database.updateComponentNode(targetNodeId, {
|
||||
childIds: [...targetNode.childIds, draggingNodeId],
|
||||
})
|
||||
|
||||
await Database.updateComponentNode(draggingNodeId, {
|
||||
parentId: targetNodeId,
|
||||
order: targetNode.childIds.length,
|
||||
})
|
||||
|
||||
setDraggingNodeId(null)
|
||||
setExpandedNodes(prev => new Set([...prev, targetNodeId]))
|
||||
await loadHierarchy()
|
||||
toast.success('Component moved')
|
||||
},
|
||||
[draggingNodeId, hierarchy, loadHierarchy]
|
||||
)
|
||||
|
||||
const expandNode = useCallback((nodeId?: string | null) => {
|
||||
if (!nodeId) return
|
||||
setExpandedNodes(prev => new Set([...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedNodeId,
|
||||
setSelectedNodeId,
|
||||
expandedNodes,
|
||||
draggingNodeId,
|
||||
handleToggleNode,
|
||||
handleExpandAll,
|
||||
handleCollapseAll,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
expandNode,
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,44 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { useState } from 'react'
|
||||
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator } from '@/components/ui'
|
||||
import { toast } from 'sonner'
|
||||
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/packages/core/package-catalog'
|
||||
import type { PackageManifest, InstalledPackage } from '@/lib/package-types'
|
||||
import { installPackage, listInstalledPackages, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
|
||||
import { Package, Download, Trash, Power, MagnifyingGlass, Star, Tag, User, TrendUp, Funnel, Export, ArrowSquareIn } from '@phosphor-icons/react'
|
||||
import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
|
||||
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
|
||||
import { ArrowSquareIn, Download, Export, Package, Star, Tag, Trash, User } from '@phosphor-icons/react'
|
||||
import { PackageImportExport } from './PackageImportExport'
|
||||
import { PackageFilters } from './package-manager/PackageFilters'
|
||||
import { PackageTabs } from './package-manager/PackageTabs'
|
||||
import { usePackages } from './package-manager/usePackages'
|
||||
|
||||
interface PackageManagerProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
const [packages, setPackages] = useState<PackageManifest[]>([])
|
||||
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
|
||||
const {
|
||||
filteredPackages,
|
||||
installedList,
|
||||
availableList,
|
||||
installedPackages,
|
||||
categories,
|
||||
searchQuery,
|
||||
categoryFilter,
|
||||
sortBy,
|
||||
setSearchQuery,
|
||||
setCategoryFilter,
|
||||
setSortBy,
|
||||
loadPackages,
|
||||
getCatalogEntry,
|
||||
} = usePackages()
|
||||
const [selectedPackage, setSelectedPackage] = useState<PackageCatalogData | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'downloads' | 'rating'>('downloads')
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [showImportExport, setShowImportExport] = useState(false)
|
||||
const [importExportMode, setImportExportMode] = useState<'import' | 'export'>('export')
|
||||
|
||||
useEffect(() => {
|
||||
loadPackages()
|
||||
}, [])
|
||||
|
||||
const loadPackages = async () => {
|
||||
const installed = await listInstalledPackages()
|
||||
setInstalledPackages(installed)
|
||||
|
||||
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => {
|
||||
const packageData = pkg()
|
||||
|
||||
return {
|
||||
...packageData.manifest,
|
||||
installed: installed.some(ip => ip.packageId === packageData.manifest.id),
|
||||
}
|
||||
})
|
||||
|
||||
setPackages(allPackages)
|
||||
}
|
||||
|
||||
const handleInstallPackage = async (packageId: string) => {
|
||||
setInstalling(true)
|
||||
try {
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]?.()
|
||||
const packageEntry = getCatalogEntry(packageId)
|
||||
if (!packageEntry) {
|
||||
toast.error('Package not found')
|
||||
return
|
||||
@@ -75,7 +59,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
|
||||
const handleUninstallPackage = async (packageId: string) => {
|
||||
try {
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]?.()
|
||||
const packageEntry = getCatalogEntry(packageId)
|
||||
if (!packageEntry) {
|
||||
toast.error('Package not found')
|
||||
return
|
||||
@@ -103,28 +87,18 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPackages = packages
|
||||
.filter(pkg => {
|
||||
const matchesSearch =
|
||||
pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
pkg.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
pkg.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
const openPackageDetails = (packageId: string) => {
|
||||
const catalogEntry = getCatalogEntry(packageId)
|
||||
if (!catalogEntry) return
|
||||
|
||||
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
|
||||
const installedPackage = installedPackages.find(pkg => pkg.packageId === packageId)
|
||||
|
||||
return matchesSearch && matchesCategory
|
||||
setSelectedPackage({
|
||||
...catalogEntry,
|
||||
manifest: { ...catalogEntry.manifest, installed: Boolean(installedPackage) },
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name)
|
||||
if (sortBy === 'downloads') return b.downloadCount - a.downloadCount
|
||||
if (sortBy === 'rating') return b.rating - a.rating
|
||||
return 0
|
||||
})
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(packages.map(p => p.category)))]
|
||||
|
||||
const installedList = packages.filter(p => p.installed)
|
||||
const availableList = packages.filter(p => !p.installed)
|
||||
setShowDetails(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -139,8 +113,8 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setImportExportMode('import')
|
||||
setShowImportExport(true)
|
||||
@@ -149,8 +123,8 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
<ArrowSquareIn size={16} className="mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setImportExportMode('export')
|
||||
setShowImportExport(true)
|
||||
@@ -167,127 +141,25 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PackageFilters
|
||||
searchQuery={searchQuery}
|
||||
categoryFilter={categoryFilter}
|
||||
sortBy={sortBy}
|
||||
categories={categories}
|
||||
onSearchChange={setSearchQuery}
|
||||
onCategoryChange={setCategoryFilter}
|
||||
onSortChange={setSortBy}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs defaultValue="all" className="h-full flex flex-col">
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="all">All Packages</TabsTrigger>
|
||||
<TabsTrigger value="installed">
|
||||
Installed ({installedList.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="available">
|
||||
Available ({availableList.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-3 border-b">
|
||||
<div className="relative">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={20} />
|
||||
<Input
|
||||
placeholder="Search packages..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<Funnel size={16} className="mr-2" />
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat === 'all' ? 'All Categories' : cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<TrendUp size={16} className="mr-2" />
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="downloads">Most Downloaded</SelectItem>
|
||||
<SelectItem value="rating">Highest Rated</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredPackages.map(pkg => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
package={pkg}
|
||||
isInstalled={pkg.installed}
|
||||
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="installed" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{installedList.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<Package size={48} className="mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No packages installed yet</p>
|
||||
</div>
|
||||
) : (
|
||||
installedList.map(pkg => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
package={pkg}
|
||||
isInstalled={true}
|
||||
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="available" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{availableList.map(pkg => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
package={pkg}
|
||||
isInstalled={false}
|
||||
installedPackage={undefined}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<PackageTabs
|
||||
filteredPackages={filteredPackages}
|
||||
installedList={installedList}
|
||||
availableList={availableList}
|
||||
installedPackages={installedPackages}
|
||||
onSelectPackage={openPackageDetails}
|
||||
onTogglePackage={handleTogglePackage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDetails} onOpenChange={setShowDetails}>
|
||||
@@ -416,7 +288,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PackageImportExport
|
||||
<PackageImportExport
|
||||
open={showImportExport}
|
||||
onOpenChange={(open) => {
|
||||
setShowImportExport(open)
|
||||
@@ -429,67 +301,3 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PackageCardProps {
|
||||
package: PackageManifest
|
||||
isInstalled: boolean
|
||||
installedPackage?: InstalledPackage
|
||||
onViewDetails: () => void
|
||||
onToggle: (packageId: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
function PackageCard({ package: pkg, isInstalled, installedPackage, onViewDetails, onToggle }: PackageCardProps) {
|
||||
return (
|
||||
<Card className="flex flex-col hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-2xl flex-shrink-0">
|
||||
{pkg.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-lg truncate">{pkg.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 mt-1">{pkg.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant="secondary">{pkg.category}</Badge>
|
||||
{isInstalled && (
|
||||
<Badge variant={installedPackage?.enabled ? 'default' : 'outline'}>
|
||||
{installedPackage?.enabled ? 'Active' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Download size={14} />
|
||||
<span>{pkg.downloadCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star size={14} weight="fill" className="text-yellow-500" />
|
||||
<span>{pkg.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={onViewDetails} className="flex-1">
|
||||
View Details
|
||||
</Button>
|
||||
{isInstalled && installedPackage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onToggle(pkg.id, !installedPackage.enabled)}
|
||||
title={installedPackage.enabled ? 'Disable' : 'Enable'}
|
||||
>
|
||||
<Power size={18} weight={installedPackage.enabled ? 'fill' : 'regular'} />
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui'
|
||||
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
|
||||
import { Download, Power, Star } from '@phosphor-icons/react'
|
||||
|
||||
interface PackageCardProps {
|
||||
package: PackageManifest
|
||||
isInstalled: boolean
|
||||
installedPackage?: InstalledPackage
|
||||
onViewDetails: () => void
|
||||
onToggle: (packageId: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
export function PackageCard({ package: pkg, isInstalled, installedPackage, onViewDetails, onToggle }: PackageCardProps) {
|
||||
return (
|
||||
<Card className="flex flex-col hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-2xl flex-shrink-0">
|
||||
{pkg.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-lg truncate">{pkg.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 mt-1">{pkg.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant="secondary">{pkg.category}</Badge>
|
||||
{isInstalled && (
|
||||
<Badge variant={installedPackage?.enabled ? 'default' : 'outline'}>
|
||||
{installedPackage?.enabled ? 'Active' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Download size={14} />
|
||||
<span>{pkg.downloadCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star size={14} weight="fill" className="text-yellow-500" />
|
||||
<span>{pkg.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={onViewDetails} className="flex-1">
|
||||
View Details
|
||||
</Button>
|
||||
{isInstalled && installedPackage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onToggle(pkg.id, !installedPackage.enabled)}
|
||||
title={installedPackage.enabled ? 'Disable' : 'Enable'}
|
||||
>
|
||||
<Power size={18} weight={installedPackage.enabled ? 'fill' : 'regular'} />
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Funnel, MagnifyingGlass, TrendUp } from '@phosphor-icons/react'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
|
||||
interface PackageFiltersProps {
|
||||
searchQuery: string
|
||||
categoryFilter: string
|
||||
sortBy: 'name' | 'downloads' | 'rating'
|
||||
categories: string[]
|
||||
onSearchChange: (value: string) => void
|
||||
onCategoryChange: (value: string) => void
|
||||
onSortChange: (value: 'name' | 'downloads' | 'rating') => void
|
||||
}
|
||||
|
||||
export function PackageFilters({
|
||||
searchQuery,
|
||||
categoryFilter,
|
||||
sortBy,
|
||||
categories,
|
||||
onSearchChange,
|
||||
onCategoryChange,
|
||||
onSortChange,
|
||||
}: PackageFiltersProps) {
|
||||
return (
|
||||
<div className="px-6 py-4 space-y-3 border-b">
|
||||
<div className="relative">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={20} />
|
||||
<Input
|
||||
placeholder="Search packages..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Select value={categoryFilter} onValueChange={onCategoryChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<Funnel size={16} className="mr-2" />
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat === 'all' ? 'All Categories' : cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={(value) => onSortChange(value as any)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<TrendUp size={16} className="mr-2" />
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="downloads">Most Downloaded</SelectItem>
|
||||
<SelectItem value="rating">Highest Rated</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
|
||||
import { Package } from '@phosphor-icons/react'
|
||||
import { PackageCard } from './PackageCard'
|
||||
|
||||
interface PackageTabsProps {
|
||||
filteredPackages: PackageManifest[]
|
||||
installedList: PackageManifest[]
|
||||
availableList: PackageManifest[]
|
||||
installedPackages: InstalledPackage[]
|
||||
onSelectPackage: (packageId: string) => void
|
||||
onTogglePackage: (packageId: string, enabled: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export function PackageTabs({
|
||||
filteredPackages,
|
||||
installedList,
|
||||
availableList,
|
||||
installedPackages,
|
||||
onSelectPackage,
|
||||
onTogglePackage,
|
||||
}: PackageTabsProps) {
|
||||
const renderPackageCards = (packages: PackageManifest[], isInstalled: (pkg: PackageManifest) => boolean) => (
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{packages.map(pkg => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
package={pkg}
|
||||
isInstalled={isInstalled(pkg)}
|
||||
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
|
||||
onViewDetails={() => onSelectPackage(pkg.id)}
|
||||
onToggle={onTogglePackage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="all" className="h-full flex flex-col">
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="all">All Packages</TabsTrigger>
|
||||
<TabsTrigger value="installed">Installed ({installedList.length})</TabsTrigger>
|
||||
<TabsTrigger value="available">Available ({availableList.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full">
|
||||
{renderPackageCards(filteredPackages, (pkg) => pkg.installed)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="installed" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{installedList.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<Package size={48} className="mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No packages installed yet</p>
|
||||
</div>
|
||||
) : (
|
||||
installedList.map(pkg => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
package={pkg}
|
||||
isInstalled
|
||||
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
|
||||
onViewDetails={() => onSelectPackage(pkg.id)}
|
||||
onToggle={onTogglePackage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="available" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full">
|
||||
{renderPackageCards(availableList, () => false)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/packages/core/package-catalog'
|
||||
import type { InstalledPackage, PackageManifest } from '@/lib/package-types'
|
||||
import { listInstalledPackages } from '@/lib/api/packages'
|
||||
|
||||
export interface UsePackagesResult {
|
||||
packages: PackageManifest[]
|
||||
installedPackages: InstalledPackage[]
|
||||
filteredPackages: PackageManifest[]
|
||||
installedList: PackageManifest[]
|
||||
availableList: PackageManifest[]
|
||||
categories: string[]
|
||||
searchQuery: string
|
||||
categoryFilter: string
|
||||
sortBy: 'name' | 'downloads' | 'rating'
|
||||
setSearchQuery: (query: string) => void
|
||||
setCategoryFilter: (category: string) => void
|
||||
setSortBy: (sort: 'name' | 'downloads' | 'rating') => void
|
||||
loadPackages: () => Promise<void>
|
||||
getCatalogEntry: (packageId: string) => PackageCatalogData | null
|
||||
}
|
||||
|
||||
export function usePackages(): UsePackagesResult {
|
||||
const [packages, setPackages] = useState<PackageManifest[]>([])
|
||||
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'downloads' | 'rating'>('downloads')
|
||||
|
||||
useEffect(() => {
|
||||
loadPackages()
|
||||
}, [])
|
||||
|
||||
const loadPackages = async () => {
|
||||
const installed = await listInstalledPackages()
|
||||
setInstalledPackages(installed)
|
||||
|
||||
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => {
|
||||
const packageData = pkg()
|
||||
|
||||
return {
|
||||
...packageData.manifest,
|
||||
installed: installed.some(ip => ip.packageId === packageData.manifest.id),
|
||||
}
|
||||
})
|
||||
|
||||
setPackages(allPackages)
|
||||
}
|
||||
|
||||
const filteredPackages = useMemo(() => {
|
||||
const matchesSearch = (pkg: PackageManifest) =>
|
||||
pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
pkg.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
pkg.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
|
||||
const matchesCategory = (pkg: PackageManifest) => categoryFilter === 'all' || pkg.category === categoryFilter
|
||||
|
||||
return packages
|
||||
.filter(pkg => matchesSearch(pkg) && matchesCategory(pkg))
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name)
|
||||
if (sortBy === 'downloads') return b.downloadCount - a.downloadCount
|
||||
if (sortBy === 'rating') return b.rating - a.rating
|
||||
return 0
|
||||
})
|
||||
}, [packages, searchQuery, categoryFilter, sortBy])
|
||||
|
||||
const categories = useMemo(
|
||||
() => ['all', ...Array.from(new Set(packages.map(p => p.category)))],
|
||||
[packages],
|
||||
)
|
||||
|
||||
const installedList = useMemo(() => packages.filter(p => p.installed), [packages])
|
||||
const availableList = useMemo(() => packages.filter(p => !p.installed), [packages])
|
||||
|
||||
const getCatalogEntry = (packageId: string) => PACKAGE_CATALOG[packageId]?.() ?? null
|
||||
|
||||
return {
|
||||
packages,
|
||||
installedPackages,
|
||||
filteredPackages,
|
||||
installedList,
|
||||
availableList,
|
||||
categories,
|
||||
searchQuery,
|
||||
categoryFilter,
|
||||
sortBy,
|
||||
setSearchQuery,
|
||||
setCategoryFilter,
|
||||
setSortBy,
|
||||
loadPackages,
|
||||
getCatalogEntry,
|
||||
}
|
||||
}
|
||||
1
frontends/nextjs/src/lib/component-registry.ts
Normal file
1
frontends/nextjs/src/lib/component-registry.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/component-registry'
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../builder-types'
|
||||
13
frontends/nextjs/src/lib/components/catalog/data.ts
Normal file
13
frontends/nextjs/src/lib/components/catalog/data.ts
Normal file
@@ -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: [],
|
||||
},
|
||||
]
|
||||
73
frontends/nextjs/src/lib/components/catalog/display.ts
Normal file
73
frontends/nextjs/src/lib/components/catalog/display.ts
Normal file
@@ -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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
39
frontends/nextjs/src/lib/components/catalog/feedback.ts
Normal file
39
frontends/nextjs/src/lib/components/catalog/feedback.ts
Normal file
@@ -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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
116
frontends/nextjs/src/lib/components/catalog/inputs.ts
Normal file
116
frontends/nextjs/src/lib/components/catalog/inputs.ts
Normal file
@@ -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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
56
frontends/nextjs/src/lib/components/catalog/layout.ts
Normal file
56
frontends/nextjs/src/lib/components/catalog/layout.ts
Normal file
@@ -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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
60
frontends/nextjs/src/lib/components/catalog/typography.ts
Normal file
60
frontends/nextjs/src/lib/components/catalog/typography.ts
Normal file
@@ -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: '' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getComponentRegistry } from './get-component-registry'
|
||||
import { getComponentRegistry } from '../getters/get-component-registry'
|
||||
|
||||
export async function initializeComponentRegistry(): Promise<void> {
|
||||
getComponentRegistry()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentDefinition } from '../builder-types'
|
||||
import type { ComponentDefinition } from '../../types'
|
||||
|
||||
export interface ComponentTypeDefinition extends ComponentDefinition {
|
||||
renderingLogic?: {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
1
frontends/nextjs/src/lib/components/types/index.ts
Normal file
1
frontends/nextjs/src/lib/components/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@/lib/types/builder-types'
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { exportDatabase } from './export-database'
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { importDatabase } from './import-database'
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user