Compare commits

...

9 Commits

27 changed files with 3447 additions and 3146 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
import type { MouseEvent } from 'react'
import {
Box,
Button,
IconButton,
MenuItem,
TextField,
Tooltip,
Typography,
} from '@mui/material'
import {
Add as AddIcon,
ArrowDownward,
ArrowUpward,
ContentCopy,
Delete as DeleteIcon,
} from '@mui/icons-material'
import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from '../types'
import styles from '../LuaBlocksEditor.module.scss'
interface BlockListProps {
blocks: LuaBlock[]
blockDefinitionMap: Map<LuaBlockType, BlockDefinition>
onRequestAddBlock: (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => void
onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
onDuplicateBlock: (blockId: string) => void
onRemoveBlock: (blockId: string) => void
onUpdateField: (blockId: string, fieldName: string, value: string) => void
}
const renderBlockFields = (
block: LuaBlock,
definition: BlockDefinition,
onUpdateField: (blockId: string, fieldName: string, value: string) => void
) => {
if (definition.fields.length === 0) return null
return (
<Box className={styles.blockFields}>
{definition.fields.map((field) => (
<Box key={field.name}>
<Typography className={styles.blockFieldLabel}>{field.label}</Typography>
{field.type === 'select' ? (
<TextField
select
size="small"
value={block.fields[field.name]}
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
fullWidth
variant="outlined"
InputProps={{
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
}}
>
{field.options?.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
) : (
<TextField
size="small"
value={block.fields[field.name]}
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
placeholder={field.placeholder}
fullWidth
variant="outlined"
type={field.type === 'number' ? 'number' : 'text'}
InputProps={{
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
}}
/>
)}
</Box>
))}
</Box>
)
}
const renderBlockSection = (
title: string,
blocks: LuaBlock[] | undefined,
parentId: string | null,
slot: BlockSlot,
onRequestAddBlock: (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => void,
renderBlockCard: (block: LuaBlock, index: number, total: number) => JSX.Element | null
) => (
<Box className={styles.blockSection}>
<Box className={styles.blockSectionHeader}>
<Typography className={styles.blockSectionTitle}>{title}</Typography>
<Button
size="small"
variant="contained"
onClick={(event) => onRequestAddBlock(event, { parentId, slot })}
startIcon={<AddIcon fontSize="small" />}
>
Add block
</Button>
</Box>
<Box className={styles.blockSectionBody}>
{blocks && blocks.length > 0 ? (
blocks.map((child, index) => renderBlockCard(child, index, blocks.length))
) : (
<Box className={styles.blockEmpty}>Drop blocks here to build this section.</Box>
)}
</Box>
</Box>
)
export const BlockList = ({
blocks,
blockDefinitionMap,
onRequestAddBlock,
onMoveBlock,
onDuplicateBlock,
onRemoveBlock,
onUpdateField,
}: BlockListProps) => {
const renderBlockCard = (block: LuaBlock, index: number, total: number) => {
const definition = blockDefinitionMap.get(block.type)
if (!definition) return null
return (
<Box key={block.id} className={styles.blockCard} data-category={definition.category}>
<Box className={styles.blockHeader}>
<Typography className={styles.blockTitle}>{definition.label}</Typography>
<Box className={styles.blockActions}>
<Tooltip title="Move up">
<span>
<IconButton
size="small"
onClick={() => onMoveBlock(block.id, 'up')}
disabled={index === 0}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ArrowUpward fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<span>
<IconButton
size="small"
onClick={() => onMoveBlock(block.id, 'down')}
disabled={index === total - 1}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ArrowDownward fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Duplicate block">
<IconButton
size="small"
onClick={() => onDuplicateBlock(block.id)}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<ContentCopy fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Delete block">
<IconButton
size="small"
onClick={() => onRemoveBlock(block.id)}
sx={{ color: 'rgba(255,255,255,0.85)' }}
>
<DeleteIcon fontSize="inherit" />
</IconButton>
</Tooltip>
</Box>
</Box>
{renderBlockFields(block, definition, onUpdateField)}
{definition.hasChildren &&
renderBlockSection('Then', block.children, block.id, 'children', onRequestAddBlock, renderBlockCard)}
{definition.hasElseChildren &&
renderBlockSection(
'Else',
block.elseChildren,
block.id,
'elseChildren',
onRequestAddBlock,
renderBlockCard
)}
</Box>
)
}
return (
<Box className={styles.blockStack}>
{blocks.map((block, index) => renderBlockCard(block, index, blocks.length))}
</Box>
)
}

View File

@@ -0,0 +1,29 @@
import { Box, Menu, MenuItem, Typography } from '@mui/material'
import type { BlockDefinition } from '../types'
import styles from '../LuaBlocksEditor.module.scss'
interface BlockMenuProps {
anchorEl: HTMLElement | null
open: boolean
onClose: () => void
blocks: BlockDefinition[]
onSelect: (type: BlockDefinition['type']) => void
}
export const BlockMenu = ({ anchorEl, open, onClose, blocks, onSelect }: BlockMenuProps) => (
<Menu anchorEl={anchorEl} open={open} onClose={onClose} PaperProps={{ sx: { minWidth: 280 } }}>
{blocks.map((definition) => (
<MenuItem key={definition.type} onClick={() => onSelect(definition.type)}>
<Box className={styles.menuSwatch} data-category={definition.category} sx={{ mr: 1 }} />
<Box>
<Typography variant="body2" fontWeight={600}>
{definition.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{definition.description}
</Typography>
</Box>
</MenuItem>
))}
</Menu>
)

View File

@@ -0,0 +1,334 @@
import { useCallback, useMemo } from 'react'
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',
},
],
},
]
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 blockDefinitionMap = useMemo(
() => new Map<LuaBlockType, BlockDefinition>(BLOCK_DEFINITIONS.map((definition) => [definition.type, definition])),
[]
)
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 createBlock = useCallback(
(type: LuaBlockType): LuaBlock => {
const definition = blockDefinitionMap.get(type)
if (!definition) {
throw new Error(`Unknown block type: ${type}`)
}
const fields = definition.fields.reduce<Record<string, string>>((acc, field) => {
acc[field.name] = field.defaultValue
return acc
}, {})
return {
id: createBlockId(),
type,
fields,
children: definition.hasChildren ? [] : undefined,
elseChildren: definition.hasElseChildren ? [] : undefined,
}
},
[blockDefinitionMap]
)
const cloneBlock = useCallback(
(block: LuaBlock): LuaBlock => ({
...block,
id: createBlockId(),
fields: { ...block.fields },
children: block.children ? block.children.map(cloneBlock) : undefined,
elseChildren: block.elseChildren ? block.elseChildren.map(cloneBlock) : undefined,
}),
[]
)
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 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
}
}, [])
return {
blockDefinitions: BLOCK_DEFINITIONS,
blockDefinitionMap,
blocksByCategory,
createBlock,
cloneBlock,
buildLuaFromBlocks,
decodeBlocksMetadata,
}
}

View File

@@ -0,0 +1,333 @@
import { useEffect, useMemo, useState, type MouseEvent } from 'react'
import { toast } from 'sonner'
import type { LuaScript } from '@/lib/level-types'
import type { BlockSlot, LuaBlock, LuaBlockType } from '../types'
interface UseLuaBlocksStateProps {
scripts: LuaScript[]
onScriptsChange: (scripts: LuaScript[]) => void
buildLuaFromBlocks: (blocks: LuaBlock[]) => string
createBlock: (type: LuaBlockType) => LuaBlock
cloneBlock: (block: LuaBlock) => LuaBlock
decodeBlocksMetadata: (code: string) => LuaBlock[] | null
}
interface MenuTarget {
parentId: string | null
slot: BlockSlot
}
const addBlockToTree = (
blocks: LuaBlock[],
parentId: string | null,
slot: BlockSlot,
newBlock: LuaBlock
): LuaBlock[] => {
if (slot === 'root' || !parentId) {
return [...blocks, newBlock]
}
return blocks.map((block) => {
if (block.id === parentId) {
const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? []
const updated = [...current, newBlock]
if (slot === 'children') {
return { ...block, children: updated }
}
return { ...block, elseChildren: updated }
}
const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children
const elseChildren = block.elseChildren
? addBlockToTree(block.elseChildren, parentId, slot, newBlock)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
}
const updateBlockInTree = (
blocks: LuaBlock[],
blockId: string,
updater: (block: LuaBlock) => LuaBlock
): LuaBlock[] =>
blocks.map((block) => {
if (block.id === blockId) {
return updater(block)
}
const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children
const elseChildren = block.elseChildren
? updateBlockInTree(block.elseChildren, blockId, updater)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] =>
blocks
.filter((block) => block.id !== blockId)
.map((block) => {
const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children
const elseChildren = block.elseChildren
? removeBlockFromTree(block.elseChildren, blockId)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => {
const index = blocks.findIndex((block) => block.id === blockId)
if (index !== -1) {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= blocks.length) return blocks
const updated = [...blocks]
const [moved] = updated.splice(index, 1)
updated.splice(targetIndex, 0, moved)
return updated
}
return blocks.map((block) => {
const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children
const elseChildren = block.elseChildren
? moveBlockInTree(block.elseChildren, blockId, direction)
: block.elseChildren
if (children !== block.children || elseChildren !== block.elseChildren) {
return { ...block, children, elseChildren }
}
return block
})
}
export function useLuaBlocksState({
scripts,
onScriptsChange,
buildLuaFromBlocks,
createBlock,
cloneBlock,
decodeBlocksMetadata,
}: UseLuaBlocksStateProps) {
const [selectedScriptId, setSelectedScriptId] = useState<string | null>(
scripts.length > 0 ? scripts[0].id : null
)
const [blocksByScript, setBlocksByScript] = useState<Record<string, LuaBlock[]>>({})
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null)
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null)
useEffect(() => {
if (scripts.length === 0) {
setSelectedScriptId(null)
return
}
if (!selectedScriptId || !scripts.find((script) => script.id === selectedScriptId)) {
setSelectedScriptId(scripts[0].id)
}
}, [scripts, selectedScriptId])
useEffect(() => {
if (!selectedScriptId) return
if (Object.prototype.hasOwnProperty.call(blocksByScript, selectedScriptId)) {
return
}
const script = scripts.find((item) => item.id === selectedScriptId)
const parsedBlocks = script ? decodeBlocksMetadata(script.code) : null
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: parsedBlocks ?? [],
}))
}, [blocksByScript, decodeBlocksMetadata, scripts, selectedScriptId])
const selectedScript = scripts.find((script) => script.id === selectedScriptId) || null
const activeBlocks = selectedScriptId ? blocksByScript[selectedScriptId] || [] : []
const generatedCode = useMemo(() => buildLuaFromBlocks(activeBlocks), [activeBlocks, buildLuaFromBlocks])
const handleAddScript = () => {
const starterBlocks = [createBlock('log')]
const newScript: LuaScript = {
id: `lua_${Date.now()}`,
name: 'Block Script',
description: 'Built with Lua blocks',
code: buildLuaFromBlocks(starterBlocks),
parameters: [],
}
onScriptsChange([...scripts, newScript])
setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks }))
setSelectedScriptId(newScript.id)
toast.success('Block script created')
}
const handleDeleteScript = (scriptId: string) => {
const remaining = scripts.filter((script) => script.id !== scriptId)
onScriptsChange(remaining)
setBlocksByScript((prev) => {
const { [scriptId]: _, ...rest } = prev
return rest
})
if (selectedScriptId === scriptId) {
setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null)
}
toast.success('Script deleted')
}
const handleUpdateScript = (updates: Partial<LuaScript>) => {
if (!selectedScript) return
onScriptsChange(
scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script))
)
}
const handleApplyCode = () => {
if (!selectedScript) return
handleUpdateScript({ code: generatedCode })
toast.success('Lua code updated from blocks')
}
const handleCopyCode = async () => {
try {
await navigator.clipboard.writeText(generatedCode)
toast.success('Lua code copied to clipboard')
} catch (error) {
toast.error('Unable to copy code')
}
}
const handleReloadFromCode = () => {
if (!selectedScript) return
const parsed = decodeBlocksMetadata(selectedScript.code)
if (!parsed) {
toast.warning('No block metadata found in this script')
return
}
setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed }))
toast.success('Blocks loaded from script')
}
const handleRequestAddBlock = (
event: MouseEvent<HTMLElement>,
target: { parentId: string | null; slot: BlockSlot }
) => {
setMenuAnchor(event.currentTarget)
setMenuTarget(target)
}
const handleAddBlock = (type: LuaBlockType, target?: { parentId: string | null; slot: BlockSlot }) => {
const resolvedTarget = target ?? menuTarget
if (!selectedScriptId || !resolvedTarget) return
const newBlock = createBlock(type)
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: addBlockToTree(
prev[selectedScriptId] || [],
resolvedTarget.parentId,
resolvedTarget.slot,
newBlock
),
}))
setMenuAnchor(null)
setMenuTarget(null)
}
const handleCloseMenu = () => {
setMenuAnchor(null)
setMenuTarget(null)
}
const handleUpdateField = (blockId: string, fieldName: string, value: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({
...block,
fields: {
...block.fields,
[fieldName]: value,
},
})),
}))
}
const handleRemoveBlock = (blockId: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId),
}))
}
const handleDuplicateBlock = (blockId: string) => {
if (!selectedScriptId) return
setBlocksByScript((prev) => {
const blocks = prev[selectedScriptId] || []
let duplicated: LuaBlock | null = null
const updated = updateBlockInTree(blocks, blockId, (block) => {
duplicated = cloneBlock(block)
return block
})
if (!duplicated) return prev
return {
...prev,
[selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated),
}
})
}
const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => {
if (!selectedScriptId) return
setBlocksByScript((prev) => ({
...prev,
[selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction),
}))
}
return {
activeBlocks,
generatedCode,
handleAddBlock,
handleAddScript,
handleApplyCode,
handleCloseMenu,
handleCopyCode,
handleDeleteScript,
handleDuplicateBlock,
handleMoveBlock,
handleReloadFromCode,
handleRemoveBlock,
handleRequestAddBlock,
handleUpdateField,
handleUpdateScript,
menuAnchor,
menuTarget,
selectedScript,
selectedScriptId,
setSelectedScriptId,
}
}

View File

@@ -0,0 +1,42 @@
export type LuaBlockType =
| 'log'
| 'set_variable'
| 'if'
| 'if_else'
| 'repeat'
| 'return'
| 'call'
| 'comment'
export type BlockSlot = 'root' | 'children' | 'elseChildren'
export type BlockCategory = 'Basics' | 'Logic' | 'Loops' | 'Data' | 'Functions'
export type BlockFieldType = 'text' | 'number' | 'select'
export interface BlockFieldDefinition {
name: string
label: string
placeholder?: string
type?: BlockFieldType
defaultValue: string
options?: Array<{ label: string; value: string }>
}
export interface BlockDefinition {
type: LuaBlockType
label: string
description: string
category: BlockCategory
fields: BlockFieldDefinition[]
hasChildren?: boolean
hasElseChildren?: boolean
}
export interface LuaBlock {
id: string
type: LuaBlockType
fields: Record<string, string>
children?: LuaBlock[]
elseChildren?: LuaBlock[]
}

View File

@@ -9,8 +9,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Separator } from '@/components/ui'
import { toast } from 'sonner'
import { PACKAGE_CATALOG } from '@/lib/packages/core/package-catalog'
import type { PackageManifest, PackageContent, InstalledPackage } from '@/lib/package-types'
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 { PackageImportExport } from './PackageImportExport'
@@ -22,7 +22,7 @@ interface PackageManagerProps {
export function PackageManager({ onClose }: PackageManagerProps) {
const [packages, setPackages] = useState<PackageManifest[]>([])
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
const [selectedPackage, setSelectedPackage] = useState<{ manifest: PackageManifest; content: PackageContent } | null>(null)
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')
@@ -39,10 +39,14 @@ export function PackageManager({ onClose }: PackageManagerProps) {
const installed = await listInstalledPackages()
setInstalledPackages(installed)
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => ({
...pkg.manifest,
installed: installed.some(ip => ip.packageId === pkg.manifest.id),
}))
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)
}
@@ -50,7 +54,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
const handleInstallPackage = async (packageId: string) => {
setInstalling(true)
try {
const packageEntry = PACKAGE_CATALOG[packageId]
const packageEntry = PACKAGE_CATALOG[packageId]?.()
if (!packageEntry) {
toast.error('Package not found')
return
@@ -71,7 +75,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
const handleUninstallPackage = async (packageId: string) => {
try {
const packageEntry = PACKAGE_CATALOG[packageId]
const packageEntry = PACKAGE_CATALOG[packageId]?.()
if (!packageEntry) {
toast.error('Package not found')
return
@@ -227,7 +231,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
isInstalled={pkg.installed}
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}
@@ -253,7 +257,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
isInstalled={true}
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}
@@ -274,7 +278,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
isInstalled={false}
installedPackage={undefined}
onViewDetails={() => {
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
setShowDetails(true)
}}
onToggle={handleTogglePackage}

View File

@@ -0,0 +1,134 @@
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { formatWorkflowLogAnalysis, summarizeWorkflowLogs } from '@/lib/github/analyze-workflow-logs'
import { Job, RepoInfo, WorkflowRun } from '../types'
interface UseWorkflowLogAnalysisOptions {
repoInfo: RepoInfo | null
onAnalysisStart?: () => void
onAnalysisComplete?: (report: string | null) => void
}
export function useWorkflowLogAnalysis({
repoInfo,
onAnalysisStart,
onAnalysisComplete,
}: UseWorkflowLogAnalysisOptions) {
const [selectedRunId, setSelectedRunId] = useState<number | null>(null)
const [runJobs, setRunJobs] = useState<Job[]>([])
const [runLogs, setRunLogs] = useState<string | null>(null)
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const downloadRunLogs = useCallback(
async (runId: number, runName: string) => {
setIsLoadingLogs(true)
setSelectedRunId(runId)
setRunLogs(null)
setRunJobs([])
try {
const query = new URLSearchParams({
runName,
includeLogs: 'true',
jobLimit: '20',
})
if (repoInfo) {
query.set('owner', repoInfo.owner)
query.set('repo', repoInfo.repo)
}
const response = await fetch(`/api/github/actions/runs/${runId}/logs?${query.toString()}`, {
cache: 'no-store',
})
let payload: {
jobs?: Job[]
logsText?: string | null
truncated?: boolean
requiresAuth?: boolean
error?: string
} | null = null
try {
payload = await response.json()
} catch {
payload = null
}
if (!response.ok) {
if (payload?.requiresAuth) {
toast.error('GitHub API requires authentication for logs')
}
const message = payload?.error || `Failed to download logs (${response.status})`
throw new Error(message)
}
const logsText = payload?.logsText ?? null
setRunJobs(payload?.jobs ?? [])
setRunLogs(logsText)
if (logsText) {
const blob = new Blob([logsText], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `workflow-logs-${runId}-${new Date().toISOString()}.txt`
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}
if (payload?.truncated) {
toast.info('Downloaded logs are truncated. Increase the job limit for more.')
}
toast.success('Workflow logs downloaded successfully')
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to download logs'
toast.error(errorMessage)
setRunLogs(`Error fetching logs: ${errorMessage}`)
} finally {
setIsLoadingLogs(false)
}
},
[repoInfo],
)
const analyzeRunLogs = useCallback(
async (runs: WorkflowRun[] | null) => {
if (!runLogs || !selectedRunId) {
toast.error('No logs to analyze')
return
}
onAnalysisStart?.()
try {
const selectedRun = runs?.find(r => r.id === selectedRunId)
const summary = summarizeWorkflowLogs(runLogs)
const report = formatWorkflowLogAnalysis(summary, {
runName: selectedRun?.name,
runId: selectedRunId,
})
onAnalysisComplete?.(report)
toast.success('Log analysis complete')
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Analysis failed'
toast.error(errorMessage)
onAnalysisComplete?.(null)
}
},
[onAnalysisComplete, onAnalysisStart, runLogs, selectedRunId],
)
return {
analyzeRunLogs,
downloadRunLogs,
isLoadingLogs,
runJobs,
runLogs,
selectedRunId,
}
}

View File

@@ -0,0 +1,171 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { WorkflowRun, RepoInfo } from '../types'
const DEFAULT_REPO_LABEL = 'johndoe6345789/metabuilder'
export function useWorkflowRuns() {
const [runs, setRuns] = useState<WorkflowRun[] | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastFetched, setLastFetched] = useState<Date | null>(null)
const [needsAuth, setNeedsAuth] = useState(false)
const [repoInfo, setRepoInfo] = useState<RepoInfo | null>(null)
const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(30)
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
const repoLabel = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL
const fetchRuns = useCallback(async () => {
setIsLoading(true)
setError(null)
setNeedsAuth(false)
try {
const response = await fetch('/api/github/actions/runs', { cache: 'no-store' })
let payload: {
owner?: string
repo?: string
runs?: WorkflowRun[]
fetchedAt?: string
requiresAuth?: boolean
error?: string
} | null = null
try {
payload = await response.json()
} catch {
payload = null
}
if (!response.ok) {
if (payload?.requiresAuth) {
setNeedsAuth(true)
}
const message = payload?.error || `Failed to fetch workflow runs (${response.status})`
throw new Error(message)
}
const retrievedRuns = payload?.runs || []
setRuns(retrievedRuns)
if (payload?.owner && payload?.repo) {
setRepoInfo({ owner: payload.owner, repo: payload.repo })
}
setLastFetched(payload?.fetchedAt ? new Date(payload.fetchedAt) : new Date())
setSecondsUntilRefresh(30)
toast.success(`Fetched ${retrievedRuns.length} workflow runs`)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
setError(errorMessage)
toast.error(`Failed to fetch: ${errorMessage}`)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchRuns()
}, [fetchRuns])
useEffect(() => {
if (!autoRefreshEnabled) return
const countdownInterval = setInterval(() => {
setSecondsUntilRefresh((prev) => {
if (prev <= 1) {
fetchRuns()
return 30
}
return prev - 1
})
}, 1000)
return () => clearInterval(countdownInterval)
}, [autoRefreshEnabled, fetchRuns])
const toggleAutoRefresh = () => setAutoRefreshEnabled((prev) => !prev)
const getStatusColor = (status: string, conclusion: string | null) => {
if (status === 'completed') {
if (conclusion === 'success') return 'success.main'
if (conclusion === 'failure') return 'error.main'
if (conclusion === 'cancelled') return 'text.secondary'
}
return 'warning.main'
}
const conclusion = useMemo(() => {
if (!runs || runs.length === 0) return null
const total = runs.length
const completed = runs.filter(r => r.status === 'completed').length
const successful = runs.filter(r => r.status === 'completed' && r.conclusion === 'success').length
const failed = runs.filter(r => r.status === 'completed' && r.conclusion === 'failure').length
const cancelled = runs.filter(r => r.status === 'completed' && r.conclusion === 'cancelled').length
const inProgress = runs.filter(r => r.status !== 'completed').length
const mostRecent = runs[0]
const mostRecentTimestamp = new Date(mostRecent.updated_at).getTime()
const timeThreshold = 5 * 60 * 1000
const recentWorkflows = runs.filter((run) => {
const runTimestamp = new Date(run.updated_at).getTime()
return mostRecentTimestamp - runTimestamp <= timeThreshold
})
const mostRecentPassed = recentWorkflows.every(
(run) => run.status === 'completed' && run.conclusion === 'success',
)
const mostRecentFailed = recentWorkflows.some(
(run) => run.status === 'completed' && run.conclusion === 'failure',
)
const mostRecentRunning = recentWorkflows.some((run) => run.status !== 'completed')
const successRate = total > 0 ? Math.round((successful / total) * 100) : 0
let health: 'healthy' | 'warning' | 'critical' = 'healthy'
if (failed / total > 0.3 || successRate < 60) {
health = 'critical'
} else if (failed > 0 || inProgress > 0) {
health = 'warning'
}
return {
total,
completed,
successful,
failed,
cancelled,
inProgress,
successRate,
health,
recentWorkflows,
mostRecentPassed,
mostRecentFailed,
mostRecentRunning,
}
}, [runs])
const summaryTone = useMemo(() => {
if (!conclusion) return 'warning'
if (conclusion.mostRecentPassed) return 'success'
if (conclusion.mostRecentFailed) return 'error'
return 'warning'
}, [conclusion])
return {
runs,
isLoading,
error,
lastFetched,
needsAuth,
repoInfo,
repoLabel,
secondsUntilRefresh,
autoRefreshEnabled,
toggleAutoRefresh,
fetchRuns,
getStatusColor,
conclusion,
summaryTone,
}
}

View File

@@ -0,0 +1,36 @@
export interface WorkflowRun {
id: number
name: string
status: string
conclusion: string | null
created_at: string
updated_at: string
html_url: string
head_branch: string
event: string
jobs_url?: string
}
export interface JobStep {
name: string
status: string
conclusion: string | null
number: number
started_at?: string | null
completed_at?: string | null
}
export interface Job {
id: number
name: string
status: string
conclusion: string | null
started_at: string
completed_at: string | null
steps: JobStep[]
}
export interface RepoInfo {
owner: string
repo: string
}

View File

@@ -0,0 +1,94 @@
import { Box, Stack } from '@mui/material'
import { Info as InfoIcon, SmartToy as RobotIcon } from '@mui/icons-material'
import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
interface AnalysisPanelProps {
analysis: string | null
isAnalyzing: boolean
runLogs: string | null
onAnalyzeWorkflows: () => void
onAnalyzeLogs?: () => void
}
export function AnalysisPanel({ analysis, isAnalyzing, runLogs, onAnalyzeLogs, onAnalyzeWorkflows }: AnalysisPanelProps) {
return (
<Card>
<CardHeader>
<Stack direction="row" spacing={1} alignItems="center">
<RobotIcon sx={{ fontSize: 24 }} />
<CardTitle>AI-Powered Workflow Analysis</CardTitle>
</Stack>
<CardDescription>
{runLogs
? 'Deep analysis of downloaded workflow logs using GPT-4'
: 'Deep analysis of your CI/CD pipeline using GPT-4'}
</CardDescription>
</CardHeader>
<CardContent>
<Stack spacing={3}>
{runLogs ? (
<Button
onClick={onAnalyzeLogs}
disabled={isAnalyzing}
size="lg"
fullWidth
startIcon={<RobotIcon sx={{ fontSize: 20 }} />}
>
{isAnalyzing ? 'Analyzing Logs...' : 'Analyze Downloaded Logs with AI'}
</Button>
) : (
<Button
onClick={onAnalyzeWorkflows}
disabled={isAnalyzing}
size="lg"
fullWidth
startIcon={<RobotIcon sx={{ fontSize: 20 }} />}
>
{isAnalyzing ? 'Analyzing...' : 'Analyze Workflows with AI'}
</Button>
)}
{isAnalyzing && (
<Stack spacing={2}>
<Skeleton sx={{ height: 128 }} />
<Skeleton sx={{ height: 128 }} />
<Skeleton sx={{ height: 128 }} />
</Stack>
)}
{analysis && !isAnalyzing && (
<Box
sx={{
bgcolor: 'action.hover',
p: 3,
borderRadius: 2,
border: 1,
borderColor: 'divider',
whiteSpace: 'pre-wrap',
}}
>
{analysis}
</Box>
)}
{!analysis && !isAnalyzing && (
<Alert>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<InfoIcon sx={{ color: 'info.main', fontSize: 20 }} />
<Box>
<AlertTitle>No Analysis Yet</AlertTitle>
<AlertDescription>
{runLogs
? 'Click the button above to run an AI analysis of the downloaded logs. The AI will identify errors, provide root cause analysis, and suggest fixes.'
: 'Download logs from a specific workflow run using the "Download Logs" button, or click above to analyze overall workflow patterns.'}
</AlertDescription>
</Box>
</Stack>
</Alert>
)}
</Stack>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,100 @@
import { Box, Stack, Typography } from '@mui/material'
import { Description as FileTextIcon, SmartToy as RobotIcon } from '@mui/icons-material'
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from '@/components/ui'
import { Job } from '../types'
interface RunDetailsProps {
runLogs: string | null
runJobs: Job[]
selectedRunId: number | null
onAnalyzeLogs: () => void
isAnalyzing: boolean
}
export function RunDetails({ runLogs, runJobs, selectedRunId, onAnalyzeLogs, isAnalyzing }: RunDetailsProps) {
if (!runLogs) return null
return (
<Card>
<CardHeader>
<Stack direction="row" spacing={1} alignItems="center">
<FileTextIcon sx={{ fontSize: 24 }} />
<CardTitle>Workflow Logs</CardTitle>
{selectedRunId && (
<Badge variant="secondary" sx={{ fontSize: '0.75rem' }}>
Run #{selectedRunId}
</Badge>
)}
</Stack>
<CardDescription>Complete logs from workflow run including all jobs and steps</CardDescription>
</CardHeader>
<CardContent>
<Stack spacing={3}>
{runJobs.length > 0 && (
<Stack spacing={1.5}>
<Typography variant="subtitle2">Jobs Summary</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{runJobs.map((job) => (
<Badge
key={job.id}
variant={
job.conclusion === 'success'
? 'default'
: job.conclusion === 'failure'
? 'destructive'
: 'outline'
}
sx={{ fontSize: '0.75rem' }}
>
{job.name}: {job.conclusion || job.status}
</Badge>
))}
</Stack>
</Stack>
)}
<ScrollArea
sx={{
height: 600,
width: '100%',
border: 1,
borderColor: 'divider',
borderRadius: 1,
}}
>
<Box
component="pre"
sx={{
m: 0,
p: 2,
fontSize: '0.75rem',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{runLogs}
</Box>
</ScrollArea>
<Stack direction="row" spacing={2} flexWrap="wrap">
<Button
onClick={() => {
if (!runLogs) return
navigator.clipboard.writeText(runLogs)
}}
variant="outline"
>
Copy to Clipboard
</Button>
<Button onClick={onAnalyzeLogs} disabled={isAnalyzing} startIcon={<RobotIcon sx={{ fontSize: 20 }} />}>
{isAnalyzing ? 'Analyzing Logs...' : 'Analyze Logs with AI'}
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,432 @@
import { Box, Stack, Typography } from '@mui/material'
import { alpha } from '@mui/material/styles'
import {
Autorenew as RunningIcon,
Cancel as FailureIcon,
CheckCircle as SuccessIcon,
Download as DownloadIcon,
OpenInNew as OpenInNewIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import { Alert, AlertDescription, AlertTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
import { WorkflowRun } from '../types'
const spinSx = {
animation: 'spin 1s linear infinite',
'@keyframes spin': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
},
}
interface PipelineSummary {
cancelled: number
completed: number
failed: number
health: 'healthy' | 'warning' | 'critical'
inProgress: number
mostRecentFailed: boolean
mostRecentPassed: boolean
mostRecentRunning: boolean
recentWorkflows: WorkflowRun[]
successRate: number
successful: number
total: number
}
interface RunListProps {
runs: WorkflowRun[] | null
isLoading: boolean
error: string | null
needsAuth: boolean
repoLabel: string
lastFetched: Date | null
autoRefreshEnabled: boolean
secondsUntilRefresh: number
onToggleAutoRefresh: () => void
onRefresh: () => void
getStatusColor: (status: string, conclusion: string | null) => string
onDownloadLogs: (runId: number, runName: string) => void
onDownloadJson: () => void
isLoadingLogs: boolean
conclusion: PipelineSummary | null
summaryTone: 'success' | 'error' | 'warning'
selectedRunId: number | null
}
export function RunList({
runs,
isLoading,
error,
needsAuth,
repoLabel,
lastFetched,
autoRefreshEnabled,
secondsUntilRefresh,
onToggleAutoRefresh,
onRefresh,
getStatusColor,
onDownloadLogs,
onDownloadJson,
isLoadingLogs,
conclusion,
summaryTone,
selectedRunId,
}: RunListProps) {
return (
<Card sx={{ borderWidth: 2, borderColor: 'divider' }}>
<CardHeader>
<Stack
direction={{ xs: 'column', lg: 'row' }}
spacing={2}
alignItems={{ xs: 'flex-start', lg: 'center' }}
justifyContent="space-between"
>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={700}>
GitHub Actions Monitor
</Typography>
<Typography color="text.secondary">
Repository:{' '}
<Box
component="code"
sx={{
ml: 1,
px: 1,
py: 0.5,
borderRadius: 1,
bgcolor: 'action.hover',
fontSize: '0.875rem',
}}
>
{repoLabel}
</Box>
</Typography>
{lastFetched && (
<Typography variant="caption" color="text.secondary">
Last fetched: {lastFetched.toLocaleString()}
</Typography>
)}
</Stack>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={{ xs: 'flex-start', md: 'center' }}
>
<Stack spacing={1} alignItems={{ xs: 'flex-start', md: 'flex-end' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Badge
variant={autoRefreshEnabled ? 'default' : 'outline'}
sx={{ fontSize: '0.75rem' }}
>
Auto-refresh {autoRefreshEnabled ? 'ON' : 'OFF'}
</Badge>
{autoRefreshEnabled && (
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
Next refresh: {secondsUntilRefresh}s
</Typography>
)}
</Stack>
<Button onClick={onToggleAutoRefresh} variant="outline" size="sm">
{autoRefreshEnabled ? 'Disable' : 'Enable'} Auto-refresh
</Button>
</Stack>
<Button
onClick={onDownloadJson}
disabled={!runs || runs.length === 0}
variant="outline"
size="sm"
startIcon={<DownloadIcon sx={{ fontSize: 18 }} />}
>
Download JSON
</Button>
<Button
onClick={onRefresh}
disabled={isLoading}
size="lg"
startIcon={<RefreshIcon sx={isLoading ? spinSx : undefined} />}
>
{isLoading ? 'Fetching...' : 'Refresh'}
</Button>
</Stack>
</Stack>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" sx={{ mb: 2 }}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{needsAuth && (
<Alert variant="warning" sx={{ mb: 2 }}>
<AlertTitle>Authentication Required</AlertTitle>
<AlertDescription>
GitHub API requires authentication for this request. Please configure credentials and retry.
</AlertDescription>
</Alert>
)}
{conclusion && (
<Alert
sx={(theme) => ({
borderWidth: 2,
borderColor: theme.palette[summaryTone].main,
bgcolor: alpha(theme.palette[summaryTone].main, 0.08),
alignItems: 'flex-start',
mb: 2,
})}
>
<Stack direction="row" spacing={2} alignItems="flex-start">
{summaryTone === 'success' && (
<SuccessIcon sx={{ color: 'success.main', fontSize: 48 }} />
)}
{summaryTone === 'error' && (
<FailureIcon sx={{ color: 'error.main', fontSize: 48 }} />
)}
{summaryTone === 'warning' && (
<RunningIcon sx={{ color: 'warning.main', fontSize: 48, ...spinSx }} />
)}
<Box flex={1}>
<AlertTitle>
<Box sx={{ fontSize: '1.25rem', fontWeight: 700, mb: 1 }}>
{conclusion.mostRecentPassed && 'Most Recent Builds: ALL PASSED'}
{conclusion.mostRecentFailed && 'Most Recent Builds: FAILURES DETECTED'}
{conclusion.mostRecentRunning && 'Most Recent Builds: RUNNING'}
</Box>
</AlertTitle>
<AlertDescription>
<Stack spacing={2}>
<Typography variant="body2">
{conclusion.recentWorkflows.length > 1
? `Showing ${conclusion.recentWorkflows.length} workflows from the most recent run:`
: 'Most recent workflow:'}
</Typography>
<Stack spacing={1.5}>
{conclusion.recentWorkflows.map((workflow: WorkflowRun) => {
const statusLabel = workflow.status === 'completed'
? workflow.conclusion
: workflow.status
const badgeVariant = workflow.conclusion === 'success'
? 'default'
: workflow.conclusion === 'failure'
? 'destructive'
: 'outline'
return (
<Box
key={workflow.id}
sx={{
bgcolor: 'background.paper',
borderRadius: 2,
p: 2,
boxShadow: 1,
}}
>
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
{workflow.status === 'completed' && workflow.conclusion === 'success' && (
<SuccessIcon sx={{ color: 'success.main', fontSize: 20 }} />
)}
{workflow.status === 'completed' && workflow.conclusion === 'failure' && (
<FailureIcon sx={{ color: 'error.main', fontSize: 20 }} />
)}
{workflow.status !== 'completed' && (
<RunningIcon sx={{ color: 'warning.main', fontSize: 20, ...spinSx }} />
)}
<Typography fontWeight={600}>{workflow.name}</Typography>
<Badge variant={badgeVariant} sx={{ fontSize: '0.75rem' }}>
{statusLabel}
</Badge>
</Stack>
<Stack
direction="row"
spacing={2}
flexWrap="wrap"
sx={{ color: 'text.secondary', fontSize: '0.75rem' }}
>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Branch:</Typography>
<Box
component="code"
sx={{
px: 0.75,
py: 0.25,
bgcolor: 'action.hover',
borderRadius: 1,
fontFamily: 'monospace',
}}
>
{workflow.head_branch}
</Box>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Updated:</Typography>
<Typography>{new Date(workflow.updated_at).toLocaleString()}</Typography>
</Stack>
</Stack>
</Stack>
</Box>
)
})}
</Stack>
<Box>
<Button
variant={conclusion.mostRecentPassed ? 'default' : 'destructive'}
size="sm"
component="a"
href="https://github.com/johndoe6345789/metabuilder/actions"
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon sx={{ fontSize: 18 }} />}
>
View All Workflows on GitHub
</Button>
</Box>
</Stack>
</AlertDescription>
</Box>
</Stack>
</Alert>
)}
<Card sx={{ borderWidth: 2, borderColor: 'divider' }}>
<CardHeader>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={1} alignItems="center">
<SuccessIcon sx={{ color: 'success.main', fontSize: 24 }} />
<CardTitle>Recent Workflow Runs</CardTitle>
</Stack>
{isLoading && <Skeleton sx={{ width: 120, height: 12 }} />}
</Stack>
<CardDescription>Latest GitHub Actions runs with status and controls</CardDescription>
</CardHeader>
<CardContent>
{isLoading && !runs && (
<Stack spacing={2}>
<Skeleton sx={{ height: 96 }} />
<Skeleton sx={{ height: 96 }} />
<Skeleton sx={{ height: 96 }} />
</Stack>
)}
{runs && runs.length > 0 ? (
<Stack spacing={2}>
{runs.map((run) => {
const statusIcon = getStatusColor(run.status, run.conclusion)
return (
<Card key={run.id} variant="outlined" sx={{ borderColor: 'divider' }}>
<CardContent>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} justifyContent="space-between">
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: statusIcon,
}}
/>
<Typography fontWeight={600}>{run.name}</Typography>
<Badge variant="outline" sx={{ textTransform: 'capitalize' }}>
{run.event}
</Badge>
</Stack>
<Stack direction="row" spacing={2} flexWrap="wrap" sx={{ color: 'text.secondary' }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Branch:</Typography>
<Box
component="code"
sx={{
px: 0.75,
py: 0.25,
bgcolor: 'action.hover',
borderRadius: 1,
fontFamily: 'monospace',
fontSize: '0.75rem',
}}
>
{run.head_branch}
</Box>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Event:</Typography>
<Typography>{run.event}</Typography>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Status:</Typography>
<Typography sx={{ color: getStatusColor(run.status, run.conclusion) }}>
{run.status === 'completed' ? run.conclusion : run.status}
</Typography>
</Stack>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Updated: {new Date(run.updated_at).toLocaleString()}
</Typography>
</Stack>
<Stack spacing={1} alignItems={{ xs: 'flex-start', md: 'flex-end' }}>
<Button
variant="outline"
size="sm"
onClick={() => onDownloadLogs(run.id, run.name)}
disabled={isLoadingLogs && selectedRunId === run.id}
startIcon={
isLoadingLogs && selectedRunId === run.id
? <RunningIcon sx={{ fontSize: 16, ...spinSx }} />
: <DownloadIcon sx={{ fontSize: 16 }} />
}
>
{isLoadingLogs && selectedRunId === run.id ? 'Loading...' : 'Download Logs'}
</Button>
<Button
variant="outline"
size="sm"
component="a"
href={run.html_url}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
>
View
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
)
})}
<Box sx={{ textAlign: 'center', pt: 2 }}>
<Button
variant="outline"
onClick={() => {
if (!runs) return
const jsonData = JSON.stringify(runs, null, 2)
navigator.clipboard.writeText(jsonData)
}}
>
Copy All as JSON
</Button>
</Box>
</Stack>
) : (
<Box sx={{ textAlign: 'center', py: 6, color: 'text.secondary' }}>
{isLoading ? 'Loading workflow runs...' : 'No workflow runs found. Click refresh to fetch data.'}
</Box>
)}
</CardContent>
</Card>
</CardContent>
</Card>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
export { forumClassicPackage } from './set-a/forum-classic'
export { guestbookRetroPackage } from './set-a/guestbook-retro'
export { youtubeClonePackage } from './set-a/youtube-clone'
export { spotifyClonePackage } from './set-a/spotify-clone'
export { retroGamesPackage } from './set-b/retro-games'
export { ecommerceBasicPackage } from './set-b/ecommerce-basic'
export { ircWebchatPackage } from './set-b/irc-webchat'

View File

@@ -0,0 +1,135 @@
import type { PackageContent, PackageManifest } from '../../package-types'
export const forumClassicPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
manifest: {
id: 'forum-classic',
name: 'Classic Forum',
version: '1.0.0',
description: 'Full-featured discussion forum with threads, categories, user profiles, and moderation tools. Perfect for building community discussions.',
author: 'MetaBuilder Team',
category: 'social',
icon: '💬',
screenshots: [],
tags: ['forum', 'discussion', 'community', 'threads'],
dependencies: [],
createdAt: Date.now(),
updatedAt: Date.now(),
downloadCount: 1247,
rating: 4.7,
installed: false,
},
content: {
schemas: [
{
name: 'ForumCategory',
displayName: 'Forum Category',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'name', type: 'string', label: 'Category Name', required: true },
{ name: 'description', type: 'text', label: 'Description', required: false },
{ name: 'order', type: 'number', label: 'Display Order', required: true, defaultValue: 0 },
{ name: 'icon', type: 'string', label: 'Icon', required: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'ForumThread',
displayName: 'Forum Thread',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'categoryId', type: 'string', label: 'Category ID', required: true },
{ name: 'title', type: 'string', label: 'Thread Title', required: true },
{ name: 'authorId', type: 'string', label: 'Author ID', required: true },
{ name: 'content', type: 'text', label: 'Content', required: true },
{ name: 'isPinned', type: 'boolean', label: 'Pinned', required: false, defaultValue: false },
{ name: 'isLocked', type: 'boolean', label: 'Locked', required: false, defaultValue: false },
{ name: 'views', type: 'number', label: 'View Count', required: true, defaultValue: 0 },
{ name: 'replyCount', type: 'number', label: 'Reply Count', required: true, defaultValue: 0 },
{ name: 'lastReplyAt', type: 'number', label: 'Last Reply At', required: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
{ name: 'updatedAt', type: 'number', label: 'Updated At', required: false },
],
},
{
name: 'ForumPost',
displayName: 'Forum Post',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'threadId', type: 'string', label: 'Thread ID', required: true },
{ name: 'authorId', type: 'string', label: 'Author ID', required: true },
{ name: 'content', type: 'text', label: 'Content', required: true },
{ name: 'likes', type: 'number', label: 'Like Count', required: true, defaultValue: 0 },
{ name: 'isEdited', type: 'boolean', label: 'Edited', required: false, defaultValue: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
{ name: 'updatedAt', type: 'number', label: 'Updated At', required: false },
],
},
],
pages: [
{
id: 'page_forum_home',
path: '/forum',
title: 'Forum Home',
level: 2,
componentTree: [],
requiresAuth: true,
requiredRole: 'user',
},
{
id: 'page_forum_category',
path: '/forum/category/:id',
title: 'Forum Category',
level: 2,
componentTree: [],
requiresAuth: true,
requiredRole: 'user',
},
{
id: 'page_forum_thread',
path: '/forum/thread/:id',
title: 'Forum Thread',
level: 2,
componentTree: [],
requiresAuth: true,
requiredRole: 'user',
},
],
workflows: [
{
id: 'workflow_create_thread',
name: 'Create Forum Thread',
description: 'Workflow for creating a new forum thread',
nodes: [],
edges: [],
enabled: true,
},
{
id: 'workflow_post_reply',
name: 'Post Forum Reply',
description: 'Workflow for posting a reply to a thread',
nodes: [],
edges: [],
enabled: true,
},
],
luaScripts: [
{
id: 'lua_forum_thread_count',
name: 'Get Thread Count',
description: 'Count threads in a category',
code: 'function countThreads(categoryId)\n return 0\nend\nreturn countThreads',
parameters: [{ name: 'categoryId', type: 'string' }],
returnType: 'number',
},
],
componentHierarchy: {},
componentConfigs: {},
seedData: {
ForumCategory: [
{ id: 'cat_1', name: 'General Discussion', description: 'Talk about anything', order: 1, icon: '💭', createdAt: Date.now() },
{ id: 'cat_2', name: 'Announcements', description: 'Official announcements', order: 0, icon: '📢', createdAt: Date.now() },
],
},
},
}
})

View File

@@ -0,0 +1,70 @@
import type { PackageContent, PackageManifest } from '../../package-types'
export const guestbookRetroPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
manifest: {
id: 'guestbook-retro',
name: 'Retro Guestbook',
version: '1.0.0',
description: 'Nostalgic 90s-style guestbook with animated GIFs, custom backgrounds, and visitor messages. Perfect for retro-themed websites.',
author: 'MetaBuilder Team',
category: 'content',
icon: '📖',
screenshots: [],
tags: ['guestbook', 'retro', '90s', 'nostalgia'],
dependencies: [],
createdAt: Date.now(),
updatedAt: Date.now(),
downloadCount: 892,
rating: 4.5,
installed: false,
},
content: {
schemas: [
{
name: 'GuestbookEntry',
displayName: 'Guestbook Entry',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'authorName', type: 'string', label: 'Name', required: true },
{ name: 'authorEmail', type: 'string', label: 'Email', required: false },
{ name: 'authorWebsite', type: 'string', label: 'Website', required: false },
{ name: 'message', type: 'text', label: 'Message', required: true },
{ name: 'backgroundColor', type: 'string', label: 'Background Color', required: false },
{ name: 'textColor', type: 'string', label: 'Text Color', required: false },
{ name: 'gifUrl', type: 'string', label: 'GIF URL', required: false },
{ name: 'approved', type: 'boolean', label: 'Approved', required: true, defaultValue: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
],
pages: [
{
id: 'page_guestbook',
path: '/guestbook',
title: 'Guestbook',
level: 1,
componentTree: [],
requiresAuth: false,
},
],
workflows: [],
luaScripts: [],
componentHierarchy: {},
componentConfigs: {},
seedData: {
GuestbookEntry: [
{
id: 'entry_1',
authorName: 'WebMaster99',
authorWebsite: 'http://coolsite.net',
message: 'Cool site! Check out mine too!',
backgroundColor: '#FF00FF',
textColor: '#00FF00',
approved: true,
createdAt: Date.now() - 86400000
},
],
},
},
}
})

View File

@@ -0,0 +1,130 @@
import type { PackageContent, PackageManifest } from '../../package-types'
export const spotifyClonePackage = (): { manifest: PackageManifest; content: PackageContent } => ({
manifest: {
id: 'spotify-clone',
name: 'Music Streaming Platform',
version: '1.0.0',
description: 'Full music streaming service with playlists, albums, artists, search, and playback controls. Create your own Spotify!',
author: 'MetaBuilder Team',
category: 'entertainment',
icon: '🎵',
screenshots: [],
tags: ['music', 'streaming', 'audio', 'spotify'],
dependencies: [],
createdAt: Date.now(),
updatedAt: Date.now(),
downloadCount: 1823,
rating: 4.6,
installed: false,
},
content: {
schemas: [
{
name: 'Artist',
displayName: 'Artist',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'name', type: 'string', label: 'Name', required: true },
{ name: 'bio', type: 'text', label: 'Biography', required: false },
{ name: 'imageUrl', type: 'string', label: 'Image URL', required: false },
{ name: 'genre', type: 'string', label: 'Genre', required: false },
{ name: 'verified', type: 'boolean', label: 'Verified', required: true, defaultValue: false },
{ name: 'followers', type: 'number', label: 'Followers', required: true, defaultValue: 0 },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'Album',
displayName: 'Album',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'title', type: 'string', label: 'Title', required: true },
{ name: 'artistId', type: 'string', label: 'Artist ID', required: true },
{ name: 'coverUrl', type: 'string', label: 'Cover URL', required: false },
{ name: 'releaseDate', type: 'number', label: 'Release Date', required: false },
{ name: 'genre', type: 'string', label: 'Genre', required: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'Track',
displayName: 'Track',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'title', type: 'string', label: 'Title', required: true },
{ name: 'artistId', type: 'string', label: 'Artist ID', required: true },
{ name: 'albumId', type: 'string', label: 'Album ID', required: false },
{ name: 'audioUrl', type: 'string', label: 'Audio URL', required: true },
{ name: 'duration', type: 'number', label: 'Duration (seconds)', required: true },
{ name: 'trackNumber', type: 'number', label: 'Track Number', required: false },
{ name: 'plays', type: 'number', label: 'Play Count', required: true, defaultValue: 0 },
{ name: 'likes', type: 'number', label: 'Likes', required: true, defaultValue: 0 },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'MusicPlaylist',
displayName: 'Playlist',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'name', type: 'string', label: 'Name', required: true },
{ name: 'description', type: 'text', label: 'Description', required: false },
{ name: 'ownerId', type: 'string', label: 'Owner ID', required: true },
{ name: 'coverUrl', type: 'string', label: 'Cover URL', required: false },
{ name: 'trackIds', type: 'json', label: 'Track IDs', required: true },
{ name: 'isPublic', type: 'boolean', label: 'Public', required: true, defaultValue: true },
{ name: 'followers', type: 'number', label: 'Followers', required: true, defaultValue: 0 },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
],
pages: [
{
id: 'page_music_home',
path: '/music',
title: 'Music Home',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_music_search',
path: '/search',
title: 'Search Music',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_music_artist',
path: '/artist/:id',
title: 'Artist',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_music_album',
path: '/album/:id',
title: 'Album',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_music_playlist',
path: '/playlist/:id',
title: 'Playlist',
level: 2,
componentTree: [],
requiresAuth: false,
},
],
workflows: [],
luaScripts: [],
componentHierarchy: {},
componentConfigs: {},
},
}
})

View File

@@ -0,0 +1,121 @@
import type { PackageContent, PackageManifest } from '../../package-types'
export const youtubeClonePackage = (): { manifest: PackageManifest; content: PackageContent } => ({
manifest: {
id: 'youtube-clone',
name: 'Video Platform',
version: '1.0.0',
description: 'Complete video sharing platform with upload, streaming, comments, likes, subscriptions, and playlists. Build your own YouTube!',
author: 'MetaBuilder Team',
category: 'entertainment',
icon: '🎥',
screenshots: [],
tags: ['video', 'streaming', 'media', 'youtube'],
dependencies: [],
createdAt: Date.now(),
updatedAt: Date.now(),
downloadCount: 2156,
rating: 4.8,
installed: false,
},
content: {
schemas: [
{
name: 'Video',
displayName: 'Video',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'title', type: 'string', label: 'Title', required: true },
{ name: 'description', type: 'text', label: 'Description', required: false },
{ name: 'uploaderId', type: 'string', label: 'Uploader ID', required: true },
{ name: 'videoUrl', type: 'string', label: 'Video URL', required: true },
{ name: 'thumbnailUrl', type: 'string', label: 'Thumbnail URL', required: false },
{ name: 'duration', type: 'number', label: 'Duration (seconds)', required: true },
{ name: 'views', type: 'number', label: 'Views', required: true, defaultValue: 0 },
{ name: 'likes', type: 'number', label: 'Likes', required: true, defaultValue: 0 },
{ name: 'dislikes', type: 'number', label: 'Dislikes', required: true, defaultValue: 0 },
{ name: 'category', type: 'string', label: 'Category', required: false },
{ name: 'tags', type: 'json', label: 'Tags', required: false },
{ name: 'published', type: 'boolean', label: 'Published', required: true, defaultValue: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'VideoComment',
displayName: 'Video Comment',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'videoId', type: 'string', label: 'Video ID', required: true },
{ name: 'userId', type: 'string', label: 'User ID', required: true },
{ name: 'content', type: 'text', label: 'Content', required: true },
{ name: 'likes', type: 'number', label: 'Likes', required: true, defaultValue: 0 },
{ name: 'parentId', type: 'string', label: 'Parent Comment ID', required: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'Subscription',
displayName: 'Subscription',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'subscriberId', type: 'string', label: 'Subscriber ID', required: true },
{ name: 'channelId', type: 'string', label: 'Channel ID', required: true },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'Playlist',
displayName: 'Playlist',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'name', type: 'string', label: 'Name', required: true },
{ name: 'description', type: 'text', label: 'Description', required: false },
{ name: 'ownerId', type: 'string', label: 'Owner ID', required: true },
{ name: 'videoIds', type: 'json', label: 'Video IDs', required: true },
{ name: 'isPublic', type: 'boolean', label: 'Public', required: true, defaultValue: true },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
],
pages: [
{
id: 'page_video_home',
path: '/videos',
title: 'Video Home',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_video_watch',
path: '/watch/:id',
title: 'Watch Video',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_video_upload',
path: '/upload',
title: 'Upload Video',
level: 2,
componentTree: [],
requiresAuth: true,
requiredRole: 'user',
},
{
id: 'page_channel',
path: '/channel/:id',
title: 'Channel',
level: 2,
componentTree: [],
requiresAuth: false,
},
],
workflows: [],
luaScripts: [],
componentHierarchy: {},
componentConfigs: {},
},
}
})

View File

@@ -0,0 +1,108 @@
import type { PackageContent, PackageManifest } from '../../package-types'
export const ecommerceBasicPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
manifest: {
id: 'ecommerce-basic',
name: 'E-Commerce Store',
version: '1.0.0',
description: 'Complete online store with products, shopping cart, checkout, orders, and inventory management. Start selling online!',
author: 'MetaBuilder Team',
category: 'ecommerce',
icon: '🛒',
screenshots: [],
tags: ['ecommerce', 'shop', 'store', 'products'],
dependencies: [],
createdAt: Date.now(),
updatedAt: Date.now(),
downloadCount: 2341,
rating: 4.7,
installed: false,
},
content: {
schemas: [
{
name: 'Product',
displayName: 'Product',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'name', type: 'string', label: 'Name', required: true },
{ name: 'description', type: 'text', label: 'Description', required: false },
{ name: 'price', type: 'number', label: 'Price', required: true },
{ name: 'salePrice', type: 'number', label: 'Sale Price', required: false },
{ name: 'imageUrl', type: 'string', label: 'Image URL', required: false },
{ name: 'category', type: 'string', label: 'Category', required: false },
{ name: 'stock', type: 'number', label: 'Stock Quantity', required: true, defaultValue: 0 },
{ name: 'sku', type: 'string', label: 'SKU', required: false },
{ name: 'featured', type: 'boolean', label: 'Featured', required: true, defaultValue: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'Cart',
displayName: 'Shopping Cart',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'userId', type: 'string', label: 'User ID', required: true },
{ name: 'items', type: 'json', label: 'Items', required: true },
{ name: 'totalAmount', type: 'number', label: 'Total Amount', required: true, defaultValue: 0 },
{ name: 'updatedAt', type: 'number', label: 'Updated At', required: true },
],
},
{
name: 'Order',
displayName: 'Order',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'userId', type: 'string', label: 'User ID', required: true },
{ name: 'items', type: 'json', label: 'Items', required: true },
{ name: 'totalAmount', type: 'number', label: 'Total Amount', required: true },
{ name: 'status', type: 'string', label: 'Status', required: true },
{ name: 'shippingAddress', type: 'json', label: 'Shipping Address', required: true },
{ name: 'paymentMethod', type: 'string', label: 'Payment Method', required: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
],
pages: [
{
id: 'page_shop_home',
path: '/shop',
title: 'Shop',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_product_detail',
path: '/product/:id',
title: 'Product Details',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_cart',
path: '/cart',
title: 'Shopping Cart',
level: 2,
componentTree: [],
requiresAuth: true,
requiredRole: 'user',
},
{
id: 'page_checkout',
path: '/checkout',
title: 'Checkout',
level: 2,
componentTree: [],
requiresAuth: true,
requiredRole: 'user',
},
],
workflows: [],
luaScripts: [],
componentHierarchy: {},
componentConfigs: {},
},
}
})

View File

@@ -0,0 +1,509 @@
import type { PackageContent, PackageManifest } from '../../package-types'
export const ircWebchatPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
manifest: {
id: 'irc-webchat',
name: 'IRC-Style Webchat',
version: '1.0.0',
description: 'Classic IRC-style webchat with channels, commands, online users, and real-time messaging. Perfect for community chat rooms.',
author: 'MetaBuilder Team',
category: 'social',
icon: '💬',
screenshots: [],
tags: ['chat', 'irc', 'messaging', 'realtime'],
dependencies: [],
createdAt: Date.now(),
updatedAt: Date.now(),
downloadCount: 1543,
rating: 4.8,
installed: false,
},
content: {
schemas: [
{
name: 'ChatChannel',
displayName: 'Chat Channel',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'name', type: 'string', label: 'Channel Name', required: true },
{ name: 'description', type: 'text', label: 'Description', required: false },
{ name: 'topic', type: 'string', label: 'Channel Topic', required: false },
{ name: 'isPrivate', type: 'boolean', label: 'Private', required: false, defaultValue: false },
{ name: 'createdBy', type: 'string', label: 'Created By', required: true },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'ChatMessage',
displayName: 'Chat Message',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'channelId', type: 'string', label: 'Channel ID', required: true },
{ name: 'username', type: 'string', label: 'Username', required: true },
{ name: 'userId', type: 'string', label: 'User ID', required: true },
{ name: 'message', type: 'text', label: 'Message', required: true },
{ name: 'type', type: 'string', label: 'Message Type', required: true },
{ name: 'timestamp', type: 'number', label: 'Timestamp', required: true },
],
},
{
name: 'ChatUser',
displayName: 'Chat User',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'channelId', type: 'string', label: 'Channel ID', required: true },
{ name: 'username', type: 'string', label: 'Username', required: true },
{ name: 'userId', type: 'string', label: 'User ID', required: true },
{ name: 'joinedAt', type: 'number', label: 'Joined At', required: true },
],
},
],
pages: [
{
id: 'page_chat',
path: '/chat',
title: 'IRC Webchat',
level: 2,
componentTree: [
{
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {
channelName: 'general',
},
children: [],
},
],
requiresAuth: true,
requiredRole: 'user',
},
],
workflows: [
{
id: 'workflow_send_message',
name: 'Send Chat Message',
description: 'Workflow for sending a chat message',
nodes: [],
edges: [],
enabled: true,
},
{
id: 'workflow_join_channel',
name: 'Join Channel',
description: 'Workflow for joining a chat channel',
nodes: [],
edges: [],
enabled: true,
},
],
luaScripts: [
{
id: 'lua_irc_send_message',
name: 'Send IRC Message',
description: 'Sends a message to the chat channel',
code: `-- Send IRC Message
function sendMessage(channelId, username, userId, message)
local msgId = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999)
local msg = {
id = msgId,
channelId = channelId,
username = username,
userId = userId,
message = message,
type = "message",
timestamp = os.time() * 1000
}
log("Sending message: " .. message)
return msg
end
return sendMessage`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
{ name: 'message', type: 'string' },
],
returnType: 'table',
},
{
id: 'lua_irc_handle_command',
name: 'Handle IRC Command',
description: 'Processes IRC commands like /help, /users, etc',
code: `-- Handle IRC Command
function handleCommand(command, channelId, username, onlineUsers)
local parts = {}
for part in string.gmatch(command, "%S+") do
table.insert(parts, part)
end
local cmd = parts[1]:lower()
local response = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
username = "System",
userId = "system",
type = "system",
timestamp = os.time() * 1000,
channelId = channelId
}
if cmd == "/help" then
response.message = "Available commands: /help, /users, /clear, /me <action>"
elseif cmd == "/users" then
local userCount = #onlineUsers
local userList = table.concat(onlineUsers, ", ")
response.message = "Online users (" .. userCount .. "): " .. userList
elseif cmd == "/clear" then
response.message = "CLEAR_MESSAGES"
response.type = "command"
elseif cmd == "/me" then
if #parts > 1 then
local action = table.concat(parts, " ", 2)
response.message = action
response.username = username
response.userId = username
response.type = "system"
else
response.message = "Usage: /me <action>"
end
else
response.message = "Unknown command: " .. cmd .. ". Type /help for available commands."
end
return response
end
return handleCommand`,
parameters: [
{ name: 'command', type: 'string' },
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'onlineUsers', type: 'table' },
],
returnType: 'table',
},
{
id: 'lua_irc_format_time',
name: 'Format Timestamp',
description: 'Formats a timestamp for display',
code: `-- Format Timestamp
function formatTime(timestamp)
local date = os.date("*t", timestamp / 1000)
local hour = date.hour
local ampm = "AM"
if hour >= 12 then
ampm = "PM"
if hour > 12 then
hour = hour - 12
end
end
if hour == 0 then
hour = 12
end
return string.format("%02d:%02d %s", hour, date.min, ampm)
end
return formatTime`,
parameters: [
{ name: 'timestamp', type: 'number' },
],
returnType: 'string',
},
{
id: 'lua_irc_user_join',
name: 'User Join Channel',
description: 'Handles user joining a channel',
code: `-- User Join Channel
function userJoin(channelId, username, userId)
local joinMsg = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
channelId = channelId,
username = "System",
userId = "system",
message = username .. " has joined the channel",
type = "join",
timestamp = os.time() * 1000
}
log(username .. " joined channel " .. channelId)
return joinMsg
end
return userJoin`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
],
returnType: 'table',
},
{
id: 'lua_irc_user_leave',
name: 'User Leave Channel',
description: 'Handles user leaving a channel',
code: `-- User Leave Channel
function userLeave(channelId, username, userId)
local leaveMsg = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
channelId = channelId,
username = "System",
userId = "system",
message = username .. " has left the channel",
type = "leave",
timestamp = os.time() * 1000
}
log(username .. " left channel " .. channelId)
return leaveMsg
end
return userLeave`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
],
returnType: 'table',
},
],
componentHierarchy: {
page_chat: {
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {},
children: [],
},
},
componentConfigs: {
IRCWebchat: {
type: 'IRCWebchat',
category: 'social',
label: 'IRC Webchat',
description: 'IRC-style chat component with channels and commands',
icon: '💬',
props: [
{
name: 'channelName',
type: 'string',
label: 'Channel Name',
defaultValue: 'general',
required: false,
},
{
name: 'showSettings',
type: 'boolean',
label: 'Show Settings',
defaultValue: false,
required: false,
},
{
name: 'height',
type: 'string',
label: 'Height',
defaultValue: '600px',
required: false,
},
],
config: {
layout: 'Card',
styling: {
className: 'h-[600px] flex flex-col',
},
children: [
{
id: 'header',
type: 'CardHeader',
props: {
className: 'border-b border-border pb-3',
},
children: [
{
id: 'title_container',
type: 'Flex',
props: {
className: 'flex items-center justify-between',
},
children: [
{
id: 'title',
type: 'CardTitle',
props: {
className: 'flex items-center gap-2 text-lg',
content: '#{channelName}',
},
},
{
id: 'actions',
type: 'Flex',
props: {
className: 'flex items-center gap-2',
},
children: [
{
id: 'user_badge',
type: 'Badge',
props: {
variant: 'secondary',
className: 'gap-1.5',
icon: 'Users',
content: '{onlineUsersCount}',
},
},
{
id: 'settings_button',
type: 'Button',
props: {
size: 'sm',
variant: 'ghost',
icon: 'Gear',
onClick: 'toggleSettings',
},
},
],
},
],
},
],
},
{
id: 'content',
type: 'CardContent',
props: {
className: 'flex-1 flex flex-col p-0 overflow-hidden',
},
children: [
{
id: 'main_area',
type: 'Flex',
props: {
className: 'flex flex-1 overflow-hidden',
},
children: [
{
id: 'messages_area',
type: 'ScrollArea',
props: {
className: 'flex-1 p-4',
},
children: [
{
id: 'messages_container',
type: 'MessageList',
props: {
className: 'space-y-2 font-mono text-sm',
dataSource: 'messages',
itemRenderer: 'renderMessage',
},
},
],
},
{
id: 'sidebar',
type: 'Container',
props: {
className: 'w-48 border-l border-border p-4 bg-muted/20',
conditional: 'showSettings',
},
children: [
{
id: 'sidebar_title',
type: 'Heading',
props: {
level: '4',
className: 'font-semibold text-sm mb-3',
content: 'Online Users',
},
},
{
id: 'users_list',
type: 'UserList',
props: {
className: 'space-y-1.5 text-sm',
dataSource: 'onlineUsers',
},
},
],
},
],
},
{
id: 'input_area',
type: 'Container',
props: {
className: 'border-t border-border p-4',
},
children: [
{
id: 'input_row',
type: 'Flex',
props: {
className: 'flex gap-2',
},
children: [
{
id: 'message_input',
type: 'Input',
props: {
className: 'flex-1 font-mono',
placeholder: 'Type a message... (/help for commands)',
onKeyPress: 'handleKeyPress',
value: '{inputMessage}',
onChange: 'updateInputMessage',
},
},
{
id: 'send_button',
type: 'Button',
props: {
size: 'icon',
icon: 'PaperPlaneTilt',
onClick: 'handleSendMessage',
},
},
],
},
{
id: 'help_text',
type: 'Text',
props: {
className: 'text-xs text-muted-foreground mt-2',
content: 'Press Enter to send. Type /help for commands.',
},
},
],
},
],
},
],
},
},
},
seedData: {
ChatChannel: [
{
id: 'channel_general',
name: 'general',
description: 'General discussion',
topic: 'Welcome to the general chat!',
isPrivate: false,
createdBy: 'system',
createdAt: Date.now(),
},
{
id: 'channel_random',
name: 'random',
description: 'Random conversations',
topic: 'Talk about anything here',
isPrivate: false,
createdBy: 'system',
createdAt: Date.now(),
},
],
},
},
},
}
})

View File

@@ -0,0 +1,114 @@
import type { PackageContent, PackageManifest } from '../../package-types'
export const retroGamesPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
manifest: {
id: 'retro-games',
name: 'Retro Games Arcade',
version: '1.0.0',
description: 'Classic arcade games collection with high scores, leaderboards, and achievements. Includes Snake, Tetris, Pong, and more!',
author: 'MetaBuilder Team',
category: 'gaming',
icon: '🕹️',
screenshots: [],
tags: ['games', 'arcade', 'retro', 'entertainment'],
dependencies: [],
createdAt: Date.now(),
updatedAt: Date.now(),
downloadCount: 1567,
rating: 4.9,
installed: false,
},
content: {
schemas: [
{
name: 'Game',
displayName: 'Game',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'name', type: 'string', label: 'Name', required: true },
{ name: 'description', type: 'text', label: 'Description', required: false },
{ name: 'thumbnailUrl', type: 'string', label: 'Thumbnail URL', required: false },
{ name: 'gameType', type: 'string', label: 'Game Type', required: true },
{ name: 'difficulty', type: 'string', label: 'Difficulty', required: false },
{ name: 'playCount', type: 'number', label: 'Play Count', required: true, defaultValue: 0 },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'HighScore',
displayName: 'High Score',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'gameId', type: 'string', label: 'Game ID', required: true },
{ name: 'userId', type: 'string', label: 'User ID', required: true },
{ name: 'playerName', type: 'string', label: 'Player Name', required: true },
{ name: 'score', type: 'number', label: 'Score', required: true },
{ name: 'level', type: 'number', label: 'Level Reached', required: false },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'Achievement',
displayName: 'Achievement',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'name', type: 'string', label: 'Name', required: true },
{ name: 'description', type: 'text', label: 'Description', required: false },
{ name: 'gameId', type: 'string', label: 'Game ID', required: true },
{ name: 'iconUrl', type: 'string', label: 'Icon URL', required: false },
{ name: 'requirement', type: 'string', label: 'Requirement', required: true },
{ name: 'points', type: 'number', label: 'Points', required: true, defaultValue: 10 },
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
],
},
{
name: 'UserAchievement',
displayName: 'User Achievement',
fields: [
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
{ name: 'userId', type: 'string', label: 'User ID', required: true },
{ name: 'achievementId', type: 'string', label: 'Achievement ID', required: true },
{ name: 'unlockedAt', type: 'number', label: 'Unlocked At', required: true },
],
},
],
pages: [
{
id: 'page_arcade_home',
path: '/arcade',
title: 'Arcade Home',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_game_play',
path: '/arcade/play/:id',
title: 'Play Game',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page_leaderboard',
path: '/arcade/leaderboard',
title: 'Leaderboard',
level: 2,
componentTree: [],
requiresAuth: false,
},
],
workflows: [],
luaScripts: [],
componentHierarchy: {},
componentConfigs: {},
seedData: {
Game: [
{ id: 'game_snake', name: 'Snake', description: 'Classic snake game', gameType: 'snake', difficulty: 'medium', playCount: 0, createdAt: Date.now() },
{ id: 'game_tetris', name: 'Tetris', description: 'Block-stacking puzzle', gameType: 'tetris', difficulty: 'medium', playCount: 0, createdAt: Date.now() },
{ id: 'game_pong', name: 'Pong', description: 'Classic paddle game', gameType: 'pong', difficulty: 'easy', playCount: 0, createdAt: Date.now() },
],
},
},
}
})

View File

@@ -4,6 +4,8 @@ import { PACKAGE_CATALOG } from '../../package-lib/package-catalog'
* Get the content of a package by its ID
*/
export function getPackageContent(packageId: string) {
const pkg = PACKAGE_CATALOG[packageId]
return pkg ? pkg.content : null
const packageEntry = PACKAGE_CATALOG[packageId]
const packageData = packageEntry?.()
return packageData ? packageData.content : null
}

View File

@@ -4,6 +4,8 @@ import { PACKAGE_CATALOG } from '../../package-lib/package-catalog'
* Get the manifest of a package by its ID
*/
export function getPackageManifest(packageId: string) {
const pkg = PACKAGE_CATALOG[packageId]
return pkg ? pkg.manifest : null
const packageEntry = PACKAGE_CATALOG[packageId]
const packageData = packageEntry?.()
return packageData ? packageData.manifest : null
}

View File

@@ -35,8 +35,10 @@ export async function initializePackageSystem(): Promise<void> {
// Load legacy packages from catalog for backward compatibility
Object.values(PACKAGE_CATALOG).forEach((pkg) => {
if (pkg.content) {
loadPackageComponents(pkg.content)
const packageData = pkg()
if (packageData.content) {
loadPackageComponents(packageData.content)
}
})

View File

@@ -1,15 +1,9 @@
import 'server-only'
import { PACKAGE_CATALOG } from '@/lib/package-catalog'
import type { PackageContent, PackageManifest } from '@/lib/package-types'
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/package-catalog'
export type PackageCatalogEntry = {
manifest: PackageManifest
content: PackageContent
}
export function getPackageCatalogEntry(packageId: string): PackageCatalogEntry | null {
export function getPackageCatalogEntry(packageId: string): PackageCatalogData | null {
const entry = PACKAGE_CATALOG[packageId]
if (!entry) return null
return entry
return entry()
}