Merge branch 'main' into codex/create-and-organize-test-files-a02kqy

This commit is contained in:
2025-12-29 16:10:01 +00:00
committed by GitHub
137 changed files with 7992 additions and 5632 deletions
+8
View File
@@ -0,0 +1,8 @@
import type { DBALConfig } from '../runtime/config'
import { DBALClient } from './client/client'
export { buildAdapter, buildEntityOperations } from './client/builders'
export { normalizeClientConfig, validateClientConfig } from './client/mappers'
export const createDBALClient = (config: DBALConfig) => new DBALClient(config)
export { DBALClient }
@@ -0,0 +1,24 @@
import type { DBALAdapter } from '../../adapters/adapter'
import type { DBALConfig } from '../../runtime/config'
import { createAdapter } from './adapter-factory'
import {
createComponentOperations,
createLuaScriptOperations,
createPackageOperations,
createPageOperations,
createSessionOperations,
createUserOperations,
createWorkflowOperations
} from '../entities'
export const buildAdapter = (config: DBALConfig): DBALAdapter => createAdapter(config)
export const buildEntityOperations = (adapter: DBALAdapter) => ({
users: createUserOperations(adapter),
pages: createPageOperations(adapter),
components: createComponentOperations(adapter),
workflows: createWorkflowOperations(adapter),
luaScripts: createLuaScriptOperations(adapter),
packages: createPackageOperations(adapter),
sessions: createSessionOperations(adapter)
})
+14 -29
View File
@@ -1,7 +1,7 @@
/**
* @file client.ts
* @description DBAL Client - Main interface for database operations
*
*
* Provides CRUD operations for all entities through modular operation handlers.
* Each entity type has its own dedicated operations module following the
* single-responsibility pattern.
@@ -9,82 +9,67 @@
import type { DBALConfig } from '../../runtime/config'
import type { DBALAdapter } from '../../adapters/adapter'
import { createAdapter } from './adapter-factory'
import {
createUserOperations,
createPageOperations,
createComponentOperations,
createWorkflowOperations,
createLuaScriptOperations,
createPackageOperations,
createSessionOperations,
} from '../entities'
import { buildAdapter, buildEntityOperations } from './builders'
import { normalizeClientConfig, validateClientConfig } from './mappers'
export class DBALClient {
private adapter: DBALAdapter
private config: DBALConfig
private operations: ReturnType<typeof buildEntityOperations>
constructor(config: DBALConfig) {
this.config = config
// Validate configuration
if (!config.adapter) {
throw new Error('Adapter type must be specified')
}
if (config.mode !== 'production' && !config.database?.url) {
throw new Error('Database URL must be specified for non-production mode')
}
this.adapter = createAdapter(config)
this.config = normalizeClientConfig(validateClientConfig(config))
this.adapter = buildAdapter(this.config)
this.operations = buildEntityOperations(this.adapter)
}
/**
* User entity operations
*/
get users() {
return createUserOperations(this.adapter)
return this.operations.users
}
/**
* Page entity operations
*/
get pages() {
return createPageOperations(this.adapter)
return this.operations.pages
}
/**
* Component hierarchy entity operations
*/
get components() {
return createComponentOperations(this.adapter)
return this.operations.components
}
/**
* Workflow entity operations
*/
get workflows() {
return createWorkflowOperations(this.adapter)
return this.operations.workflows
}
/**
* Lua script entity operations
*/
get luaScripts() {
return createLuaScriptOperations(this.adapter)
return this.operations.luaScripts
}
/**
* Package entity operations
*/
get packages() {
return createPackageOperations(this.adapter)
return this.operations.packages
}
/**
* Session entity operations
*/
get sessions() {
return createSessionOperations(this.adapter)
return this.operations.sessions
}
/**
@@ -0,0 +1,25 @@
import type { DBALConfig } from '../../runtime/config'
import { DBALError } from '../foundation/errors'
export const validateClientConfig = (config: DBALConfig): DBALConfig => {
if (!config.adapter) {
throw DBALError.validationError('Adapter type must be specified', [])
}
if (config.mode !== 'production' && !config.database?.url) {
throw DBALError.validationError('Database URL must be specified for non-production mode', [])
}
return config
}
export const normalizeClientConfig = (config: DBALConfig): DBALConfig => ({
...config,
security: {
sandbox: config.security?.sandbox ?? 'strict',
enableAuditLog: config.security?.enableAuditLog ?? true
},
performance: {
...config.performance
}
})
+1 -1
View File
@@ -1,4 +1,4 @@
export { DBALClient } from './core/client/client'
export { DBALClient, createDBALClient } from './core/client'
export type { DBALConfig } from './runtime/config'
export type * from './core/foundation/types'
export { DBALError, DBALErrorCode } from './core/foundation/errors'
+64
View File
@@ -0,0 +1,64 @@
import * as ts from 'typescript'
import { Detector, DetectionFinding, DetectorContext } from '..'
const getLocation = (sourceFile: ts.SourceFile, node: ts.Node) => {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
return {
line: line + 1,
column: character + 1
}
}
const getClassName = (
node: ts.ClassDeclaration | ts.ClassExpression,
sourceFile: ts.SourceFile
): string => {
if (node.name) {
return node.name.getText(sourceFile)
}
const parent = node.parent
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
return 'anonymous'
}
const collectClasses = (context: DetectorContext): DetectionFinding[] => {
const sourceFile = ts.createSourceFile(
context.filePath,
context.source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
const findings: DetectionFinding[] = []
const visit = (node: ts.Node) => {
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
const name = getClassName(node, sourceFile)
findings.push({
detectorId: 'class-detector',
name,
message: `Class detected: ${name}`,
location: getLocation(sourceFile, node)
})
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return findings
}
export const classDetector: Detector = {
id: 'class-detector',
description: 'Detects class declarations and expressions within a TypeScript/TSX source file.',
detect: collectClasses
}
+78
View File
@@ -0,0 +1,78 @@
import * as ts from 'typescript'
import { Detector, DetectionFinding, DetectorContext } from '..'
type FunctionLike =
| ts.FunctionDeclaration
| ts.FunctionExpression
| ts.ArrowFunction
| ts.MethodDeclaration
| ts.ConstructorDeclaration
const getLocation = (sourceFile: ts.SourceFile, node: ts.Node) => {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
return {
line: line + 1,
column: character + 1
}
}
const getFunctionName = (node: FunctionLike, sourceFile: ts.SourceFile): string => {
if ('name' in node && node.name) {
return node.name.getText(sourceFile)
}
const parent = node.parent
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
return 'anonymous'
}
const collectFunctions = (context: DetectorContext): DetectionFinding[] => {
const sourceFile = ts.createSourceFile(
context.filePath,
context.source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
const findings: DetectionFinding[] = []
const visit = (node: ts.Node) => {
if (
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node)
) {
const name = getFunctionName(node, sourceFile)
findings.push({
detectorId: 'function-detector',
name,
message: `Function detected: ${name}`,
location: getLocation(sourceFile, node)
})
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return findings
}
export const functionDetector: Detector = {
id: 'function-detector',
description: 'Detects functions and methods within a TypeScript/TSX source file.',
detect: collectFunctions
}
+45
View File
@@ -0,0 +1,45 @@
import { classDetector } from './detectors/class-detector'
import { functionDetector } from './detectors/function-detector'
export type DetectorContext = {
filePath: string
source: string
}
export type DetectionFinding = {
detectorId: string
name: string
message: string
location?: {
line: number
column: number
}
}
export interface Detector {
id: string
description: string
detect: (context: DetectorContext) => DetectionFinding[]
}
export class DetectorRegistry {
private readonly detectors: Detector[] = []
register(detector: Detector): void {
this.detectors.push(detector)
}
list(): Detector[] {
return [...this.detectors]
}
run(context: DetectorContext): DetectionFinding[] {
return this.detectors.flatMap((detector) => detector.detect(context))
}
}
export const registry = new DetectorRegistry()
const builtInDetectors: Detector[] = [functionDetector, classDetector]
builtInDetectors.forEach((detector) => registry.register(detector))
@@ -0,0 +1,83 @@
import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
export interface GodCredentialsFormProps {
duration: number
unit: 'minutes' | 'hours'
onDurationChange: (value: number) => void
onUnitChange: (unit: 'minutes' | 'hours') => void
onSave: () => void
onResetExpiry: () => void
onClearExpiry: () => void
}
export function GodCredentialsForm({
duration,
unit,
onDurationChange,
onUnitChange,
onSave,
onResetExpiry,
onClearExpiry,
}: GodCredentialsFormProps) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="duration">Expiry Duration</Label>
<div className="flex gap-2">
<Input
id="duration"
type="number"
min="1"
max={unit === 'hours' ? '24' : '1440'}
value={duration}
onChange={(e) => onDurationChange(Number(e.target.value))}
className="flex-1"
/>
<Select value={unit} onValueChange={(value) => onUnitChange(value as 'minutes' | 'hours')}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Set the duration for how long credentials are visible (1 minute to 24 hours)
</p>
</div>
<div className="flex gap-2">
<Button onClick={onSave} className="flex-1">
Save Duration
</Button>
</div>
</div>
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<Label>Expiry Management</Label>
<p className="text-xs text-muted-foreground">
Reset or clear the current expiry timer
</p>
</div>
<div className="flex gap-2">
<Button onClick={onResetExpiry} variant="outline" className="flex-1">
Reset Timer
</Button>
<Button onClick={onClearExpiry} variant="outline" className="flex-1">
Clear Expiry
</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>Reset Timer:</strong> Restart the countdown using the configured duration<br />
<strong>Clear Expiry:</strong> Remove expiry time (credentials will show on next page load)
</p>
</div>
</div>
)
}
@@ -0,0 +1,42 @@
import { Alert, AlertDescription, Badge } from '@/components/ui'
import { CheckCircle, WarningCircle } from '@phosphor-icons/react'
export interface GodCredentialsSummaryProps {
isActive: boolean
expiryTime: number
timeRemaining: string
}
export function GodCredentialsSummary({ isActive, expiryTime, timeRemaining }: GodCredentialsSummaryProps) {
if (isActive) {
return (
<Alert className="bg-gradient-to-br from-purple-500/10 to-orange-500/10 border-purple-500/50">
<CheckCircle className="h-5 w-5 text-green-500" />
<AlertDescription className="ml-2">
<div className="space-y-1">
<p className="font-semibold text-sm flex items-center gap-2">
God credentials are currently visible
<Badge variant="secondary" className="font-mono">Active</Badge>
</p>
<p className="text-xs text-muted-foreground">
Time remaining: <span className="font-mono font-semibold">{timeRemaining}</span>
</p>
</div>
</AlertDescription>
</Alert>
)
}
if (!isActive && expiryTime > 0) {
return (
<Alert>
<WarningCircle className="h-5 w-5 text-yellow-500" />
<AlertDescription className="ml-2">
<p className="text-sm">God credentials have expired or been hidden</p>
</AlertDescription>
</Alert>
)
}
return null
}
@@ -0,0 +1,48 @@
import { Button, Input, Label, Alert, AlertDescription } from '@/components/ui'
import { SignIn } from '@phosphor-icons/react'
export interface LoginFormProps {
username: string
password: string
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
}
export function LoginForm({ username, password, onUsernameChange, onPasswordChange, onSubmit }: LoginFormProps) {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="login-username">Username</Label>
<Input
id="login-username"
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
placeholder="Enter username"
onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="login-password">Password</Label>
<Input
id="login-password"
type="password"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
placeholder="Enter password"
onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
/>
</div>
<Button className="w-full" onClick={onSubmit}>
<SignIn className="mr-2" size={16} />
Sign In
</Button>
<Alert>
<AlertDescription className="text-xs">
<p className="font-semibold mb-1">Test Credentials:</p>
<p>Check browser console for default user passwords (they are scrambled on first run)</p>
</AlertDescription>
</Alert>
</div>
)
}
@@ -0,0 +1,50 @@
import { Button, Separator } from '@/components/ui'
import { GoogleLogo, GithubLogo, IconProps } from '@phosphor-icons/react'
export interface Provider {
name: string
description?: string
icon?: React.ComponentType<IconProps>
}
export interface ProviderListProps {
providers: Provider[]
onSelect?: (provider: Provider) => void
}
const FALLBACK_PROVIDERS: Provider[] = [
{ name: 'Google', description: 'Use your Google Workspace account', icon: GoogleLogo },
{ name: 'GitHub', description: 'Developer SSO via GitHub', icon: GithubLogo },
]
export function ProviderList({ providers, onSelect }: ProviderListProps) {
const entries = providers.length > 0 ? providers : FALLBACK_PROVIDERS
return (
<div className="space-y-3">
<Separator />
<p className="text-xs text-muted-foreground text-center">Or continue with</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{entries.map((provider) => {
const Icon = provider.icon
return (
<Button
key={provider.name}
variant="outline"
className="w-full justify-start gap-3"
onClick={() => onSelect?.(provider)}
>
{Icon ? <Icon size={18} /> : null}
<span className="text-sm font-medium">{provider.name}</span>
{provider.description ? (
<span className="text-xs text-muted-foreground block leading-tight text-left">
{provider.description}
</span>
) : null}
</Button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,185 @@
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
import type { InstalledPackage } from '@/lib/package-types'
import { Download, Star, Tag, Trash, User } from '@phosphor-icons/react'
import { DependenciesTab } from './tabs/DependenciesTab'
import { ScriptsTab } from './tabs/ScriptsTab'
interface PackageDetailsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedPackage: PackageCatalogData | null
installing: boolean
onInstall: (packageId: string) => void
onUninstall: (packageId: string) => void
installedPackages: InstalledPackage[]
getCatalogEntry: (packageId: string) => PackageCatalogData | undefined
}
export function PackageDetailsDialog({
open,
onOpenChange,
selectedPackage,
installing,
onInstall,
onUninstall,
installedPackages,
getCatalogEntry,
}: PackageDetailsDialogProps) {
if (!selectedPackage) return null
const { manifest, content } = selectedPackage
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-3xl flex-shrink-0">
{manifest.icon}
</div>
<div className="flex-1">
<DialogTitle className="text-2xl">{manifest.name}</DialogTitle>
<DialogDescription className="mt-1">{manifest.description}</DialogDescription>
<div className="flex items-center gap-3 mt-3">
<Badge variant="secondary">{manifest.category}</Badge>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Download size={14} />
<span>{manifest.downloadCount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Star size={14} weight="fill" className="text-yellow-500" />
<span>{manifest.rating}</span>
</div>
</div>
</div>
</div>
</DialogHeader>
<Separator className="my-4" />
<Tabs defaultValue="overview" className="flex-1 flex flex-col">
<div className="px-1">
<TabsList className="grid grid-cols-3 w-full">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="dependencies">Dependencies</TabsTrigger>
<TabsTrigger value="scripts">Scripts</TabsTrigger>
</TabsList>
</div>
<TabsContent value="overview" className="flex-1 m-0">
<ScrollArea className="h-[50vh]">
<div className="space-y-6 pr-4">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="font-semibold mb-2">Author</h4>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<User size={16} />
<span>{manifest.author}</span>
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Version</h4>
<p className="text-sm text-muted-foreground">{manifest.version}</p>
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Tags</h4>
<div className="flex flex-wrap gap-2">
{manifest.tags.map(tag => (
<Badge key={tag} variant="outline">
<Tag size={12} className="mr-1" />
{tag}
</Badge>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Includes</h4>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Data Models</div>
<div className="text-2xl font-bold text-primary">{content.schemas.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Pages</div>
<div className="text-2xl font-bold text-primary">{content.pages.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Workflows</div>
<div className="text-2xl font-bold text-primary">{content.workflows.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Scripts</div>
<div className="text-2xl font-bold text-primary">{content.luaScripts.length}</div>
</div>
</div>
</div>
{content.schemas.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Data Models</h4>
<div className="space-y-2">
{content.schemas.map(schema => (
<div key={schema.name} className="p-3 rounded-lg border">
<div className="font-medium">{schema.displayName || schema.name}</div>
<div className="text-sm text-muted-foreground">{schema.fields.length} fields</div>
</div>
))}
</div>
</div>
)}
{content.pages.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Pages</h4>
<div className="space-y-2">
{content.pages.map(page => (
<div key={page.id} className="p-3 rounded-lg border">
<div className="font-medium">{page.title}</div>
<div className="text-sm text-muted-foreground font-mono">{page.path}</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="dependencies" className="flex-1 m-0">
<ScrollArea className="h-[50vh] pr-4">
<DependenciesTab
dependencies={manifest.dependencies}
installedPackages={installedPackages}
resolveCatalogEntry={getCatalogEntry}
/>
</ScrollArea>
</TabsContent>
<TabsContent value="scripts" className="flex-1 m-0">
<ScrollArea className="h-[50vh] pr-4">
<ScriptsTab scripts={content.luaScripts} />
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter className="mt-4">
{manifest.installed ? (
<Button variant="destructive" onClick={() => onUninstall(manifest.id)}>
<Trash size={16} className="mr-2" />
Uninstall
</Button>
) : (
<Button onClick={() => onInstall(manifest.id)} disabled={installing}>
<Download size={16} className="mr-2" />
{installing ? 'Installing...' : 'Install Package'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,12 +1,12 @@
import { useState } from 'react'
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator } from '@/components/ui'
import { toast } from 'sonner'
import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
import { Button } from '@/components/ui'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
import { ArrowSquareIn, Download, Export, Package, Star, Tag, Trash, User } from '@phosphor-icons/react'
import { ArrowSquareIn, Export, Package } from '@phosphor-icons/react'
import { PackageDetailsDialog } from './PackageDetailsDialog'
import { PackageImportExport } from './PackageImportExport'
import { PackageFilters } from './package-manager/PackageFilters'
import { PackageTabs } from './package-manager/PackageTabs'
import { usePackageActions } from './package-manager/usePackageActions'
import { usePackages } from './package-manager/usePackages'
interface PackageManagerProps {
@@ -31,61 +31,12 @@ export function PackageManager({ onClose }: PackageManagerProps) {
} = usePackages()
const [selectedPackage, setSelectedPackage] = useState<PackageCatalogData | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [installing, setInstalling] = useState(false)
const [showImportExport, setShowImportExport] = useState(false)
const [importExportMode, setImportExportMode] = useState<'import' | 'export'>('export')
const handleInstallPackage = async (packageId: string) => {
setInstalling(true)
try {
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return
}
await installPackage(packageId)
toast.success(`${packageEntry.manifest.name} installed successfully!`)
await loadPackages()
setShowDetails(false)
} catch (error) {
console.error('Installation error:', error)
toast.error('Failed to install package')
} finally {
setInstalling(false)
}
}
const handleUninstallPackage = async (packageId: string) => {
try {
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return
}
await uninstallPackage(packageId)
toast.success(`${packageEntry.manifest.name} uninstalled successfully!`)
await loadPackages()
setShowDetails(false)
} catch (error) {
console.error('Uninstallation error:', error)
toast.error('Failed to uninstall package')
}
}
const handleTogglePackage = async (packageId: string, enabled: boolean) => {
try {
await togglePackageEnabled(packageId, enabled)
toast.success(enabled ? 'Package enabled' : 'Package disabled')
await loadPackages()
} catch (error) {
console.error('Toggle error:', error)
toast.error('Failed to toggle package')
}
}
const { installing, handleInstallPackage, handleUninstallPackage, handleTogglePackage } = usePackageActions({
loadPackages,
getCatalogEntry,
})
const openPackageDetails = (packageId: string) => {
const catalogEntry = getCatalogEntry(packageId)
@@ -162,131 +113,16 @@ export function PackageManager({ onClose }: PackageManagerProps) {
/>
</div>
<Dialog open={showDetails} onOpenChange={setShowDetails}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
{selectedPackage && (
<>
<DialogHeader>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-3xl flex-shrink-0">
{selectedPackage.manifest.icon}
</div>
<div className="flex-1">
<DialogTitle className="text-2xl">{selectedPackage.manifest.name}</DialogTitle>
<DialogDescription className="mt-1">{selectedPackage.manifest.description}</DialogDescription>
<div className="flex items-center gap-3 mt-3">
<Badge variant="secondary">{selectedPackage.manifest.category}</Badge>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Download size={14} />
<span>{selectedPackage.manifest.downloadCount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Star size={14} weight="fill" className="text-yellow-500" />
<span>{selectedPackage.manifest.rating}</span>
</div>
</div>
</div>
</div>
</DialogHeader>
<Separator className="my-4" />
<ScrollArea className="flex-1">
<div className="space-y-6 pr-4">
<div>
<h4 className="font-semibold mb-2">Author</h4>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<User size={16} />
<span>{selectedPackage.manifest.author}</span>
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Version</h4>
<p className="text-sm text-muted-foreground">{selectedPackage.manifest.version}</p>
</div>
<div>
<h4 className="font-semibold mb-2">Tags</h4>
<div className="flex flex-wrap gap-2">
{selectedPackage.manifest.tags.map(tag => (
<Badge key={tag} variant="outline">
<Tag size={12} className="mr-1" />
{tag}
</Badge>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Includes</h4>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Data Models</div>
<div className="text-2xl font-bold text-primary">{selectedPackage.content.schemas.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Pages</div>
<div className="text-2xl font-bold text-primary">{selectedPackage.content.pages.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Workflows</div>
<div className="text-2xl font-bold text-primary">{selectedPackage.content.workflows.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Scripts</div>
<div className="text-2xl font-bold text-primary">{selectedPackage.content.luaScripts.length}</div>
</div>
</div>
</div>
{selectedPackage.content.schemas.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Data Models</h4>
<div className="space-y-2">
{selectedPackage.content.schemas.map(schema => (
<div key={schema.name} className="p-3 rounded-lg border">
<div className="font-medium">{schema.displayName || schema.name}</div>
<div className="text-sm text-muted-foreground">{schema.fields.length} fields</div>
</div>
))}
</div>
</div>
)}
{selectedPackage.content.pages.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Pages</h4>
<div className="space-y-2">
{selectedPackage.content.pages.map(page => (
<div key={page.id} className="p-3 rounded-lg border">
<div className="font-medium">{page.title}</div>
<div className="text-sm text-muted-foreground font-mono">{page.path}</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
<DialogFooter className="mt-4">
{selectedPackage.manifest.installed ? (
<Button variant="destructive" onClick={() => handleUninstallPackage(selectedPackage.manifest.id)}>
<Trash size={16} className="mr-2" />
Uninstall
</Button>
) : (
<Button onClick={() => handleInstallPackage(selectedPackage.manifest.id)} disabled={installing}>
<Download size={16} className="mr-2" />
{installing ? 'Installing...' : 'Install Package'}
</Button>
)}
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<PackageDetailsDialog
open={showDetails}
onOpenChange={setShowDetails}
selectedPackage={selectedPackage}
installing={installing}
onInstall={(packageId) => handleInstallPackage(packageId, () => setShowDetails(false))}
onUninstall={(packageId) => handleUninstallPackage(packageId, () => setShowDetails(false))}
installedPackages={installedPackages}
getCatalogEntry={getCatalogEntry}
/>
<PackageImportExport
open={showImportExport}
@@ -1,29 +1,24 @@
import type React from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
import { Label } from '@/components/ui'
import { Input } from '@/components/ui'
import { Textarea } from '@/components/ui'
import { Checkbox } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Separator } from '@/components/ui'
import {
Button,
Card,
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
ScrollArea,
Separator,
} from '@/components/ui'
import { Export, Package, Database as DatabaseIcon, FileArrowDown } from '@phosphor-icons/react'
import type { PackageManifest } from '@/lib/package-types'
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
const exportOptionLabels: { key: keyof ExportPackageOptions; label: string }[] = [
{ key: 'includeSchemas', label: 'Include data schemas' },
{ key: 'includePages', label: 'Include page configurations' },
{ key: 'includeWorkflows', label: 'Include workflows' },
{ key: 'includeLuaScripts', label: 'Include Lua scripts' },
{ key: 'includeComponentHierarchy', label: 'Include component hierarchies' },
{ key: 'includeComponentConfigs', label: 'Include component configurations' },
{ key: 'includeCssClasses', label: 'Include CSS classes' },
{ key: 'includeDropdownConfigs', label: 'Include dropdown configurations' },
{ key: 'includeSeedData', label: 'Include seed data' },
{ key: 'includeAssets', label: 'Include assets (images, videos, audio, documents)' },
]
import { ExportOptions } from './ExportOptions'
import { ExportManifestForm } from './ExportManifestForm'
interface ExportDialogProps {
open: boolean
@@ -100,104 +95,18 @@ export const ExportDialog = ({
<Separator />
<div className="space-y-4">
<div>
<Label htmlFor="package-name">Package Name *</Label>
<Input
id="package-name"
placeholder="My Awesome Package"
value={manifest.name}
onChange={e => setManifest(prev => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="package-version">Version</Label>
<Input
id="package-version"
placeholder="1.0.0"
value={manifest.version}
onChange={e => setManifest(prev => ({ ...prev, version: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="package-author">Author</Label>
<Input
id="package-author"
placeholder="Your Name"
value={manifest.author}
onChange={e => setManifest(prev => ({ ...prev, author: e.target.value }))}
/>
</div>
</div>
<div>
<Label htmlFor="package-description">Description</Label>
<Textarea
id="package-description"
placeholder="Describe what this package does..."
value={manifest.description}
onChange={e => setManifest(prev => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</div>
<div>
<Label htmlFor="package-tags">Tags</Label>
<div className="flex gap-2 mb-2">
<Input
id="package-tags"
placeholder="Add a tag..."
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
/>
<Button type="button" onClick={onAddTag}>
Add
</Button>
</div>
{manifest.tags && manifest.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{manifest.tags.map(tag => (
<div key={tag} className="px-2 py-1 bg-secondary rounded-md text-sm flex items-center gap-2">
<span>{tag}</span>
<button
type="button"
onClick={() => onRemoveTag(tag)}
className="text-muted-foreground hover:text-foreground"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
<ExportManifestForm
manifest={manifest}
setManifest={setManifest}
tagInput={tagInput}
setTagInput={setTagInput}
onAddTag={onAddTag}
onRemoveTag={onRemoveTag}
/>
<Separator />
<div>
<Label className="mb-3 block">Export Options</Label>
<div className="space-y-3">
{exportOptionLabels.map(({ key, label }) => (
<div className="flex items-center gap-2" key={key}>
<Checkbox
id={`export-${key}`}
checked={exportOptions[key] as boolean}
onCheckedChange={checked =>
setExportOptions(prev => ({ ...prev, [key]: checked as boolean }))
}
/>
<Label htmlFor={`export-${key}`} className="font-normal cursor-pointer">
{label}
</Label>
</div>
))}
</div>
</div>
<ExportOptions exportOptions={exportOptions} setExportOptions={setExportOptions} />
</div>
</ScrollArea>
@@ -0,0 +1,100 @@
import type React from 'react'
import { Button, Input, Label, Textarea } from '@/components/ui'
import type { PackageManifest } from '@/lib/package-types'
interface ExportManifestFormProps {
manifest: Partial<PackageManifest>
setManifest: React.Dispatch<React.SetStateAction<Partial<PackageManifest>>>
tagInput: string
setTagInput: (value: string) => void
onAddTag: () => void
onRemoveTag: (tag: string) => void
}
export function ExportManifestForm({
manifest,
setManifest,
tagInput,
setTagInput,
onAddTag,
onRemoveTag,
}: ExportManifestFormProps) {
return (
<div className="space-y-4">
<div>
<Label htmlFor="package-name">Package Name *</Label>
<Input
id="package-name"
placeholder="My Awesome Package"
value={manifest.name}
onChange={e => setManifest(prev => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="package-version">Version</Label>
<Input
id="package-version"
placeholder="1.0.0"
value={manifest.version}
onChange={e => setManifest(prev => ({ ...prev, version: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="package-author">Author</Label>
<Input
id="package-author"
placeholder="Your Name"
value={manifest.author}
onChange={e => setManifest(prev => ({ ...prev, author: e.target.value }))}
/>
</div>
</div>
<div>
<Label htmlFor="package-description">Description</Label>
<Textarea
id="package-description"
placeholder="Describe what this package does..."
value={manifest.description}
onChange={e => setManifest(prev => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</div>
<div>
<Label htmlFor="package-tags">Tags</Label>
<div className="flex gap-2 mb-2">
<Input
id="package-tags"
placeholder="Add a tag..."
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
/>
<Button type="button" onClick={onAddTag}>
Add
</Button>
</div>
{manifest.tags && manifest.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{manifest.tags.map(tag => (
<div key={tag} className="px-2 py-1 bg-secondary rounded-md text-sm flex items-center gap-2">
<span>{tag}</span>
<button
type="button"
onClick={() => onRemoveTag(tag)}
className="text-muted-foreground hover:text-foreground"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,42 @@
import { Checkbox, Label } from '@/components/ui'
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
const exportOptionLabels: { key: keyof ExportPackageOptions; label: string }[] = [
{ key: 'includeSchemas', label: 'Include data schemas' },
{ key: 'includePages', label: 'Include page configurations' },
{ key: 'includeWorkflows', label: 'Include workflows' },
{ key: 'includeLuaScripts', label: 'Include Lua scripts' },
{ key: 'includeComponentHierarchy', label: 'Include component hierarchies' },
{ key: 'includeComponentConfigs', label: 'Include component configurations' },
{ key: 'includeCssClasses', label: 'Include CSS classes' },
{ key: 'includeDropdownConfigs', label: 'Include dropdown configurations' },
{ key: 'includeSeedData', label: 'Include seed data' },
{ key: 'includeAssets', label: 'Include assets (images, videos, audio, documents)' },
]
interface ExportOptionsProps {
exportOptions: ExportPackageOptions
setExportOptions: React.Dispatch<React.SetStateAction<ExportPackageOptions>>
}
export function ExportOptions({ exportOptions, setExportOptions }: ExportOptionsProps) {
return (
<div>
<Label className="mb-3 block">Export Options</Label>
<div className="space-y-3">
{exportOptionLabels.map(({ key, label }) => (
<div className="flex items-center gap-2" key={key}>
<Checkbox
id={`export-${key}`}
checked={exportOptions[key] as boolean}
onCheckedChange={checked => setExportOptions(prev => ({ ...prev, [key]: checked as boolean }))}
/>
<Label htmlFor={`export-${key}`} className="font-normal cursor-pointer">
{label}
</Label>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,83 @@
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
interface UsePackageActionsProps {
loadPackages: () => Promise<void>
getCatalogEntry: (packageId: string) => PackageCatalogData | undefined
}
export function usePackageActions({ loadPackages, getCatalogEntry }: UsePackageActionsProps) {
const [installing, setInstalling] = useState(false)
const resolvePackage = useCallback(
(packageId: string) => {
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return null
}
return packageEntry
},
[getCatalogEntry]
)
const handleInstallPackage = useCallback(
async (packageId: string, onComplete?: () => void) => {
setInstalling(true)
const packageEntry = resolvePackage(packageId)
if (!packageEntry) {
setInstalling(false)
return
}
try {
await installPackage(packageId)
toast.success(`${packageEntry.manifest.name} installed successfully!`)
await loadPackages()
onComplete?.()
} catch (error) {
console.error('Installation error:', error)
toast.error('Failed to install package')
} finally {
setInstalling(false)
}
},
[loadPackages, resolvePackage]
)
const handleUninstallPackage = useCallback(
async (packageId: string, onComplete?: () => void) => {
const packageEntry = resolvePackage(packageId)
if (!packageEntry) return
try {
await uninstallPackage(packageId)
toast.success(`${packageEntry.manifest.name} uninstalled successfully!`)
await loadPackages()
onComplete?.()
} catch (error) {
console.error('Uninstallation error:', error)
toast.error('Failed to uninstall package')
}
},
[loadPackages, resolvePackage]
)
const handleTogglePackage = useCallback(
async (packageId: string, enabled: boolean) => {
try {
await togglePackageEnabled(packageId, enabled)
toast.success(enabled ? 'Package enabled' : 'Package disabled')
await loadPackages()
} catch (error) {
console.error('Toggle error:', error)
toast.error('Failed to toggle package')
}
},
[loadPackages]
)
return { installing, handleInstallPackage, handleUninstallPackage, handleTogglePackage }
}
@@ -0,0 +1,57 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
import type { InstalledPackage } from '@/lib/package-types'
import { CheckCircle, WarningCircle } from '@phosphor-icons/react'
interface DependenciesTabProps {
dependencies: string[]
installedPackages: InstalledPackage[]
resolveCatalogEntry: (packageId: string) => PackageCatalogData | undefined
}
export function DependenciesTab({ dependencies, installedPackages, resolveCatalogEntry }: DependenciesTabProps) {
if (dependencies.length === 0) {
return <p className="text-sm text-muted-foreground">No dependencies required.</p>
}
return (
<div className="space-y-3">
{dependencies.map(dependencyId => {
const catalogEntry = resolveCatalogEntry(dependencyId)
const isInstalled = installedPackages.some(pkg => pkg.packageId === dependencyId)
const dependencyName = catalogEntry?.manifest.name ?? dependencyId
const dependencyDescription = catalogEntry?.manifest.description ?? 'Dependency not found in catalog.'
return (
<Card key={dependencyId}>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle className="text-base flex items-center gap-2">
{dependencyName}
<Badge variant={isInstalled ? 'secondary' : 'outline'}>{isInstalled ? 'Installed' : 'Missing'}</Badge>
</CardTitle>
<CardDescription>{dependencyDescription}</CardDescription>
</div>
{isInstalled ? (
<CheckCircle size={20} className="text-green-500" />
) : (
<WarningCircle size={20} className="text-amber-500" />
)}
</CardHeader>
{catalogEntry?.manifest.tags?.length ? (
<CardContent>
<div className="flex flex-wrap gap-2">
{catalogEntry.manifest.tags.map(tag => (
<Badge key={`${dependencyId}-${tag}`} variant="outline">
{tag}
</Badge>
))}
</div>
</CardContent>
) : null}
</Card>
)
})}
</div>
)
}
@@ -0,0 +1,42 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
interface Script {
id?: string
name?: string
description?: string
category?: string
code?: string
}
interface ScriptsTabProps {
scripts: Script[]
}
export function ScriptsTab({ scripts }: ScriptsTabProps) {
if (!scripts.length) {
return <p className="text-sm text-muted-foreground">No scripts included in this package.</p>
}
return (
<div className="space-y-3">
{scripts.map((script, index) => (
<Card key={script.id ?? script.name ?? index}>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{script.name ?? 'Unnamed Script'}</CardTitle>
<CardDescription>{script.description ?? 'No description provided.'}</CardDescription>
</div>
{script.category ? <Badge variant="outline">{script.category}</Badge> : null}
</CardHeader>
{script.code ? (
<CardContent>
<pre className="bg-muted rounded-lg p-3 text-xs overflow-x-auto">
<code>{script.code}</code>
</pre>
</CardContent>
) : null}
</Card>
))}
</div>
)
}
@@ -1,13 +1,10 @@
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Button } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Alert, AlertDescription } from '@/components/ui'
import { GodCredentialsForm } from '@/components/auth/god-credentials/Form'
import { GodCredentialsSummary } from '@/components/auth/god-credentials/Summary'
import { Database } from '@/lib/database'
import { toast } from 'sonner'
import { Clock, Key, WarningCircle, CheckCircle } from '@phosphor-icons/react'
import { Key } from '@phosphor-icons/react'
export function GodCredentialsSettings() {
const [duration, setDuration] = useState<number>(60)
@@ -112,91 +109,21 @@ export function GodCredentialsSettings() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isActive && (
<Alert className="bg-gradient-to-br from-purple-500/10 to-orange-500/10 border-purple-500/50">
<CheckCircle className="h-5 w-5 text-green-500" />
<AlertDescription className="ml-2">
<div className="space-y-1">
<p className="font-semibold text-sm">
God credentials are currently visible on the front page
</p>
<p className="text-xs text-muted-foreground">
Time remaining: <span className="font-mono font-semibold">{timeRemaining}</span>
</p>
</div>
</AlertDescription>
</Alert>
)}
<GodCredentialsSummary
isActive={isActive}
expiryTime={expiryTime}
timeRemaining={timeRemaining}
/>
{!isActive && expiryTime > 0 && (
<Alert>
<WarningCircle className="h-5 w-5 text-yellow-500" />
<AlertDescription className="ml-2">
<p className="text-sm">
God credentials have expired or been hidden
</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="duration">Expiry Duration</Label>
<div className="flex gap-2">
<Input
id="duration"
type="number"
min="1"
max={unit === 'hours' ? '24' : '1440'}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="flex-1"
/>
<Select value={unit} onValueChange={(v) => setUnit(v as 'minutes' | 'hours')}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Set the duration for how long credentials are visible (1 minute to 24 hours)
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} className="flex-1">
<Clock className="mr-2" size={16} />
Save Duration
</Button>
</div>
</div>
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<Label>Expiry Management</Label>
<p className="text-xs text-muted-foreground">
Reset or clear the current expiry timer
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleResetExpiry} variant="outline" className="flex-1">
Reset Timer
</Button>
<Button onClick={handleClearExpiry} variant="outline" className="flex-1">
Clear Expiry
</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>Reset Timer:</strong> Restart the countdown using the configured duration<br />
<strong>Clear Expiry:</strong> Remove expiry time (credentials will show on next page load)
</p>
</div>
<GodCredentialsForm
duration={duration}
unit={unit}
onDurationChange={setDuration}
onUnitChange={setUnit}
onSave={handleSave}
onResetExpiry={handleResetExpiry}
onClearExpiry={handleClearExpiry}
/>
</CardContent>
</Card>
)
@@ -4,11 +4,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { SignIn, UserPlus, ArrowLeft, Envelope } from '@phosphor-icons/react'
import { ArrowLeft, Envelope, SignIn, UserPlus } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { Database, hashPassword } from '@/lib/database'
import { generateScrambledPassword, simulateEmailSend } from '@/lib/password-utils'
import { Alert, AlertDescription } from '@/components/ui'
import { LoginForm } from '@/components/auth/unified-login/LoginForm'
import { Provider, ProviderList } from '@/components/auth/unified-login/ProviderList'
export interface UnifiedLoginProps {
onLogin: (credentials: { username: string; password: string }) => void
@@ -20,6 +22,14 @@ export function UnifiedLogin({ onLogin, onRegister, onBack }: UnifiedLoginProps)
const [loginForm, setLoginForm] = useState({ username: '', password: '' })
const [registerForm, setRegisterForm] = useState({ username: '', email: '' })
const [resetEmail, setResetEmail] = useState('')
const providers: Provider[] = [
{ name: 'Google', description: 'Use your Google Workspace account' },
{ name: 'GitHub', description: 'Developer SSO via GitHub' },
]
const handleProviderSelect = (provider: Provider) => {
toast.info(`${provider.name} login is coming soon`)
}
const handleLogin = () => {
if (!loginForm.username || !loginForm.password) {
@@ -119,37 +129,14 @@ export function UnifiedLogin({ onLogin, onRegister, onBack }: UnifiedLoginProps)
</TabsList>
<TabsContent value="login" className="space-y-4 mt-6">
<div className="space-y-2">
<Label htmlFor="login-username">Username</Label>
<Input
id="login-username"
value={loginForm.username}
onChange={(e) => setLoginForm({ ...loginForm, username: e.target.value })}
placeholder="Enter username"
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="login-password">Password</Label>
<Input
id="login-password"
type="password"
value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
placeholder="Enter password"
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
</div>
<Button className="w-full" onClick={handleLogin}>
<SignIn className="mr-2" size={16} />
Sign In
</Button>
<Alert>
<AlertDescription className="text-xs">
<p className="font-semibold mb-1">Test Credentials:</p>
<p>Check browser console for default user passwords (they are scrambled on first run)</p>
</AlertDescription>
</Alert>
<LoginForm
username={loginForm.username}
password={loginForm.password}
onUsernameChange={(username) => setLoginForm({ ...loginForm, username })}
onPasswordChange={(password) => setLoginForm({ ...loginForm, password })}
onSubmit={handleLogin}
/>
<ProviderList providers={providers} onSelect={handleProviderSelect} />
</TabsContent>
<TabsContent value="register" className="space-y-4 mt-6">
@@ -0,0 +1,120 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator } from '@/components/ui'
import { PageDefinition } from '@/lib/rendering/page/page-renderer'
import { Eye, Layout, ShieldCheck } from '@phosphor-icons/react'
interface GenericPagePreviewProps {
page: PageDefinition
updatedAt?: string
footerText?: string
}
const layoutCopy: Record<PageDefinition['layout'], string> = {
default: 'Default layout with header and footer',
sidebar: 'Sidebar layout with navigation',
dashboard: 'Dashboard layout with widgets',
blank: 'Blank canvas for custom layouts'
}
export function Preview({ page, updatedAt, footerText }: GenericPagePreviewProps) {
const showHeader = page.metadata?.showHeader !== false
const showFooter = page.metadata?.showFooter !== false
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Eye size={20} weight="duotone" />
Page preview
</CardTitle>
<CardDescription className="flex items-center gap-2 text-muted-foreground">
<Layout size={16} />
{layoutCopy[page.layout]}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{page.description || 'No description provided.'}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="capitalize">
Level {page.level}
</Badge>
<Badge variant="secondary">{page.components.length} components</Badge>
{page.permissions?.requiresAuth && (
<span className="inline-flex items-center gap-1">
<ShieldCheck size={14} />
Auth required{page.permissions?.requiredRole ? ` (${page.permissions.requiredRole})` : ''}
</span>
)}
{updatedAt && <span>Last updated {updatedAt}</span>}
</div>
</div>
<Badge>{page.metadata?.headerTitle || page.title}</Badge>
</div>
<div className="rounded-lg border bg-card p-4 shadow-inner">
{showHeader && (
<div className="mb-3 flex items-center justify-between rounded-md border border-dashed border-border/60 bg-muted/60 px-3 py-2 text-sm">
<span className="font-semibold">Header</span>
<Badge variant="outline">{page.metadata?.headerTitle || 'Default title'}</Badge>
</div>
)}
<div
className={`grid gap-3 ${page.layout === 'dashboard' ? 'lg:grid-cols-3 md:grid-cols-2 grid-cols-1' : ''} ${
page.layout === 'sidebar' ? 'lg:grid-cols-[240px_1fr]' : ''
}`}
>
{page.layout === 'sidebar' && (
<div className="rounded-md border border-dashed border-border/60 bg-muted/50 p-3 text-sm text-muted-foreground">
Sidebar navigation
</div>
)}
<div className="space-y-3 rounded-md border border-dashed border-border/60 bg-background p-3">
<p className="text-sm font-semibold">Component tree</p>
<div className="grid gap-2 md:grid-cols-2">
{page.components.slice(0, 4).map(component => (
<div key={component.id} className="rounded border bg-muted/40 p-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span className="font-semibold text-foreground">{component.type}</span>
{component.children && component.children.length > 0 && (
<Badge variant="outline">{component.children.length} children</Badge>
)}
</div>
{component.props?.className && <p className="line-clamp-1">{component.props.className}</p>}
</div>
))}
{page.components.length === 0 && (
<p className="text-xs text-muted-foreground">Add components to see them previewed here.</p>
)}
</div>
</div>
</div>
{showFooter && (
<div className="mt-3 flex items-center justify-between rounded-md border border-dashed border-border/60 bg-muted/60 px-3 py-2 text-sm text-muted-foreground">
<span>Footer</span>
<Badge variant="secondary">{footerText || 'Configured in metadata'}</Badge>
</div>
)}
</div>
<Separator />
<div className="grid gap-2 text-sm text-muted-foreground md:grid-cols-2">
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-foreground">Lua hooks</p>
<p>onLoad: {page.luaScripts?.onLoad || 'Not configured'}</p>
<p>onUnload: {page.luaScripts?.onUnload || 'Not configured'}</p>
</div>
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-foreground">Metadata</p>
<p>Header actions: {page.metadata?.headerActions?.length ?? 0}</p>
<p>Sidebar items: {page.metadata?.sidebarItems?.length ?? 0}</p>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,101 @@
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea, Separator } from '@/components/ui'
import { ListNumbers, Plus, PushPinSimple, SquaresFour } from '@phosphor-icons/react'
export interface PageSection {
id: string
title: string
description?: string
componentCount?: number
status?: 'draft' | 'review' | 'published'
updatedAt?: string
}
interface SectionListProps {
sections: PageSection[]
selectedSectionId?: string
onSelectSection?: (section: PageSection) => void
onCreateSection?: () => void
}
const statusVariant: Record<NonNullable<PageSection['status']>, 'default' | 'secondary' | 'outline'> = {
draft: 'secondary',
review: 'outline',
published: 'default'
}
export function SectionList({ sections, selectedSectionId, onSelectSection, onCreateSection }: SectionListProps) {
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<ListNumbers size={20} weight="duotone" />
Sections
</CardTitle>
<CardDescription>Outline the sections that make up your generic page.</CardDescription>
</div>
<Button size="sm" onClick={onCreateSection} variant="secondary">
<Plus size={16} />
Add Section
</Button>
</CardHeader>
<CardContent className="p-0">
{sections.length === 0 ? (
<div className="py-10 text-center text-muted-foreground">
<p className="text-sm">No sections yet. Create your first section to start building the page.</p>
</div>
) : (
<ScrollArea className="max-h-[520px]">
<div className="divide-y divide-border">
{sections.map(section => (
<button
key={section.id}
className={`w-full text-left transition hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
selectedSectionId === section.id ? 'bg-muted' : ''
}`}
onClick={() => onSelectSection?.(section)}
>
<div className="flex items-start gap-3 px-4 py-3">
<div className="mt-1">
{section.status ? (
<Badge variant={statusVariant[section.status]} className="capitalize">
{section.status}
</Badge>
) : (
<Badge variant="outline">Draft</Badge>
)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-semibold leading-none">{section.title}</p>
{section.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{section.description}</p>
)}
</div>
{section.updatedAt && (
<p className="text-xs text-muted-foreground whitespace-nowrap">Updated {section.updatedAt}</p>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<SquaresFour size={14} />
{section.componentCount ?? 0} components
</span>
<Separator orientation="vertical" className="h-4" />
<span className="inline-flex items-center gap-1">
<PushPinSimple size={14} />
ID: {section.id}
</span>
</div>
</div>
</div>
</button>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,69 @@
import Image from 'next/image'
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@/components/ui'
import { FilmSlate, ImageSquare } from '@phosphor-icons/react'
interface MediaPaneProps {
thumbnailUrl?: string
videoUrl?: string
onThumbnailChange?: (value: string) => void
onVideoChange?: (value: string) => void
}
export function MediaPane({ thumbnailUrl, videoUrl, onThumbnailChange, onVideoChange }: MediaPaneProps) {
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<FilmSlate size={20} weight="duotone" />
Media
</CardTitle>
<CardDescription>Optional visuals to make the quick guide easier to follow.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="thumbnail-url">Thumbnail image</Label>
<Input
id="thumbnail-url"
value={thumbnailUrl || ''}
onChange={(e) => onThumbnailChange?.(e.target.value)}
placeholder="https://images.example.com/quick-guide.png"
/>
<p className="text-xs text-muted-foreground">Shown in dashboards and previews.</p>
{thumbnailUrl && (
<div className="relative aspect-[16/9] overflow-hidden rounded-lg border bg-muted">
<Image src={thumbnailUrl} alt="Quick guide thumbnail" fill className="object-cover" />
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="video-url">Demo video (optional)</Label>
<Input
id="video-url"
value={videoUrl || ''}
onChange={(e) => onVideoChange?.(e.target.value)}
placeholder="YouTube or direct MP4 link"
/>
<p className="text-xs text-muted-foreground">Embed a short clip that shows the flow in action.</p>
{videoUrl && (
<div className="rounded-lg border bg-black p-3 text-sm text-muted-foreground">
<Badge variant="secondary" className="mb-2 inline-flex items-center gap-1">
<ImageSquare size={14} />
Preview
</Badge>
<div className="aspect-video overflow-hidden rounded-md bg-muted">
<iframe
className="h-full w-full"
src={videoUrl}
title="Quick guide demo"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react'
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Textarea } from '@/components/ui'
import { ArrowCounterClockwise, ListNumbers, Plus, Trash } from '@phosphor-icons/react'
export interface GuideStep {
id: string
title: string
description: string
mediaUrl?: string
duration?: string
}
interface StepsEditorProps {
steps: GuideStep[]
onChange?: (steps: GuideStep[]) => void
}
export function StepsEditor({ steps, onChange }: StepsEditorProps) {
const [localSteps, setLocalSteps] = useState<GuideStep[]>(steps)
useEffect(() => {
setLocalSteps(steps)
}, [steps])
const updateStep = (id: string, payload: Partial<GuideStep>) => {
const nextSteps = localSteps.map(step => (step.id === id ? { ...step, ...payload } : step))
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const removeStep = (id: string) => {
const nextSteps = localSteps.filter(step => step.id !== id)
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const addStep = () => {
const newStep: GuideStep = {
id: crypto.randomUUID(),
title: 'New step',
description: 'Describe what happens in this step.',
duration: '1-2 min'
}
const nextSteps = [...localSteps, newStep]
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const resetOrdering = () => {
const nextSteps = localSteps.map((step, index) => ({ ...step, id: `step_${index + 1}` }))
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
return (
<Card className="h-full">
<CardHeader className="flex items-center justify-between space-y-0">
<div>
<CardTitle className="flex items-center gap-2">
<ListNumbers size={20} weight="duotone" />
Steps
</CardTitle>
<CardDescription>Keep your quick guide instructions concise and actionable.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={resetOrdering}>
<ArrowCounterClockwise size={16} />
Reset IDs
</Button>
<Button size="sm" onClick={addStep}>
<Plus size={16} />
Add Step
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{localSteps.length === 0 ? (
<p className="text-sm text-muted-foreground">Add your first step to get started.</p>
) : (
<div className="space-y-4">
{localSteps.map((step, index) => (
<div key={step.id} className="rounded-lg border border-border/80 bg-card/60 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline">Step {index + 1}</Badge>
<span>Duration: {step.duration || 'n/a'}</span>
</div>
<Button variant="ghost" size="icon" onClick={() => removeStep(step.id)}>
<Trash size={16} />
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`title-${step.id}`}>Title</Label>
<Input
id={`title-${step.id}`}
value={step.title}
onChange={(e) => updateStep(step.id, { title: e.target.value })}
placeholder="Give this step a short name"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`duration-${step.id}`}>Expected duration</Label>
<Input
id={`duration-${step.id}`}
value={step.duration || ''}
onChange={(e) => updateStep(step.id, { duration: e.target.value })}
placeholder="e.g. 30s, 1-2 min"
/>
</div>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor={`description-${step.id}`}>Description</Label>
<Textarea
id={`description-${step.id}`}
value={step.description}
onChange={(e) => updateStep(step.id, { description: e.target.value })}
rows={3}
placeholder="Outline the actions or context for this step"
/>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor={`media-${step.id}`}>Media URL (optional)</Label>
<Input
id={`media-${step.id}`}
value={step.mediaUrl || ''}
onChange={(e) => updateStep(step.id, { mediaUrl: e.target.value })}
placeholder="Link to an image, GIF, or short video"
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,112 @@
import { useMemo } from 'react'
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Input, Label, Switch } from '@/components/ui'
import { EnvelopeSimple, FloppyDisk } from '@phosphor-icons/react'
import type { SMTPConfig } from '@/lib/password-utils'
interface ConnectionFormProps {
value: SMTPConfig
onChange: (value: SMTPConfig) => void
onSave?: () => void
onTest?: () => void
}
export function ConnectionForm({ value, onChange, onSave, onTest }: ConnectionFormProps) {
const securePort = useMemo(() => (value.tls ? 465 : 587), [value.tls])
const updateField = <K extends keyof SMTPConfig>(key: K, fieldValue: SMTPConfig[K]) => {
onChange({ ...value, [key]: fieldValue })
}
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<EnvelopeSimple size={20} weight="duotone" />
SMTP connection
</CardTitle>
<CardDescription>Configure how MetaBuilder connects to your mail provider.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="host">Host</Label>
<Input
id="host"
value={value.host}
onChange={(e) => updateField('host', e.target.value)}
placeholder="smtp.example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="port">Port</Label>
<Input
id="port"
type="number"
value={value.port}
onChange={(e) => updateField('port', parseInt(e.target.value || '0', 10))}
placeholder={securePort.toString()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={value.username}
onChange={(e) => updateField('username', e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={value.password}
onChange={(e) => updateField('password', e.target.value)}
placeholder="App password or token"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="fromName">From name</Label>
<Input
id="fromName"
value={value.fromName || ''}
onChange={(e) => updateField('fromName', e.target.value)}
placeholder="MetaBuilder"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fromEmail">From email</Label>
<Input
id="fromEmail"
type="email"
value={value.fromEmail}
onChange={(e) => updateField('fromEmail', e.target.value)}
placeholder="no-reply@example.com"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/40 p-3">
<div>
<p className="font-medium">Use secure connection (TLS)</p>
<p className="text-sm text-muted-foreground">Switching on updates the recommended port to {securePort}.</p>
</div>
<Switch checked={value.tls} onCheckedChange={(checked) => updateField('tls', checked)} />
</div>
</CardContent>
<CardFooter className="flex flex-wrap items-center gap-2">
<Button variant="secondary" onClick={onTest}>
Test connection
</Button>
<Button onClick={onSave}>
<FloppyDisk size={16} />
Save configuration
</Button>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,55 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { CheckCircle, Clock, WarningCircle } from '@phosphor-icons/react'
import type { ReactNode } from 'react'
export type ConnectionStatus = 'idle' | 'connected' | 'error'
interface StatusCardProps {
status: ConnectionStatus
host?: string
lastChecked?: string
message?: string
}
const statusCopy: Record<ConnectionStatus, { label: string; tone: string; icon: ReactNode }> = {
idle: {
label: 'Not tested',
tone: 'bg-muted text-muted-foreground',
icon: <Clock size={16} />
},
connected: {
label: 'Connected',
tone: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
icon: <CheckCircle size={16} />
},
error: {
label: 'Connection failed',
tone: 'bg-destructive/15 text-destructive',
icon: <WarningCircle size={16} />
}
}
export function StatusCard({ status, host, lastChecked, message }: StatusCardProps) {
const copy = statusCopy[status]
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Connection status</CardTitle>
<CardDescription>Stay aware of how the platform talks to your SMTP provider.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Badge variant="secondary" className={`inline-flex items-center gap-2 ${copy.tone}`}>
{copy.icon}
{copy.label}
</Badge>
<div className="text-sm text-muted-foreground space-y-1">
<p>Host: {host || 'Not configured'}</p>
<p>Last checked: {lastChecked || 'Pending test'}</p>
<p>{message || 'Run a test to see connection details.'}</p>
</div>
</CardContent>
</Card>
)
}
@@ -5,10 +5,15 @@
* with the MetaBuilder application.
*/
import { useMemo, useState } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { useDBAL } from '@/hooks/use-dbal/use-dbal'
import { BlobStorageDemo } from './dbal/BlobStorageDemo'
import { CachedDataDemo } from './dbal/CachedDataDemo'
import { ConnectionForm } from './dbal/ConnectionForm'
import { KVStoreDemo } from './dbal/KVStoreDemo'
import { LogsPanel } from './dbal/LogsPanel'
import { ResultPanel } from './dbal/ResultPanel'
import { DBALTabConfig, DBAL_CONTAINER_CLASS, DBAL_TAB_GRID_CLASS } from './dbal/dbal-demo.utils'
const tabs: DBALTabConfig[] = [
@@ -18,6 +23,25 @@ const tabs: DBALTabConfig[] = [
]
export function DBALDemo() {
const { isReady, error } = useDBAL()
const [logs, setLogs] = useState<string[]>([])
const [latestResult, setLatestResult] = useState<unknown>(null)
const statusMessage = useMemo(() => {
if (error) return `Error: ${error}`
return isReady ? 'Connected' : 'Initializing...'
}, [error, isReady])
const handleConnect = (config: { endpoint: string; apiKey: string }) => {
const timestamp = new Date().toLocaleTimeString()
setLogs((current) => [...current, `${timestamp}: Connected to ${config.endpoint}`])
setLatestResult({
endpoint: config.endpoint,
apiKey: config.apiKey ? '***' : 'Not provided',
ready: isReady,
})
}
return (
<div className={DBAL_CONTAINER_CLASS}>
<div className="mb-8">
@@ -27,6 +51,20 @@ export function DBALDemo() {
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 mb-6">
<ConnectionForm
defaultUrl={process.env.NEXT_PUBLIC_DBAL_ENDPOINT}
defaultApiKey={process.env.NEXT_PUBLIC_DBAL_API_KEY}
statusMessage={statusMessage}
onConnect={handleConnect}
/>
<ResultPanel title="Connection Details" result={latestResult} emptyLabel="Submit the form to log a connection" />
</div>
<div className="mb-6">
<LogsPanel logs={logs} title="Demo Logs" />
</div>
<Tabs defaultValue={tabs[0].value} className="space-y-4">
<TabsList className={DBAL_TAB_GRID_CLASS}>
{tabs.map((tab) => (
@@ -1,21 +1,9 @@
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Badge } from '@/components/ui'
import { PaperPlaneTilt, Users, SignOut, Gear } from '@phosphor-icons/react'
import { useState, useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
import type { User } from '@/lib/level-types'
interface ChatMessage {
id: string
username: string
userId: string
message: string
timestamp: number
type: 'message' | 'system' | 'join' | 'leave'
}
import { ChatWindow } from './irc/ChatWindow'
import { useChatInput, useFormattedTimes } from './irc/hooks'
import type { ChatMessage } from './irc/types'
interface IRCWebchatProps {
user: User
@@ -26,10 +14,9 @@ interface IRCWebchatProps {
export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebchatProps) {
const [messages, setMessages] = useKV<ChatMessage[]>(`chat_${channelName}`, [])
const [onlineUsers, setOnlineUsers] = useKV<string[]>(`chat_${channelName}_users`, [])
const [inputMessage, setInputMessage] = useState('')
const [showSettings, setShowSettings] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const { inputMessage, setInputMessage, handleKeyPress } = useChatInput(handleSendMessage)
const formattedTimes = useFormattedTimes(messages || [], formatTime)
useEffect(() => {
addUserToChannel()
@@ -38,14 +25,6 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
}
}, [])
useEffect(() => {
scrollToBottom()
}, [messages])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const addUserToChannel = () => {
setOnlineUsers((current) => {
if (!current) return [user.username]
@@ -89,7 +68,7 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
})
}
const handleSendMessage = () => {
function handleSendMessage() {
const trimmed = inputMessage.trim()
if (!trimmed) return
@@ -151,121 +130,24 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const formatTime = (timestamp: number) => {
function formatTime(timestamp: number) {
const date = new Date(timestamp)
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
}
const getMessageStyle = (msg: ChatMessage) => {
if (msg.type === 'system' || msg.type === 'join' || msg.type === 'leave') {
return 'text-muted-foreground italic text-sm'
}
return ''
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader className="border-b border-border pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<span className="font-mono">#</span>
{channelName}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
<Users size={14} />
{onlineUsers?.length || 0}
</Badge>
<Button size="sm" variant="ghost" onClick={() => setShowSettings(!showSettings)}>
<Gear size={16} />
</Button>
{onClose && (
<Button size="sm" variant="ghost" onClick={onClose}>
<SignOut size={16} />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
<div className="flex flex-1 overflow-hidden">
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-2 font-mono text-sm">
{(messages || []).map((msg) => (
<div key={msg.id} className={getMessageStyle(msg)}>
{msg.type === 'message' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formatTime(msg.timestamp)}</span>
<span className="font-semibold shrink-0 text-primary">&lt;{msg.username}&gt;</span>
<span className="break-words">{msg.message}</span>
</div>
)}
{msg.type === 'system' && msg.username === 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formatTime(msg.timestamp)}</span>
<span>*** {msg.message}</span>
</div>
)}
{msg.type === 'system' && msg.username !== 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formatTime(msg.timestamp)}</span>
<span className="text-accent">* {msg.username} {msg.message}</span>
</div>
)}
{(msg.type === 'join' || msg.type === 'leave') && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formatTime(msg.timestamp)}</span>
<span className={msg.type === 'join' ? 'text-green-500' : 'text-orange-500'}>
--&gt; {msg.message}
</span>
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{showSettings && (
<div className="w-48 border-l border-border p-4 bg-muted/20">
<h4 className="font-semibold text-sm mb-3">Online Users</h4>
<div className="space-y-1.5 text-sm">
{(onlineUsers || []).map((username) => (
<div key={username} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span>{username}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border p-4">
<div className="flex gap-2">
<Input
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message... (/help for commands)"
className="flex-1 font-mono"
/>
<Button onClick={handleSendMessage} size="icon">
<PaperPlaneTilt size={18} />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Press Enter to send. Type /help for commands.
</p>
</div>
</CardContent>
</Card>
<ChatWindow
channelName={channelName}
messages={messages || []}
formattedTimes={formattedTimes}
onlineUsers={onlineUsers || []}
inputMessage={inputMessage}
onInputChange={setInputMessage}
onSendMessage={handleSendMessage}
onToggleSettings={() => setShowSettings(!showSettings)}
showSettings={showSettings}
onClose={onClose}
onInputKeyPress={handleKeyPress}
/>
)
}
@@ -1,22 +1,10 @@
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Badge } from '@/components/ui'
import { PaperPlaneTilt, Users, SignOut, Gear } from '@phosphor-icons/react'
import { useState, useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
import type { User } from '@/lib/level-types'
import { getDeclarativeRenderer } from '@/lib/rendering-lib/declarative-component-renderer'
interface ChatMessage {
id: string
username: string
userId: string
message: string
timestamp: number
type: 'message' | 'system' | 'join' | 'leave' | 'command'
}
import { ChatWindow } from './irc/ChatWindow'
import { useChatInput, useFormattedTimes } from './irc/hooks'
import type { ChatMessage } from './irc/types'
interface IRCWebchatDeclarativeProps {
user: User
@@ -27,11 +15,10 @@ interface IRCWebchatDeclarativeProps {
export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }: IRCWebchatDeclarativeProps) {
const [messages, setMessages] = useKV<ChatMessage[]>(`chat_${channelName}`, [])
const [onlineUsers, setOnlineUsers] = useKV<string[]>(`chat_${channelName}_users`, [])
const [inputMessage, setInputMessage] = useState('')
const [showSettings, setShowSettings] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const { inputMessage, setInputMessage, handleKeyPress } = useChatInput(handleSendMessage)
const renderer = getDeclarativeRenderer()
const formattedTimes = useFormattedTimes(messages, formatTime)
useEffect(() => {
addUserToChannel()
@@ -40,14 +27,6 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}, [])
useEffect(() => {
scrollToBottom()
}, [messages])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const addUserToChannel = async () => {
setOnlineUsers((current) => {
if (!current) return [user.username]
@@ -113,7 +92,7 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
const handleSendMessage = async () => {
async function handleSendMessage() {
const trimmed = inputMessage.trim()
if (!trimmed) return
@@ -182,14 +161,9 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const formatTime = async (timestamp: number): Promise<string> => {
async function formatTime(timestamp: number): Promise<string> {
try {
const formatted = await renderer.executeLuaScript('lua_irc_format_time', [timestamp])
return formatted || new Date(timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
@@ -198,122 +172,19 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
const [formattedTimes, setFormattedTimes] = useState<Record<string, string>>({})
useEffect(() => {
const updateTimes = async () => {
const times: Record<string, string> = {}
for (const msg of messages || []) {
times[msg.id] = await formatTime(msg.timestamp)
}
setFormattedTimes(times)
}
updateTimes()
}, [messages])
const getMessageStyle = (msg: ChatMessage) => {
if (msg.type === 'system' || msg.type === 'join' || msg.type === 'leave') {
return 'text-muted-foreground italic text-sm'
}
return ''
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader className="border-b border-border pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<span className="font-mono">#</span>
{channelName}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
<Users size={14} />
{onlineUsers?.length || 0}
</Badge>
<Button size="sm" variant="ghost" onClick={() => setShowSettings(!showSettings)}>
<Gear size={16} />
</Button>
{onClose && (
<Button size="sm" variant="ghost" onClick={onClose}>
<SignOut size={16} />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
<div className="flex flex-1 overflow-hidden">
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-2 font-mono text-sm">
{(messages || []).map((msg) => (
<div key={msg.id} className={getMessageStyle(msg)}>
{msg.type === 'message' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[msg.id] || ''}</span>
<span className="font-semibold shrink-0 text-primary">&lt;{msg.username}&gt;</span>
<span className="break-words">{msg.message}</span>
</div>
)}
{msg.type === 'system' && msg.username === 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[msg.id] || ''}</span>
<span>*** {msg.message}</span>
</div>
)}
{msg.type === 'system' && msg.username !== 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[msg.id] || ''}</span>
<span className="text-accent">* {msg.username} {msg.message}</span>
</div>
)}
{(msg.type === 'join' || msg.type === 'leave') && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[msg.id] || ''}</span>
<span className={msg.type === 'join' ? 'text-green-500' : 'text-orange-500'}>
--&gt; {msg.message}
</span>
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{showSettings && (
<div className="w-48 border-l border-border p-4 bg-muted/20">
<h4 className="font-semibold text-sm mb-3">Online Users</h4>
<div className="space-y-1.5 text-sm">
{(onlineUsers || []).map((username) => (
<div key={username} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span>{username}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border p-4">
<div className="flex gap-2">
<Input
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message... (/help for commands)"
className="flex-1 font-mono"
/>
<Button onClick={handleSendMessage} size="icon">
<PaperPlaneTilt size={18} />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Press Enter to send. Type /help for commands.
</p>
</div>
</CardContent>
</Card>
<ChatWindow
channelName={channelName}
messages={messages || []}
formattedTimes={formattedTimes}
onlineUsers={onlineUsers || []}
inputMessage={inputMessage}
onInputChange={setInputMessage}
onSendMessage={handleSendMessage}
onToggleSettings={() => setShowSettings(!showSettings)}
showSettings={showSettings}
onClose={onClose}
onInputKeyPress={handleKeyPress}
/>
)
}
@@ -1,26 +1,11 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardContent,
CardHeader,
Chip,
CircularProgress,
Stack,
Typography,
Grid,
} from '@mui/material'
import {
CameraAlt as CameraIcon,
Visibility as EyeIcon,
Download as DownloadIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import { Box, Card, CardContent, CardHeader, Chip, Grid, Typography } from '@mui/material'
import { toast } from 'sonner'
import { captureDomSnapshot } from '@/lib/screenshot/capture-dom-snapshot'
import { requestScreenshotAnalysis } from '@/lib/screenshot/request-screenshot-analysis'
import type { ScreenshotAnalysisResult } from '@/lib/screenshot/types'
import { UploadSection } from './screenshot-analyzer/UploadSection'
import { ResultPanel } from './screenshot-analyzer/ResultPanel'
export function ScreenshotAnalyzer() {
const [isCapturing, setIsCapturing] = useState(false)
@@ -96,108 +81,16 @@ export function ScreenshotAnalyzer() {
<Chip label="Local Analysis" color="secondary" />
</Box>
<Card>
<CardHeader
title="Capture & Analyze"
subheader="Create a DOM snapshot and run heuristic checks"
/>
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
onClick={captureScreenshot}
disabled={isCapturing || isAnalyzing}
variant="contained"
startIcon={<CameraIcon />}
sx={{ flex: 1 }}
>
{isCapturing ? 'Capturing...' : 'Capture & Analyze'}
</Button>
<UploadSection
isCapturing={isCapturing}
isAnalyzing={isAnalyzing}
screenshotData={screenshotData}
onCapture={captureScreenshot}
onDownload={downloadScreenshot}
onReanalyze={analyzeScreenshot}
/>
{screenshotData && (
<>
<Button
onClick={downloadScreenshot}
variant="outlined"
startIcon={<DownloadIcon />}
>
Download
</Button>
<Button
onClick={analyzeScreenshot}
variant="outlined"
disabled={isAnalyzing}
startIcon={<RefreshIcon />}
>
Re-analyze
</Button>
</>
)}
</Box>
{isAnalyzing && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4, gap: 1.5 }}>
<CircularProgress size={24} />
<Typography color="text.secondary">Analyzing with heuristics...</Typography>
</Box>
)}
{analysisReport && !isAnalyzing && (
<Card variant="outlined" sx={{ bgcolor: 'action.hover' }}>
<CardHeader
avatar={<EyeIcon />}
title="Heuristic Analysis"
titleTypographyProps={{ variant: 'subtitle1' }}
/>
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{analysisResult && (
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Chip size="small" label={`Words: ${analysisResult.metrics.wordCount}`} />
<Chip size="small" label={`Headings: ${analysisResult.metrics.headingCount}`} />
<Chip size="small" label={`Links: ${analysisResult.metrics.linkCount}`} />
<Chip size="small" label={`Buttons: ${analysisResult.metrics.buttonCount}`} />
<Chip size="small" label={`Images: ${analysisResult.metrics.imgCount}`} />
<Chip size="small" label={`Missing alt: ${analysisResult.metrics.imgMissingAltCount}`} />
</Stack>
)}
{analysisResult?.warnings.length ? (
<Box>
<Typography variant="subtitle2" gutterBottom>Warnings</Typography>
<Box component="ul" sx={{ pl: 3, m: 0 }}>
{analysisResult.warnings.map((warning) => (
<li key={warning}>
<Typography variant="body2">{warning}</Typography>
</li>
))}
</Box>
</Box>
) : null}
<Typography
component="pre"
sx={{
whiteSpace: 'pre-wrap',
fontFamily: 'inherit',
fontSize: '0.875rem',
}}
>
{analysisReport}
</Typography>
</CardContent>
</Card>
)}
{screenshotData && (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 2, bgcolor: 'action.hover' }}>
<Typography variant="subtitle2" gutterBottom>Screenshot Preview</Typography>
<Box sx={{ maxHeight: 384, overflow: 'auto', border: 1, borderColor: 'divider', borderRadius: 1 }}>
<Box component="img" src={screenshotData} alt="Page screenshot" sx={{ width: '100%' }} />
</Box>
</Box>
)}
</CardContent>
</Card>
<ResultPanel analysisReport={analysisReport} analysisResult={analysisResult} />
<Card>
<CardHeader title="Page Information" />
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@/components/ui'
interface ConnectionFormProps {
defaultUrl?: string
defaultApiKey?: string
isConnecting?: boolean
statusMessage?: string
onConnect?: (config: { endpoint: string; apiKey: string }) => void
}
export function ConnectionForm({
defaultUrl = '',
defaultApiKey = '',
isConnecting = false,
statusMessage,
onConnect,
}: ConnectionFormProps) {
const [endpoint, setEndpoint] = useState(defaultUrl)
const [apiKey, setApiKey] = useState(defaultApiKey)
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
onConnect?.({ endpoint, apiKey })
}
return (
<Card>
<CardHeader>
<CardTitle>DBAL Connection</CardTitle>
<CardDescription>Configure the DBAL endpoint used by the demos</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="dbal-endpoint">Endpoint</Label>
<Input
id="dbal-endpoint"
placeholder="http://localhost:8080/api/dbal"
value={endpoint}
onChange={(event) => setEndpoint(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dbal-api-key">API Key</Label>
<Input
id="dbal-api-key"
type="password"
placeholder="Optional"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={isConnecting}>
{isConnecting ? 'Connecting…' : 'Connect'}
</Button>
{statusMessage ? <p className="text-sm text-muted-foreground">{statusMessage}</p> : null}
</div>
</form>
</CardContent>
</Card>
)
}
@@ -0,0 +1,31 @@
import { Card, CardContent, CardHeader, CardTitle, ScrollArea } from '@/components/ui'
interface LogsPanelProps {
logs: string[]
title?: string
}
export function LogsPanel({ logs, title = 'Activity' }: LogsPanelProps) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64 rounded border bg-muted/50 p-3 font-mono text-sm">
<div className="space-y-2">
{logs.length === 0 ? (
<p className="text-muted-foreground">No events yet</p>
) : (
logs.map((entry, index) => (
<div key={index} className="text-foreground">
{entry}
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
)
}
@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
interface ResultPanelProps {
title?: string
result: unknown
emptyLabel?: string
}
export function ResultPanel({ title = 'Latest Result', result, emptyLabel = 'No result yet' }: ResultPanelProps) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{result ? (
<pre className="whitespace-pre-wrap break-words rounded bg-muted/50 p-3 text-sm">
{JSON.stringify(result, null, 2)}
</pre>
) : (
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,138 @@
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, ScrollArea } from '@/components/ui'
import { Gear, PaperPlaneTilt, SignOut, Users } from '@phosphor-icons/react'
import { UserList } from './UserList'
import type { ChatMessage } from './types'
interface ChatWindowProps {
channelName: string
messages: ChatMessage[]
formattedTimes: Record<string, string>
onlineUsers: string[]
inputMessage: string
onInputChange: (value: string) => void
onSendMessage: () => void
onToggleSettings: () => void
showSettings: boolean
onClose?: () => void
onInputKeyPress?: (event: React.KeyboardEvent) => void
}
export function ChatWindow({
channelName,
messages,
formattedTimes,
onlineUsers,
inputMessage,
onInputChange,
onSendMessage,
onToggleSettings,
showSettings,
onClose,
onInputKeyPress,
}: ChatWindowProps) {
const getMessageStyle = (message: ChatMessage) => {
if (message.type === 'system' || message.type === 'join' || message.type === 'leave' || message.type === 'command') {
return 'text-muted-foreground italic text-sm'
}
return ''
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader className="border-b border-border pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<span className="font-mono">#</span>
{channelName}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
<Users size={14} />
{onlineUsers.length}
</Badge>
<Button size="sm" variant="ghost" onClick={onToggleSettings}>
<Gear size={16} />
</Button>
{onClose && (
<Button size="sm" variant="ghost" onClick={onClose}>
<SignOut size={16} />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
<div className="flex flex-1 overflow-hidden">
<ScrollArea className="flex-1 p-4">
<div className="space-y-2 font-mono text-sm">
{messages.map((message) => (
<div key={message.id} className={getMessageStyle(message)}>
{message.type === 'message' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span className="font-semibold shrink-0 text-primary">&lt;{message.username}&gt;</span>
<span className="break-words">{message.message}</span>
</div>
)}
{message.type === 'system' && message.username === 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span>*** {message.message}</span>
</div>
)}
{message.type === 'system' && message.username !== 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span className="text-accent">* {message.username} {message.message}</span>
</div>
)}
{(message.type === 'join' || message.type === 'leave') && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span className={message.type === 'join' ? 'text-green-500' : 'text-orange-500'}>
--&gt; {message.message}
</span>
</div>
)}
{message.type === 'command' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span className="text-muted-foreground">{message.message}</span>
</div>
)}
</div>
))}
</div>
</ScrollArea>
{showSettings && (
<div className="w-48 border-l border-border p-4 bg-muted/20">
<h4 className="font-semibold text-sm mb-3">Online Users</h4>
<UserList users={onlineUsers} />
</div>
)}
</div>
<div className="border-t border-border p-4">
<div className="flex gap-2">
<Input
value={inputMessage}
onChange={(event) => onInputChange(event.target.value)}
onKeyPress={onInputKeyPress}
placeholder="Type a message... (/help for commands)"
className="flex-1 font-mono"
/>
<Button onClick={onSendMessage} size="icon">
<PaperPlaneTilt size={18} />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">Press Enter to send. Type /help for commands.</p>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,20 @@
interface UserListProps {
users: string[]
}
export function UserList({ users }: UserListProps) {
if (users.length === 0) {
return <p className="text-sm text-muted-foreground">No users online</p>
}
return (
<div className="space-y-1.5 text-sm">
{users.map((username) => (
<div key={username} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span>{username}</span>
</div>
))}
</div>
)
}
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
import type { ChatMessage } from './types'
type TimestampFormatter = (timestamp: number) => Promise<string> | string
export function useChatInput(onSubmit: () => void) {
const [inputMessage, setInputMessage] = useState('')
const handleKeyPress = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
onSubmit()
}
}
return {
inputMessage,
setInputMessage,
handleKeyPress,
}
}
export function useFormattedTimes(messages: ChatMessage[] | undefined, formatTime: TimestampFormatter) {
const [formattedTimes, setFormattedTimes] = useState<Record<string, string>>({})
useEffect(() => {
let isMounted = true
const formatAllTimes = async () => {
if (!messages) {
setFormattedTimes({})
return
}
const entries = await Promise.all(
messages.map(async (message) => {
const formatted = await formatTime(message.timestamp)
return [message.id, formatted] as const
}),
)
if (isMounted) {
setFormattedTimes(Object.fromEntries(entries))
}
}
formatAllTimes()
return () => {
isMounted = false
}
}, [messages, formatTime])
return formattedTimes
}
@@ -0,0 +1,10 @@
export type ChatMessageType = 'message' | 'system' | 'join' | 'leave' | 'command'
export interface ChatMessage {
id: string
username: string
userId: string
message: string
timestamp: number
type: ChatMessageType
}
@@ -0,0 +1,56 @@
import { Box, Card, CardContent, CardHeader, Chip, Stack, Typography } from '@mui/material'
import { Visibility as EyeIcon } from '@mui/icons-material'
import type { ScreenshotAnalysisResult } from '@/lib/screenshot/types'
interface ResultPanelProps {
analysisReport: string
analysisResult: ScreenshotAnalysisResult | null
}
export function ResultPanel({ analysisReport, analysisResult }: ResultPanelProps) {
if (!analysisReport) return null
return (
<Card variant="outlined" sx={{ bgcolor: 'action.hover' }}>
<CardHeader avatar={<EyeIcon />} title="Heuristic Analysis" titleTypographyProps={{ variant: 'subtitle1' }} />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{analysisResult && (
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Chip size="small" label={`Words: ${analysisResult.metrics.wordCount}`} />
<Chip size="small" label={`Headings: ${analysisResult.metrics.headingCount}`} />
<Chip size="small" label={`Links: ${analysisResult.metrics.linkCount}`} />
<Chip size="small" label={`Buttons: ${analysisResult.metrics.buttonCount}`} />
<Chip size="small" label={`Images: ${analysisResult.metrics.imgCount}`} />
<Chip size="small" label={`Missing alt: ${analysisResult.metrics.imgMissingAltCount}`} />
</Stack>
)}
{analysisResult?.warnings.length ? (
<Box>
<Typography variant="subtitle2" gutterBottom>
Warnings
</Typography>
<Box component="ul" sx={{ pl: 3, m: 0 }}>
{analysisResult.warnings.map((warning) => (
<li key={warning}>
<Typography variant="body2">{warning}</Typography>
</li>
))}
</Box>
</Box>
) : null}
<Typography
component="pre"
sx={{
whiteSpace: 'pre-wrap',
fontFamily: 'inherit',
fontSize: '0.875rem',
}}
>
{analysisReport}
</Typography>
</CardContent>
</Card>
)
}
@@ -0,0 +1,71 @@
import { Box, Button, Card, CardContent, CardHeader, CircularProgress, Typography } from '@mui/material'
import { CameraAlt as CameraIcon, Download as DownloadIcon, Refresh as RefreshIcon } from '@mui/icons-material'
interface UploadSectionProps {
isCapturing: boolean
isAnalyzing: boolean
screenshotData: string | null
onCapture: () => void
onDownload: () => void
onReanalyze: () => void
previewTitle?: string
}
export function UploadSection({
isCapturing,
isAnalyzing,
screenshotData,
onCapture,
onDownload,
onReanalyze,
previewTitle = 'Screenshot Preview',
}: UploadSectionProps) {
return (
<Card>
<CardHeader title="Capture & Analyze" subheader="Create a DOM snapshot and run heuristic checks" />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
onClick={onCapture}
disabled={isCapturing || isAnalyzing}
variant="contained"
startIcon={<CameraIcon />}
sx={{ flex: 1 }}
>
{isCapturing ? 'Capturing...' : 'Capture & Analyze'}
</Button>
{screenshotData && (
<>
<Button onClick={onDownload} variant="outlined" startIcon={<DownloadIcon />}>
Download
</Button>
<Button onClick={onReanalyze} variant="outlined" disabled={isAnalyzing} startIcon={<RefreshIcon />}>
Re-analyze
</Button>
</>
)}
</Box>
{isAnalyzing && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4, gap: 1.5 }}>
<CircularProgress size={24} />
<Typography color="text.secondary">Analyzing with heuristics...</Typography>
</Box>
)}
{screenshotData && (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 2, bgcolor: 'action.hover' }}>
<Typography variant="subtitle2" gutterBottom>
{previewTitle}
</Typography>
<Box sx={{ maxHeight: 384, overflow: 'auto', border: 1, borderColor: 'divider', borderRadius: 1 }}>
<Box component="img" src={screenshotData} alt="Page screenshot" sx={{ width: '100%' }} />
</Box>
</Box>
)}
</CardContent>
</Card>
)
}
@@ -1,101 +1,40 @@
import { useEffect, useState } from 'react'
import { Stack } from '@mui/material'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { formatWorkflowRunAnalysis, summarizeWorkflowRuns } from '@/lib/github/analyze-workflow-runs'
import { toast } from 'sonner'
import { useWorkflowRuns } from './hooks/useWorkflowRuns'
import { useWorkflowLogAnalysis } from './hooks/useWorkflowLogAnalysis'
import { useActionsFetcher } from './workflows/useActionsFetcher'
import { AnalysisPanel } from './views/AnalysisPanel'
import { RunDetails } from './views/RunDetails'
import { RunList } from './views/RunList'
export function GitHubActionsFetcher() {
const [analysis, setAnalysis] = useState<string | null>(null)
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [activeTab, setActiveTab] = useState<'runs' | 'logs' | 'analysis'>(
'runs',
)
const {
runs,
isLoading,
error,
needsAuth,
repoInfo,
repoLabel,
lastFetched,
secondsUntilRefresh,
autoRefreshEnabled,
secondsUntilRefresh,
toggleAutoRefresh,
downloadWorkflowData,
fetchRuns,
getStatusColor,
isLoadingLogs,
conclusion,
summaryTone,
} = useWorkflowRuns()
const {
analyzeRunLogs,
downloadRunLogs,
isLoadingLogs,
runJobs,
runLogs,
selectedRunId,
} = useWorkflowLogAnalysis({
repoInfo,
onAnalysisStart: () => setIsAnalyzing(true),
onAnalysisComplete: (report) => {
if (report) {
setAnalysis(report)
}
setIsAnalyzing(false)
},
})
const downloadWorkflowData = () => {
if (!runs) return
const jsonData = JSON.stringify(runs, null, 2)
const blob = new Blob([jsonData], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `github-actions-${new Date().toISOString()}.json`
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
toast.success('Downloaded workflow data')
}
const analyzeWorkflows = async () => {
if (!runs || runs.length === 0) {
toast.error('No data to analyze')
return
}
setIsAnalyzing(true)
try {
const summary = summarizeWorkflowRuns(runs)
const report = formatWorkflowRunAnalysis(summary)
setAnalysis(report)
toast.success('Analysis complete')
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Analysis failed'
toast.error(errorMessage)
} finally {
setIsAnalyzing(false)
}
}
const handleAnalyzeLogs = () => analyzeRunLogs(runs)
useEffect(() => {
if (runLogs && activeTab === 'runs') {
setActiveTab('logs')
}
}, [activeTab, runLogs])
runLogs,
runJobs,
analyzeLogs,
analyzeWorkflows,
downloadRunLogs,
analysis,
isAnalyzing,
activeTab,
setActiveTab,
} = useActionsFetcher()
return (
<Stack spacing={3}>
@@ -134,7 +73,7 @@ export function GitHubActionsFetcher() {
runLogs={runLogs}
runJobs={runJobs}
selectedRunId={selectedRunId}
onAnalyzeLogs={handleAnalyzeLogs}
onAnalyzeLogs={analyzeLogs}
isAnalyzing={isAnalyzing}
/>
</TabsContent>
@@ -145,7 +84,7 @@ export function GitHubActionsFetcher() {
analysis={analysis}
isAnalyzing={isAnalyzing}
runLogs={runLogs}
onAnalyzeLogs={handleAnalyzeLogs}
onAnalyzeLogs={analyzeLogs}
onAnalyzeWorkflows={analyzeWorkflows}
/>
</TabsContent>
@@ -1,14 +1,11 @@
import { Box, Stack, Typography } from '@mui/material'
import { Stack } from '@mui/material'
import { CheckCircle as SuccessIcon } from '@mui/icons-material'
import { Card, CardContent, CardHeader } from '@/components/ui'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
import type { WorkflowRun } from '../types'
import { Filters } from './run-list/Filters'
import { RefreshControls } from './run-list/RefreshControls'
import { RunItemCard } from './run-list/RunItemCard'
import { RunListAlerts } from './run-list/RunListAlerts'
import { RunListEmptyState } from './run-list/RunListEmptyState'
import { RunTable } from './run-list/Table'
import type { RunListProps } from './run-list/run-list.types'
export function RunList({
@@ -39,32 +36,7 @@ export function RunList({
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>
<Filters repoLabel={repoLabel} lastFetched={lastFetched} />
<RefreshControls
autoRefreshEnabled={autoRefreshEnabled}
@@ -86,57 +58,14 @@ export function RunList({
summaryTone={summaryTone}
/>
<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: WorkflowRun) => (
<RunItemCard
key={run.id}
run={run}
getStatusColor={getStatusColor}
onDownloadLogs={onDownloadLogs}
isLoadingLogs={isLoadingLogs}
selectedRunId={selectedRunId}
/>
))}
<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>
) : (
<RunListEmptyState isLoading={isLoading} />
)}
</CardContent>
</Card>
<RunTable
runs={runs}
isLoading={isLoading}
getStatusColor={getStatusColor}
onDownloadLogs={onDownloadLogs}
isLoadingLogs={isLoadingLogs}
selectedRunId={selectedRunId}
/>
</CardContent>
</Card>
)
@@ -0,0 +1,34 @@
import { Box, Stack, Typography } from '@mui/material'
import type { RunListProps } from './run-list.types'
type FiltersProps = Pick<RunListProps, 'repoLabel' | 'lastFetched'>
export const Filters = ({ repoLabel, lastFetched }: FiltersProps) => (
<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>
)
@@ -7,21 +7,22 @@ import type { WorkflowRun } from '../types'
import type { RunListProps } from './run-list.types'
import { spinSx } from './run-list.types'
type RunItemCardProps = Pick<
type RunRowProps = Pick<
RunListProps,
'getStatusColor' | 'onDownloadLogs' | 'isLoadingLogs' | 'selectedRunId'
> & {
run: WorkflowRun
}
export const RunItemCard = ({
export const RunRow = ({
run,
getStatusColor,
onDownloadLogs,
isLoadingLogs,
selectedRunId,
}: RunItemCardProps) => {
}: RunRowProps) => {
const statusIcon = getStatusColor(run.status, run.conclusion)
const isSelectedRun = isLoadingLogs && selectedRunId === run.id
return (
<Card variant="outlined" sx={{ borderColor: 'divider' }}>
@@ -81,14 +82,14 @@ export const RunItemCard = ({
variant="outline"
size="sm"
onClick={() => onDownloadLogs(run.id, run.name)}
disabled={isLoadingLogs && selectedRunId === run.id}
disabled={isSelectedRun}
startIcon={
isLoadingLogs && selectedRunId === run.id
isSelectedRun
? <RunningIcon sx={{ fontSize: 16, ...spinSx }} />
: <DownloadIcon sx={{ fontSize: 16 }} />
}
>
{isLoadingLogs && selectedRunId === run.id ? 'Loading...' : 'Download Logs'}
{isSelectedRun ? 'Loading...' : 'Download Logs'}
</Button>
<Button
variant="outline"
@@ -0,0 +1,81 @@
import { Box, Stack } from '@mui/material'
import { CheckCircle as SuccessIcon } from '@mui/icons-material'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
import type { RunListProps } from './run-list.types'
import { RunListEmptyState } from './RunListEmptyState'
import { RunRow } from './RunRow'
type RunTableProps = Pick<
RunListProps,
| 'runs'
| 'isLoading'
| 'getStatusColor'
| 'onDownloadLogs'
| 'isLoadingLogs'
| 'selectedRunId'
>
export const RunTable = ({
runs,
isLoading,
getStatusColor,
onDownloadLogs,
isLoadingLogs,
selectedRunId,
}: RunTableProps) => {
const copyRunsToClipboard = () => {
if (!runs) return
const jsonData = JSON.stringify(runs, null, 2)
navigator.clipboard.writeText(jsonData)
}
return (
<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) => (
<RunRow
key={run.id}
run={run}
getStatusColor={getStatusColor}
onDownloadLogs={onDownloadLogs}
isLoadingLogs={isLoadingLogs}
selectedRunId={selectedRunId}
/>
))}
<Box sx={{ textAlign: 'center', pt: 2 }}>
<Button variant="outline" onClick={copyRunsToClipboard}>
Copy All as JSON
</Button>
</Box>
</Stack>
) : (
<RunListEmptyState isLoading={isLoading} />
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,12 @@
import { useWorkflowRunsApi } from './useWorkflowRunsApi'
import { useWorkflowRunsSelectors } from './useWorkflowRunsSelectors'
export function useWorkflowRuns() {
const api = useWorkflowRunsApi()
const selectors = useWorkflowRunsSelectors(api.runs)
return {
...api,
...selectors,
}
}
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { WorkflowRun, RepoInfo } from '../types'
import { RepoInfo, WorkflowRun } from '../../types'
const DEFAULT_REPO_LABEL = 'johndoe6345789/metabuilder'
export function useWorkflowRuns() {
export function useWorkflowRunsApi() {
const [runs, setRuns] = useState<WorkflowRun[] | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -15,7 +15,10 @@ export function useWorkflowRuns() {
const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(30)
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
const repoLabel = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL
const repoLabel = useMemo(
() => (repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL),
[repoInfo],
)
const fetchRuns = useCallback(async () => {
setIsLoading(true)
@@ -86,72 +89,6 @@ export function useWorkflowRuns() {
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,
@@ -164,8 +101,5 @@ export function useWorkflowRuns() {
autoRefreshEnabled,
toggleAutoRefresh,
fetchRuns,
getStatusColor,
conclusion,
summaryTone,
}
}
@@ -0,0 +1,97 @@
import { useCallback, useMemo } from 'react'
import { WorkflowRun } from '../../types'
const TIME_THRESHOLD_MS = 5 * 60 * 1000
type PipelineHealth = 'healthy' | 'warning' | 'critical'
type PipelineSummary = {
total: number
completed: number
successful: number
failed: number
cancelled: number
inProgress: number
successRate: number
health: PipelineHealth
recentWorkflows: WorkflowRun[]
mostRecentPassed: boolean
mostRecentFailed: boolean
mostRecentRunning: boolean
}
type SummaryTone = 'success' | 'error' | 'warning'
export const useWorkflowRunsSelectors = (runs: WorkflowRun[] | null) => {
const getStatusColor = useCallback((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<PipelineSummary | null>(() => {
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 recentWorkflows = runs.filter((run) => {
const runTimestamp = new Date(run.updated_at).getTime()
return mostRecentTimestamp - runTimestamp <= TIME_THRESHOLD_MS
})
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: PipelineHealth = '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<SummaryTone>(() => {
if (!conclusion) return 'warning'
if (conclusion.mostRecentPassed) return 'success'
if (conclusion.mostRecentFailed) return 'error'
return 'warning'
}, [conclusion])
return {
getStatusColor,
conclusion,
summaryTone,
}
}
@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { formatWorkflowRunAnalysis, summarizeWorkflowRuns } from '@/lib/github/analyze-workflow-runs'
import { useWorkflowLogAnalysis } from '../hooks/useWorkflowLogAnalysis'
import { useWorkflowRuns } from './hooks/useWorkflowRuns'
export function useActionsFetcher() {
const [analysis, setAnalysis] = useState<string | null>(null)
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [activeTab, setActiveTab] = useState<'runs' | 'logs' | 'analysis'>('runs')
const workflowRuns = useWorkflowRuns()
const workflowLogAnalysis = useWorkflowLogAnalysis({
repoInfo: workflowRuns.repoInfo,
onAnalysisStart: () => setIsAnalyzing(true),
onAnalysisComplete: (report) => {
if (report) {
setAnalysis(report)
}
setIsAnalyzing(false)
},
})
const downloadWorkflowData = useCallback(() => {
if (!workflowRuns.runs) return
const jsonData = JSON.stringify(workflowRuns.runs, null, 2)
const blob = new Blob([jsonData], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `github-actions-${new Date().toISOString()}.json`
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
toast.success('Downloaded workflow data')
}, [workflowRuns.runs])
const analyzeWorkflows = useCallback(async () => {
if (!workflowRuns.runs || workflowRuns.runs.length === 0) {
toast.error('No data to analyze')
return
}
setIsAnalyzing(true)
try {
const summary = summarizeWorkflowRuns(workflowRuns.runs)
const report = formatWorkflowRunAnalysis(summary)
setAnalysis(report)
toast.success('Analysis complete')
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Analysis failed'
toast.error(errorMessage)
} finally {
setIsAnalyzing(false)
}
}, [workflowRuns.runs])
const analyzeLogs = useCallback(() => {
workflowLogAnalysis.analyzeRunLogs(workflowRuns.runs)
}, [workflowLogAnalysis, workflowRuns.runs])
useEffect(() => {
if (workflowLogAnalysis.runLogs && activeTab === 'runs') {
setActiveTab('logs')
}
}, [activeTab, workflowLogAnalysis.runLogs])
return {
...workflowRuns,
...workflowLogAnalysis,
analysis,
isAnalyzing,
activeTab,
setActiveTab,
analyzeLogs,
analyzeWorkflows,
downloadWorkflowData,
}
}
@@ -0,0 +1,188 @@
import { useMemo } from 'react'
import { Badge, Button, Input, Label, Switch, ToggleGroup, ToggleGroupItem } from '@/components/ui'
import type { OperationType, ResourceType } from '@/lib/security/secure-db/types'
import { FunnelSimple, MagnifyingGlass, X } from '@phosphor-icons/react'
interface AuditLogFiltersProps {
searchTerm: string
onSearchChange: (value: string) => void
selectedOperations: OperationType[]
onOperationsChange: (operations: OperationType[]) => void
selectedResources: ResourceType[]
onResourcesChange: (resources: ResourceType[]) => void
showFailuresOnly: boolean
onShowFailuresChange: (value: boolean) => void
availableOperations?: OperationType[]
availableResources?: ResourceType[]
onReset?: () => void
}
const DEFAULT_OPERATIONS: OperationType[] = ['CREATE', 'READ', 'UPDATE', 'DELETE']
const DEFAULT_RESOURCES: ResourceType[] = [
'user',
'workflow',
'luaScript',
'pageConfig',
'modelSchema',
'comment',
'componentNode',
'componentConfig',
'cssCategory',
'dropdownConfig',
'tenant',
'powerTransfer',
'smtpConfig',
'credential'
]
export function AuditLogFilters({
searchTerm,
onSearchChange,
selectedOperations,
onOperationsChange,
selectedResources,
onResourcesChange,
showFailuresOnly,
onShowFailuresChange,
availableOperations,
availableResources,
onReset
}: AuditLogFiltersProps) {
const operationOptions = availableOperations || DEFAULT_OPERATIONS
const resourceOptions = useMemo(
() => availableResources || DEFAULT_RESOURCES,
[availableResources]
)
return (
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<FunnelSimple weight="bold" />
<span className="text-sm font-medium">Filters</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="audit-log-search">Search</Label>
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="audit-log-search"
placeholder="Search by user, resource, or error message"
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2">
<div className="space-y-1">
<Label htmlFor="audit-log-failures">Failures only</Label>
<p className="text-xs text-muted-foreground">Show only unsuccessful operations</p>
</div>
<Switch
id="audit-log-failures"
checked={showFailuresOnly}
onCheckedChange={onShowFailuresChange}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Operations</Label>
<ToggleGroup
type="multiple"
value={selectedOperations}
onValueChange={(value) => onOperationsChange(value as OperationType[])}
className="flex flex-wrap gap-2"
>
{operationOptions.map((operation) => (
<ToggleGroupItem
key={operation}
value={operation}
className="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground"
>
{operation}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Resources</Label>
<span className="text-xs text-muted-foreground">Select one or more</span>
</div>
<div className="flex flex-wrap gap-2">
{resourceOptions.map((resource) => {
const isSelected = selectedResources.includes(resource)
return (
<Button
key={resource}
variant={isSelected ? 'default' : 'outline'}
size="sm"
onClick={() =>
onResourcesChange(
isSelected
? selectedResources.filter((value) => value !== resource)
: [...selectedResources, resource]
)
}
className="rounded-full"
>
<Badge
variant={isSelected ? 'default' : 'secondary'}
className="pointer-events-none bg-transparent px-0 text-xs capitalize"
>
{resource}
</Badge>
</Button>
)
})}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{selectedOperations.length > 0 && (
<Badge variant="outline" className="gap-1">
<span className="font-medium">Operations:</span>
<span>{selectedOperations.join(', ')}</span>
</Badge>
)}
{selectedResources.length > 0 && (
<Badge variant="outline" className="gap-1">
<span className="font-medium">Resources:</span>
<span>{selectedResources.join(', ')}</span>
</Badge>
)}
{showFailuresOnly && (
<Badge variant="destructive" className="gap-1">
<WarningIcon />
Failures
</Badge>
)}
{selectedOperations.length === 0 &&
selectedResources.length === 0 &&
!showFailuresOnly && (
<span>No filters applied</span>
)}
</div>
{onReset && (
<Button variant="ghost" size="sm" onClick={onReset} className="ml-auto gap-1">
<X className="h-4 w-4" />
Clear all
</Button>
)}
</div>
</div>
)
}
function WarningIcon() {
return <span className="inline-block h-3 w-3 rounded-full bg-destructive" />
}
@@ -0,0 +1,124 @@
import { Badge, Card, CardContent, CardHeader, CardTitle, ScrollArea, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import type { AuditLog, OperationType, ResourceType } from '@/lib/security/secure-db/types'
import { ArrowDown, ArrowUp, ShieldCheck, User as UserIcon, WarningCircle } from '@phosphor-icons/react'
interface LogTableProps {
logs: AuditLog[]
sortField?: keyof AuditLog | null
sortDirection?: 'asc' | 'desc'
onSortChange?: (field: keyof AuditLog) => void
}
const OPERATION_COLORS: Record<OperationType, string> = {
CREATE: 'bg-green-100 text-green-800',
READ: 'bg-blue-100 text-blue-800',
UPDATE: 'bg-yellow-100 text-yellow-800',
DELETE: 'bg-red-100 text-red-800'
}
const RESOURCE_ICONS: Partial<Record<ResourceType, JSX.Element>> = {
user: <UserIcon className="h-4 w-4" weight="bold" />,
credential: <ShieldCheck className="h-4 w-4" weight="bold" />
}
export function LogTable({ logs, sortField, sortDirection = 'asc', onSortChange }: LogTableProps) {
const handleSort = (field: keyof AuditLog) => {
onSortChange?.(field)
}
return (
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b bg-muted/40 py-3">
<CardTitle className="text-base font-semibold">Audit Log</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-[480px]">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<SortableHeader
field="timestamp"
label="Timestamp"
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
/>
<TableHead>User</TableHead>
<TableHead>Operation</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Status</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
No audit events to display
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.id} className="hover:bg-muted/20">
<TableCell className="text-sm text-muted-foreground">
{new Date(log.timestamp).toLocaleString()}
</TableCell>
<TableCell className="font-medium">{log.username}</TableCell>
<TableCell>
<Badge className={OPERATION_COLORS[log.operation]}>{log.operation}</Badge>
</TableCell>
<TableCell className="flex items-center gap-2">
{RESOURCE_ICONS[log.resource] || <ShieldCheck className="h-4 w-4 text-muted-foreground" />}
<span className="capitalize">{log.resource}</span>
</TableCell>
<TableCell>
{log.success ? (
<Badge variant="outline" className="border-green-200 text-green-700">
<ShieldCheck className="mr-1 h-4 w-4" />
Success
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<WarningCircle className="h-4 w-4" />
Failed
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{log.errorMessage || '—'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
)
}
interface SortableHeaderProps {
field: keyof AuditLog
label: string
sortField?: keyof AuditLog | null
sortDirection?: 'asc' | 'desc'
onSort?: (field: keyof AuditLog) => void
}
function SortableHeader({ field, label, sortField, sortDirection = 'asc', onSort }: SortableHeaderProps) {
const isActive = sortField === field
const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown
return (
<TableHead
className="cursor-pointer select-none"
onClick={() => onSort?.(field)}
>
<div className="flex items-center gap-2">
{label}
{isActive && <Icon className="h-3.5 w-3.5" />}
</div>
</TableHead>
)
}
@@ -0,0 +1,73 @@
import { Badge, Button, ScrollArea, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui'
import type { ModelSchema } from '@/lib/schema-types'
import { getFieldLabel } from '@/lib/schema-utils'
interface DetailsDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
record: any | null
model: ModelSchema
}
export function DetailsDrawer({ open, onOpenChange, record, model }: DetailsDrawerProps) {
const renderValue = (fieldName: string) => {
const field = model.fields.find((item) => item.name === fieldName)
if (!field) return null
const value = record?.[fieldName]
if (value === null || value === undefined || value === '') {
return <span className="text-muted-foreground">Not provided</span>
}
switch (field.type) {
case 'boolean':
return value ? (
<Badge variant="outline" className="bg-emerald-50 text-emerald-700">
Yes
</Badge>
) : (
<Badge variant="secondary">No</Badge>
)
case 'date':
case 'datetime':
return new Date(value).toLocaleString()
case 'json':
return <pre className="whitespace-pre-wrap rounded-md bg-muted/60 p-3 text-xs">{JSON.stringify(value, null, 2)}</pre>
default:
return <span className="font-medium text-foreground">{String(value)}</span>
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader>
<SheetTitle>{model.label || model.name} details</SheetTitle>
<SheetDescription>Review the full record and its attributes.</SheetDescription>
</SheetHeader>
<Separator className="my-4" />
<ScrollArea className="h-[70vh] pr-4">
<div className="space-y-4">
{model.fields.map((field) => (
<div key={field.name} className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs uppercase text-muted-foreground">{getFieldLabel(field)}</p>
<div className="mt-1 text-sm">{renderValue(field.name)}</div>
{field.helpText && (
<p className="mt-1 text-xs text-muted-foreground">{field.helpText}</p>
)}
</div>
))}
</div>
</ScrollArea>
<SheetFooter className="mt-6">
<SheetClose asChild>
<Button variant="outline" className="w-full sm:w-auto">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,82 @@
import { Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
import { MagnifyingGlass } from '@phosphor-icons/react'
interface ModelFiltersProps {
model: ModelSchema
filters: Record<string, any>
searchTerm: string
onSearchChange: (value: string) => void
onFilterChange: (field: string, value: any) => void
}
function getFilterableFields(model: ModelSchema): FieldSchema[] {
if (model.listFilter) {
return model.fields.filter((field) => model.listFilter?.includes(field.name))
}
return model.fields.filter((field) => field.type === 'select' || field.type === 'boolean')
}
export function ModelFilters({ model, filters, searchTerm, onSearchChange, onFilterChange }: ModelFiltersProps) {
const filterFields = getFilterableFields(model)
return (
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<Label htmlFor="model-search">Search</Label>
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="model-search"
placeholder={`Search ${model.labelPlural || model.name}`}
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
className="pl-9"
/>
</div>
</div>
{filterFields.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filterFields.map((field) => (
<div key={field.name} className="space-y-1.5">
<Label>{field.label || field.name}</Label>
{field.type === 'select' ? (
<Select
value={filters[field.name] ?? '__all__'}
onValueChange={(value) => onFilterChange(field.name, value === '__all__' ? null : value)}
>
<SelectTrigger>
<SelectValue placeholder={field.label || field.name} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
{field.choices?.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.label || choice.value}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select
value={filters[field.name] === true ? 'true' : filters[field.name] === false ? 'false' : '__all__'}
onValueChange={(value) => onFilterChange(field.name, value === 'true' ? true : value === 'false' ? false : null)}
>
<SelectTrigger>
<SelectValue placeholder={field.label || field.name} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
)}
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,148 @@
import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
import { getFieldLabel } from '@/lib/schema-utils'
import { ArrowDown, ArrowUp, Pencil, Trash } from '@phosphor-icons/react'
import { ReactNode } from 'react'
interface ModelTableProps {
model: ModelSchema
records: any[]
displayFields: string[]
sortField?: string | null
sortDirection?: 'asc' | 'desc'
onSortChange?: (field: string) => void
onEdit?: (record: any) => void
onDelete?: (id: string) => void
onRowClick?: (record: any) => void
renderRelationValue?: (value: string, field: FieldSchema) => ReactNode
}
export function ModelTable({
model,
records,
displayFields,
sortField,
sortDirection = 'asc',
onSortChange,
onEdit,
onDelete,
onRowClick,
renderRelationValue
}: ModelTableProps) {
const actionColumns = onEdit || onDelete ? 1 : 0
const renderCellValue = (record: any, fieldName: string) => {
const field = model.fields.find((item) => item.name === fieldName)
if (!field) return null
const value = record[fieldName]
if (value === null || value === undefined) {
return <span className="text-muted-foreground"></span>
}
if (field.type === 'relation' && typeof value === 'string' && renderRelationValue) {
return renderRelationValue(value, field)
}
switch (field.type) {
case 'boolean':
return value ? <Badge variant="outline">Yes</Badge> : <Badge variant="secondary">No</Badge>
case 'date':
case 'datetime':
return new Date(value).toLocaleString()
case 'json':
return <code className="text-xs">{JSON.stringify(value)}</code>
default:
return typeof value === 'string' && value.length > 60 ? `${value.slice(0, 60)}` : String(value)
}
}
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
{displayFields.map((fieldName) => {
const field = model.fields.find((item) => item.name === fieldName)
if (!field) return null
const isSortable = field.sortable !== false
const isActive = sortField === fieldName
const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown
return (
<TableHead
key={fieldName}
className={isSortable ? 'cursor-pointer select-none' : undefined}
onClick={() => isSortable && onSortChange?.(fieldName)}
>
<div className="flex items-center gap-2">
<span className="uppercase text-xs font-semibold tracking-wide">
{getFieldLabel(field)}
</span>
{isSortable && isActive && <Icon className="h-3.5 w-3.5" />}
</div>
</TableHead>
)
})}
{(onEdit || onDelete) && <TableHead className="w-24">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={displayFields.length + actionColumns} className="py-10 text-center text-muted-foreground">
No records to display
</TableCell>
</TableRow>
) : (
records.map((record) => (
<TableRow
key={record.id}
className="hover:bg-muted/30"
onClick={() => onRowClick?.(record)}
>
{displayFields.map((fieldName) => (
<TableCell key={fieldName} className="py-3">
{renderCellValue(record, fieldName)}
</TableCell>
))}
{(onEdit || onDelete) && (
<TableCell>
<div className="flex gap-2">
{onEdit && (
<Button
size="sm"
variant="ghost"
onClick={(event) => {
event.stopPropagation()
onEdit(record)
}}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{onDelete && (
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={(event) => {
event.stopPropagation()
onDelete(record.id)
}}
>
<Trash className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}
@@ -5,23 +5,27 @@ import { useKV } from '@/hooks/data/useKV'
const STORAGE_PREFIX = 'mb_kv:'
let store: Record<string, string>
const setupLocalStorage = (): void => {
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach(k => delete store[k])
}),
length: 0,
key: vi.fn(() => null),
})
}
describe('useKV validation', () => {
beforeEach(() => {
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach(k => delete store[k])
}),
length: 0,
key: vi.fn(() => null),
})
setupLocalStorage()
})
it.each([
@@ -0,0 +1,22 @@
// Domain re-exports
export * from '../auth'
export * from '../users'
export * from '../credentials'
export * from '../sessions'
export * from '../workflows'
export * from '../lua-scripts'
export * from '../pages'
export * from '../schemas'
export * from '../comments'
export * from '../app-config'
export * from '../system-config'
export * from '../components'
export * from '../css-classes'
export * from '../dropdown-configs'
export * from '../tenants'
export * from '../packages'
export * from '../power-transfers'
export * from '../smtp-config'
export * from '../god-credentials'
export * from '../database-admin'
export * from '../error-logs'
+3 -215
View File
@@ -6,220 +6,8 @@ export { DB_KEYS } from './types'
export { getAdapter, closeAdapter } from './dbal-client'
export type { DBALAdapter, ListOptions, ListResult } from './dbal-client'
// Core
export { hashPassword } from '../password/hash-password'
export { verifyPassword } from '../password/verify-password'
export { initializeDatabase } from './initialize-database'
// Operations
export { hashPassword, verifyPassword, initializeDatabase, Database } from './operations'
// Domain re-exports
export * from '../auth'
export * from '../users'
export * from '../credentials'
export * from '../sessions'
export * from '../workflows'
export * from '../lua-scripts'
export * from '../pages'
export * from '../schemas'
export * from '../comments'
export * from '../app-config'
export * from '../system-config'
export * from '../components'
export * from '../css-classes'
export * from '../dropdown-configs'
export * from '../tenants'
export * from '../packages'
export * from '../power-transfers'
export * from '../smtp-config'
export * from '../god-credentials'
export * from '../database-admin'
export * from '../error-logs'
// Import all for namespace class
import { initializeDatabase } from './initialize-database'
import { hashPassword } from '../password/hash-password'
import { verifyPassword } from '../password/verify-password'
import * as auth from '../auth'
import * as users from '../users'
import * as credentials from '../credentials'
import * as sessions from '../sessions'
import * as workflows from '../workflows'
import * as luaScripts from '../lua-scripts'
import * as pages from '../pages'
import * as schemas from '../schemas'
import * as comments from '../comments'
import * as appConfig from '../app-config'
import * as systemConfig from '../system-config'
import * as components from '../components'
import * as cssClasses from '../css-classes'
import * as dropdownConfigs from '../dropdown-configs'
import * as tenants from '../tenants'
import * as packages from '../packages'
import * as powerTransfers from '../power-transfers'
import * as smtpConfig from '../smtp-config'
import * as godCredentials from '../god-credentials'
import * as databaseAdmin from '../database-admin'
import * as errorLogs from '../error-logs'
/**
* Database namespace class - groups all DB operations as static methods
* No instance state - pure function container for backward compatibility
*/
export class Database {
// Core
static initializeDatabase = initializeDatabase
static hashPassword = hashPassword
static verifyPassword = verifyPassword
// Auth
static authenticateUser = auth.authenticateUser
static getUserByUsername = auth.getUserByUsername
static getUserByEmail = auth.getUserByEmail
// Users
static getUsers = users.getUsers
static getUserById = users.getUserById
static setUsers = users.setUsers
static addUser = users.addUser
static updateUser = users.updateUser
static deleteUser = users.deleteUser
static getSuperGod = users.getSuperGod
static transferSuperGodPower = users.transferSuperGodPower
// Credentials
static getCredentials = credentials.getCredentials
static setCredential = credentials.setCredential
static verifyCredentials = credentials.verifyCredentials
static getPasswordChangeTimestamps = credentials.getPasswordChangeTimestamps
static setPasswordChangeTimestamps = credentials.setPasswordChangeTimestamps
static getPasswordResetTokens = credentials.getPasswordResetTokens
static setPasswordResetToken = credentials.setPasswordResetToken
static deletePasswordResetToken = credentials.deletePasswordResetToken
// Sessions
static createSession = sessions.createSession
static getSessionById = sessions.getSessionById
static getSessionByToken = sessions.getSessionByToken
static updateSession = sessions.updateSession
static deleteSession = sessions.deleteSession
static deleteSessionByToken = sessions.deleteSessionByToken
static listSessions = sessions.listSessions
// Workflows
static getWorkflows = workflows.getWorkflows
static setWorkflows = workflows.setWorkflows
static addWorkflow = workflows.addWorkflow
static updateWorkflow = workflows.updateWorkflow
static deleteWorkflow = workflows.deleteWorkflow
// Lua Scripts
static getLuaScripts = luaScripts.getLuaScripts
static setLuaScripts = luaScripts.setLuaScripts
static addLuaScript = luaScripts.addLuaScript
static updateLuaScript = luaScripts.updateLuaScript
static deleteLuaScript = luaScripts.deleteLuaScript
// Pages
static getPages = pages.getPages
static setPages = pages.setPages
static addPage = pages.addPage
static updatePage = pages.updatePage
static deletePage = pages.deletePage
// Schemas
static getSchemas = schemas.getSchemas
static setSchemas = schemas.setSchemas
static addSchema = schemas.addSchema
static updateSchema = schemas.updateSchema
static deleteSchema = schemas.deleteSchema
// Comments
static getComments = comments.getComments
static setComments = comments.setComments
static addComment = comments.addComment
static updateComment = comments.updateComment
static deleteComment = comments.deleteComment
// App Config
static getAppConfig = appConfig.getAppConfig
static setAppConfig = appConfig.setAppConfig
// System Config
static getSystemConfigValue = systemConfig.getSystemConfigValue
// Components
static getComponentHierarchy = components.getComponentHierarchy
static setComponentHierarchy = components.setComponentHierarchy
static addComponentNode = components.addComponentNode
static updateComponentNode = components.updateComponentNode
static deleteComponentNode = components.deleteComponentNode
static getComponentConfigs = components.getComponentConfigs
static setComponentConfigs = components.setComponentConfigs
static addComponentConfig = components.addComponentConfig
static updateComponentConfig = components.updateComponentConfig
static deleteComponentConfig = components.deleteComponentConfig
// CSS Classes
static getCssClasses = cssClasses.getCssClasses
static setCssClasses = cssClasses.setCssClasses
static addCssCategory = cssClasses.addCssCategory
static updateCssCategory = cssClasses.updateCssCategory
static deleteCssCategory = cssClasses.deleteCssCategory
// Dropdown Configs
static getDropdownConfigs = dropdownConfigs.getDropdownConfigs
static setDropdownConfigs = dropdownConfigs.setDropdownConfigs
static addDropdownConfig = dropdownConfigs.addDropdownConfig
static updateDropdownConfig = dropdownConfigs.updateDropdownConfig
static deleteDropdownConfig = dropdownConfigs.deleteDropdownConfig
// Tenants
static getTenants = tenants.getTenants
static setTenants = tenants.setTenants
static addTenant = tenants.addTenant
static updateTenant = tenants.updateTenant
static deleteTenant = tenants.deleteTenant
// Packages
static getInstalledPackages = packages.getInstalledPackages
static setInstalledPackages = packages.setInstalledPackages
static installPackage = packages.installPackage
static uninstallPackage = packages.uninstallPackage
static togglePackageEnabled = packages.togglePackageEnabled
static getPackageData = packages.getPackageData
static setPackageData = packages.setPackageData
static deletePackageData = packages.deletePackageData
// Power Transfers
static getPowerTransferRequests = powerTransfers.getPowerTransferRequests
static setPowerTransferRequests = powerTransfers.setPowerTransferRequests
static addPowerTransferRequest = powerTransfers.addPowerTransferRequest
static updatePowerTransferRequest = powerTransfers.updatePowerTransferRequest
static deletePowerTransferRequest = powerTransfers.deletePowerTransferRequest
// SMTP Config
static getSMTPConfig = smtpConfig.getSMTPConfig
static setSMTPConfig = smtpConfig.setSMTPConfig
// God Credentials
static getGodCredentialsExpiry = godCredentials.getGodCredentialsExpiry
static setGodCredentialsExpiry = godCredentials.setGodCredentialsExpiry
static getFirstLoginFlags = godCredentials.getFirstLoginFlags
static setFirstLoginFlag = godCredentials.setFirstLoginFlag
static getGodCredentialsExpiryDuration = godCredentials.getGodCredentialsExpiryDuration
static setGodCredentialsExpiryDuration = godCredentials.setGodCredentialsExpiryDuration
static shouldShowGodCredentials = godCredentials.shouldShowGodCredentials
static resetGodCredentialsExpiry = godCredentials.resetGodCredentialsExpiry
// Database Admin
static clearDatabase = databaseAdmin.clearDatabase
static exportDatabase = databaseAdmin.exportDatabase
static importDatabase = databaseAdmin.importDatabase
static seedDefaultData = databaseAdmin.seedDefaultData
// Error Logs
static getErrorLogs = errorLogs.getErrorLogs
static addErrorLog = errorLogs.addErrorLog
static updateErrorLog = errorLogs.updateErrorLog
static deleteErrorLog = errorLogs.deleteErrorLog
static clearErrorLogs = errorLogs.clearErrorLogs
}
export * from './entities'
@@ -0,0 +1,190 @@
import { initializeDatabase } from './initialize-database'
import { hashPassword } from '../password/hash-password'
import { verifyPassword } from '../password/verify-password'
import * as auth from '../auth'
import * as users from '../users'
import * as credentials from '../credentials'
import * as sessions from '../sessions'
import * as workflows from '../workflows'
import * as luaScripts from '../lua-scripts'
import * as pages from '../pages'
import * as schemas from '../schemas'
import * as comments from '../comments'
import * as appConfig from '../app-config'
import * as systemConfig from '../system-config'
import * as components from '../components'
import * as cssClasses from '../css-classes'
import * as dropdownConfigs from '../dropdown-configs'
import * as tenants from '../tenants'
import * as packages from '../packages'
import * as powerTransfers from '../power-transfers'
import * as smtpConfig from '../smtp-config'
import * as godCredentials from '../god-credentials'
import * as databaseAdmin from '../database-admin'
import * as errorLogs from '../error-logs'
export { initializeDatabase, hashPassword, verifyPassword }
/**
* Database namespace class - groups all DB operations as static methods
* No instance state - pure function container for backward compatibility
*/
export class Database {
// Core
static initializeDatabase = initializeDatabase
static hashPassword = hashPassword
static verifyPassword = verifyPassword
// Auth
static authenticateUser = auth.authenticateUser
static getUserByUsername = auth.getUserByUsername
static getUserByEmail = auth.getUserByEmail
// Users
static getUsers = users.getUsers
static getUserById = users.getUserById
static setUsers = users.setUsers
static addUser = users.addUser
static updateUser = users.updateUser
static deleteUser = users.deleteUser
static getSuperGod = users.getSuperGod
static transferSuperGodPower = users.transferSuperGodPower
// Credentials
static getCredentials = credentials.getCredentials
static setCredential = credentials.setCredential
static verifyCredentials = credentials.verifyCredentials
static getPasswordChangeTimestamps = credentials.getPasswordChangeTimestamps
static setPasswordChangeTimestamps = credentials.setPasswordChangeTimestamps
static getPasswordResetTokens = credentials.getPasswordResetTokens
static setPasswordResetToken = credentials.setPasswordResetToken
static deletePasswordResetToken = credentials.deletePasswordResetToken
// Sessions
static createSession = sessions.createSession
static getSessionById = sessions.getSessionById
static getSessionByToken = sessions.getSessionByToken
static updateSession = sessions.updateSession
static deleteSession = sessions.deleteSession
static deleteSessionByToken = sessions.deleteSessionByToken
static listSessions = sessions.listSessions
// Workflows
static getWorkflows = workflows.getWorkflows
static setWorkflows = workflows.setWorkflows
static addWorkflow = workflows.addWorkflow
static updateWorkflow = workflows.updateWorkflow
static deleteWorkflow = workflows.deleteWorkflow
// Lua Scripts
static getLuaScripts = luaScripts.getLuaScripts
static setLuaScripts = luaScripts.setLuaScripts
static addLuaScript = luaScripts.addLuaScript
static updateLuaScript = luaScripts.updateLuaScript
static deleteLuaScript = luaScripts.deleteLuaScript
// Pages
static getPages = pages.getPages
static setPages = pages.setPages
static addPage = pages.addPage
static updatePage = pages.updatePage
static deletePage = pages.deletePage
// Schemas
static getSchemas = schemas.getSchemas
static setSchemas = schemas.setSchemas
static addSchema = schemas.addSchema
static updateSchema = schemas.updateSchema
static deleteSchema = schemas.deleteSchema
// Comments
static getComments = comments.getComments
static setComments = comments.setComments
static addComment = comments.addComment
static updateComment = comments.updateComment
static deleteComment = comments.deleteComment
// App Config
static getAppConfig = appConfig.getAppConfig
static setAppConfig = appConfig.setAppConfig
// System Config
static getSystemConfigValue = systemConfig.getSystemConfigValue
// Components
static getComponentHierarchy = components.getComponentHierarchy
static setComponentHierarchy = components.setComponentHierarchy
static addComponentNode = components.addComponentNode
static updateComponentNode = components.updateComponentNode
static deleteComponentNode = components.deleteComponentNode
static getComponentConfigs = components.getComponentConfigs
static setComponentConfigs = components.setComponentConfigs
static addComponentConfig = components.addComponentConfig
static updateComponentConfig = components.updateComponentConfig
static deleteComponentConfig = components.deleteComponentConfig
// CSS Classes
static getCssClasses = cssClasses.getCssClasses
static setCssClasses = cssClasses.setCssClasses
static addCssCategory = cssClasses.addCssCategory
static updateCssCategory = cssClasses.updateCssCategory
static deleteCssCategory = cssClasses.deleteCssCategory
// Dropdown Configs
static getDropdownConfigs = dropdownConfigs.getDropdownConfigs
static setDropdownConfigs = dropdownConfigs.setDropdownConfigs
static addDropdownConfig = dropdownConfigs.addDropdownConfig
static updateDropdownConfig = dropdownConfigs.updateDropdownConfig
static deleteDropdownConfig = dropdownConfigs.deleteDropdownConfig
// Tenants
static getTenants = tenants.getTenants
static setTenants = tenants.setTenants
static addTenant = tenants.addTenant
static updateTenant = tenants.updateTenant
static deleteTenant = tenants.deleteTenant
// Packages
static getInstalledPackages = packages.getInstalledPackages
static setInstalledPackages = packages.setInstalledPackages
static installPackage = packages.installPackage
static uninstallPackage = packages.uninstallPackage
static togglePackageEnabled = packages.togglePackageEnabled
static getPackageData = packages.getPackageData
static setPackageData = packages.setPackageData
static deletePackageData = packages.deletePackageData
// Power Transfers
static getPowerTransferRequests = powerTransfers.getPowerTransferRequests
static setPowerTransferRequests = powerTransfers.setPowerTransferRequests
static addPowerTransferRequest = powerTransfers.addPowerTransferRequest
static updatePowerTransferRequest = powerTransfers.updatePowerTransferRequest
static deletePowerTransferRequest = powerTransfers.deletePowerTransferRequest
// SMTP Config
static getSMTPConfig = smtpConfig.getSMTPConfig
static setSMTPConfig = smtpConfig.setSMTPConfig
// God Credentials
static getGodCredentialsExpiry = godCredentials.getGodCredentialsExpiry
static setGodCredentialsExpiry = godCredentials.setGodCredentialsExpiry
static getFirstLoginFlags = godCredentials.getFirstLoginFlags
static setFirstLoginFlag = godCredentials.setFirstLoginFlag
static getGodCredentialsExpiryDuration = godCredentials.getGodCredentialsExpiryDuration
static setGodCredentialsExpiryDuration = godCredentials.setGodCredentialsExpiryDuration
static shouldShowGodCredentials = godCredentials.shouldShowGodCredentials
static resetGodCredentialsExpiry = godCredentials.resetGodCredentialsExpiry
// Database Admin
static clearDatabase = databaseAdmin.clearDatabase
static exportDatabase = databaseAdmin.exportDatabase
static importDatabase = databaseAdmin.importDatabase
static seedDefaultData = databaseAdmin.seedDefaultData
// Error Logs
static getErrorLogs = errorLogs.getErrorLogs
static addErrorLog = errorLogs.addErrorLog
static updateErrorLog = errorLogs.updateErrorLog
static deleteErrorLog = errorLogs.deleteErrorLog
static clearErrorLogs = errorLogs.clearErrorLogs
}
@@ -0,0 +1,3 @@
import type { CssCategory } from '../../../../core/types'
export const buildAdvancedCssCategories = (): CssCategory[] => []
@@ -0,0 +1,278 @@
import type { CssCategory } from '../../../../core/types'
import { buildSizingClasses, buildSpacingClasses } from '../build-css-classes'
export const buildBaseCssCategories = (): CssCategory[] => [
{
name: 'Layout',
classes: [
'block',
'inline-block',
'inline',
'flex',
'inline-flex',
'grid',
'inline-grid',
'contents',
'hidden',
'flex-row',
'flex-row-reverse',
'flex-col',
'flex-col-reverse',
'flex-wrap',
'flex-wrap-reverse',
'flex-nowrap',
],
},
{
name: 'Spacing',
classes: buildSpacingClasses(),
},
{
name: 'Sizing',
classes: buildSizingClasses(),
},
{
name: 'Typography',
classes: [
'text-xs',
'text-sm',
'text-base',
'text-lg',
'text-xl',
'text-2xl',
'text-3xl',
'text-4xl',
'text-5xl',
'text-6xl',
'font-thin',
'font-light',
'font-normal',
'font-medium',
'font-semibold',
'font-bold',
'font-extrabold',
'font-black',
'leading-none',
'leading-tight',
'leading-snug',
'leading-normal',
'leading-relaxed',
'leading-loose',
'tracking-tighter',
'tracking-tight',
'tracking-normal',
'tracking-wide',
'tracking-wider',
'tracking-widest',
'text-left',
'text-center',
'text-right',
'text-justify',
'uppercase',
'lowercase',
'capitalize',
'normal-case',
'italic',
'not-italic',
'underline',
'no-underline',
'line-through',
'font-sans',
'font-serif',
'font-mono',
],
},
{
name: 'Colors',
classes: [
'text-foreground',
'text-muted-foreground',
'text-primary',
'text-primary-foreground',
'text-secondary',
'text-secondary-foreground',
'text-accent',
'text-accent-foreground',
'text-destructive',
'text-destructive-foreground',
'bg-background',
'bg-card',
'bg-muted',
'bg-accent',
'bg-primary',
'bg-secondary',
'bg-destructive',
'bg-popover',
'bg-transparent',
'bg-white',
'bg-black',
'text-white',
'text-black',
'border-border',
'border-input',
'border-primary',
'border-secondary',
'border-accent',
'border-destructive',
'ring-ring',
'ring-primary',
'ring-secondary',
'ring-accent',
'ring-destructive',
],
},
{
name: 'Borders',
classes: [
'border',
'border-0',
'border-2',
'border-4',
'border-8',
'border-t',
'border-b',
'border-l',
'border-r',
'border-x',
'border-y',
'border-solid',
'border-dashed',
'border-dotted',
'border-double',
'border-hidden',
'rounded-none',
'rounded-sm',
'rounded',
'rounded-md',
'rounded-lg',
'rounded-xl',
'rounded-2xl',
'rounded-3xl',
'rounded-full',
],
},
{
name: 'Effects',
classes: [
'shadow-none',
'shadow-sm',
'shadow',
'shadow-md',
'shadow-lg',
'shadow-xl',
'shadow-2xl',
'shadow-inner',
'ring-0',
'ring-1',
'ring-2',
'ring-4',
'ring-offset-1',
'ring-offset-2',
'opacity-0',
'opacity-25',
'opacity-50',
'opacity-75',
'opacity-100',
'transition',
'transition-all',
'transition-colors',
'transition-opacity',
'transition-transform',
'duration-75',
'duration-100',
'duration-150',
'duration-200',
'duration-300',
'duration-500',
'ease-in',
'ease-out',
'ease-in-out',
'blur-none',
'blur-sm',
'blur',
'blur-md',
'blur-lg',
'backdrop-blur',
'backdrop-blur-sm',
],
},
{
name: 'Positioning',
classes: [
'static',
'relative',
'absolute',
'fixed',
'sticky',
'inset-0',
'inset-x-0',
'inset-y-0',
'top-0',
'right-0',
'bottom-0',
'left-0',
'z-auto',
'z-0',
'z-10',
'z-20',
'z-30',
'z-40',
'z-50',
'overflow-hidden',
'overflow-auto',
'overflow-scroll',
'overflow-visible',
'overflow-x-auto',
'overflow-y-auto',
],
},
{
name: 'Alignment',
classes: [
'items-start',
'items-center',
'items-end',
'items-stretch',
'items-baseline',
'justify-start',
'justify-center',
'justify-end',
'justify-between',
'justify-around',
'justify-evenly',
'content-start',
'content-center',
'content-end',
'self-start',
'self-center',
'self-end',
'self-stretch',
'place-items-start',
'place-items-center',
'place-items-end',
],
},
{
name: 'Interactivity',
classes: [
'cursor-pointer',
'cursor-default',
'cursor-not-allowed',
'pointer-events-none',
'pointer-events-auto',
'select-none',
'select-text',
'select-all',
'select-auto',
'hover:bg-accent',
'hover:text-accent-foreground',
'hover:underline',
'active:scale-95',
'focus:ring-2',
'focus:ring-primary',
'focus-visible:outline-none',
'disabled:opacity-50',
'disabled:pointer-events-none',
],
},
]
@@ -0,0 +1,3 @@
import type { CssCategory } from '../../../../core/types'
export const buildExperimentalCssCategories = (): CssCategory[] => []
@@ -1,278 +1,10 @@
import type { CssCategory } from '../../../core/types'
import { buildSizingClasses, buildSpacingClasses } from './build-css-classes'
import { buildAdvancedCssCategories } from './categories/advanced'
import { buildBaseCssCategories } from './categories/base'
import { buildExperimentalCssCategories } from './categories/experimental'
export const buildDefaultCssCategories = (): CssCategory[] => [
{
name: 'Layout',
classes: [
'block',
'inline-block',
'inline',
'flex',
'inline-flex',
'grid',
'inline-grid',
'contents',
'hidden',
'flex-row',
'flex-row-reverse',
'flex-col',
'flex-col-reverse',
'flex-wrap',
'flex-wrap-reverse',
'flex-nowrap',
],
},
{
name: 'Spacing',
classes: buildSpacingClasses(),
},
{
name: 'Sizing',
classes: buildSizingClasses(),
},
{
name: 'Typography',
classes: [
'text-xs',
'text-sm',
'text-base',
'text-lg',
'text-xl',
'text-2xl',
'text-3xl',
'text-4xl',
'text-5xl',
'text-6xl',
'font-thin',
'font-light',
'font-normal',
'font-medium',
'font-semibold',
'font-bold',
'font-extrabold',
'font-black',
'leading-none',
'leading-tight',
'leading-snug',
'leading-normal',
'leading-relaxed',
'leading-loose',
'tracking-tighter',
'tracking-tight',
'tracking-normal',
'tracking-wide',
'tracking-wider',
'tracking-widest',
'text-left',
'text-center',
'text-right',
'text-justify',
'uppercase',
'lowercase',
'capitalize',
'normal-case',
'italic',
'not-italic',
'underline',
'no-underline',
'line-through',
'font-sans',
'font-serif',
'font-mono',
],
},
{
name: 'Colors',
classes: [
'text-foreground',
'text-muted-foreground',
'text-primary',
'text-primary-foreground',
'text-secondary',
'text-secondary-foreground',
'text-accent',
'text-accent-foreground',
'text-destructive',
'text-destructive-foreground',
'bg-background',
'bg-card',
'bg-muted',
'bg-accent',
'bg-primary',
'bg-secondary',
'bg-destructive',
'bg-popover',
'bg-transparent',
'bg-white',
'bg-black',
'text-white',
'text-black',
'border-border',
'border-input',
'border-primary',
'border-secondary',
'border-accent',
'border-destructive',
'ring-ring',
'ring-primary',
'ring-secondary',
'ring-accent',
'ring-destructive',
],
},
{
name: 'Borders',
classes: [
'border',
'border-0',
'border-2',
'border-4',
'border-8',
'border-t',
'border-b',
'border-l',
'border-r',
'border-x',
'border-y',
'border-solid',
'border-dashed',
'border-dotted',
'border-double',
'border-hidden',
'rounded-none',
'rounded-sm',
'rounded',
'rounded-md',
'rounded-lg',
'rounded-xl',
'rounded-2xl',
'rounded-3xl',
'rounded-full',
],
},
{
name: 'Effects',
classes: [
'shadow-none',
'shadow-sm',
'shadow',
'shadow-md',
'shadow-lg',
'shadow-xl',
'shadow-2xl',
'shadow-inner',
'ring-0',
'ring-1',
'ring-2',
'ring-4',
'ring-offset-1',
'ring-offset-2',
'opacity-0',
'opacity-25',
'opacity-50',
'opacity-75',
'opacity-100',
'transition',
'transition-all',
'transition-colors',
'transition-opacity',
'transition-transform',
'duration-75',
'duration-100',
'duration-150',
'duration-200',
'duration-300',
'duration-500',
'ease-in',
'ease-out',
'ease-in-out',
'blur-none',
'blur-sm',
'blur',
'blur-md',
'blur-lg',
'backdrop-blur',
'backdrop-blur-sm',
],
},
{
name: 'Positioning',
classes: [
'static',
'relative',
'absolute',
'fixed',
'sticky',
'inset-0',
'inset-x-0',
'inset-y-0',
'top-0',
'right-0',
'bottom-0',
'left-0',
'z-auto',
'z-0',
'z-10',
'z-20',
'z-30',
'z-40',
'z-50',
'overflow-hidden',
'overflow-auto',
'overflow-scroll',
'overflow-visible',
'overflow-x-auto',
'overflow-y-auto',
],
},
{
name: 'Alignment',
classes: [
'items-start',
'items-center',
'items-end',
'items-stretch',
'items-baseline',
'justify-start',
'justify-center',
'justify-end',
'justify-between',
'justify-around',
'justify-evenly',
'content-start',
'content-center',
'content-end',
'self-start',
'self-center',
'self-end',
'self-stretch',
'place-items-start',
'place-items-center',
'place-items-end',
],
},
{
name: 'Interactivity',
classes: [
'cursor-pointer',
'cursor-default',
'cursor-not-allowed',
'pointer-events-none',
'pointer-events-auto',
'select-none',
'select-text',
'select-all',
'select-auto',
'hover:bg-accent',
'hover:text-accent-foreground',
'hover:underline',
'active:scale-95',
'focus:ring-2',
'focus:ring-primary',
'focus-visible:outline-none',
'disabled:opacity-50',
'disabled:pointer-events-none',
],
},
...buildBaseCssCategories(),
...buildAdvancedCssCategories(),
...buildExperimentalCssCategories(),
]
@@ -1,5 +1,52 @@
import { describe, it, expect } from 'vitest'
import { summarizeWorkflowRuns } from './analyze-workflow-runs'
import {
analyzeWorkflowRuns,
parseWorkflowRuns,
summarizeWorkflowRuns,
} from './analyze-workflow-runs'
describe('parseWorkflowRuns', () => {
it('normalizes unknown entries and ignores items without numeric IDs', () => {
const runs = [
{
id: 1,
name: 'Build',
status: 'completed',
conclusion: 'success',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:10:00Z',
head_branch: 'main',
event: 'push',
},
{ id: 'not-a-number' },
{
id: 2,
name: '',
status: '',
conclusion: 'failure',
created_at: '',
updated_at: '',
head_branch: '',
event: '',
},
]
const parsed = parseWorkflowRuns(runs)
expect(parsed).toHaveLength(2)
expect(parsed[0].name).toBe('Build')
expect(parsed[1]).toEqual({
id: 2,
name: 'Unknown workflow',
status: 'unknown',
conclusion: 'failure',
created_at: '',
updated_at: '',
head_branch: 'unknown',
event: 'unknown',
})
})
})
describe('summarizeWorkflowRuns', () => {
it('summarizes totals, success rate, and failure hotspots', () => {
@@ -60,3 +107,24 @@ describe('summarizeWorkflowRuns', () => {
expect(summary.mostRecent).toBeNull()
})
})
describe('analyzeWorkflowRuns', () => {
it('returns parsed summary and formatted output', () => {
const result = analyzeWorkflowRuns([
{
id: 7,
name: 'Deploy',
status: 'completed',
conclusion: 'success',
created_at: '2024-02-01T00:00:00Z',
updated_at: '2024-02-01T00:05:00Z',
head_branch: 'main',
event: 'workflow_dispatch',
},
])
expect(result.summary.total).toBe(1)
expect(result.formatted).toContain('Workflow Run Analysis')
expect(result.formatted).toContain('Deploy')
})
})
@@ -1,164 +1,18 @@
export type WorkflowRunLike = {
id: number
name: string
status: string
conclusion: string | null
created_at: string
updated_at: string
head_branch: string
event: string
}
import { parseWorkflowRuns, WorkflowRunLike } from './parser'
import { formatWorkflowRunAnalysis, summarizeWorkflowRuns, WorkflowRunSummary } from './stats'
export type WorkflowRunSummary = {
total: number
completed: number
successful: number
failed: number
cancelled: number
inProgress: number
successRate: number
mostRecent: WorkflowRunLike | null
recentRuns: WorkflowRunLike[]
topFailingWorkflows: Array<{ name: string; failures: number }>
failingBranches: Array<{ branch: string; failures: number }>
failingEvents: Array<{ event: string; failures: number }>
}
export type { WorkflowRunLike, WorkflowRunSummary }
export { parseWorkflowRuns, summarizeWorkflowRuns, formatWorkflowRunAnalysis }
const DEFAULT_RECENT_COUNT = 5
const DEFAULT_TOP_COUNT = 3
function toTopCounts(
values: string[],
topCount: number
): Array<{ key: string; count: number }> {
const counts = new Map<string, number>()
values.forEach((value) => {
counts.set(value, (counts.get(value) || 0) + 1)
})
return Array.from(counts.entries())
.map(([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
.slice(0, topCount)
}
export function summarizeWorkflowRuns(
runs: WorkflowRunLike[],
export function analyzeWorkflowRuns(
runs: unknown[],
options?: { recentCount?: number; topCount?: number }
): WorkflowRunSummary {
const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT
const topCount = options?.topCount ?? DEFAULT_TOP_COUNT
const total = runs.length
const completedRuns = runs.filter((run) => run.status === 'completed')
const successful = completedRuns.filter((run) => run.conclusion === 'success').length
const failed = completedRuns.filter((run) => run.conclusion === 'failure').length
const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length
const inProgress = total - completedRuns.length
const successRate = completedRuns.length
? Math.round((successful / completedRuns.length) * 100)
: 0
const sortedByUpdated = [...runs].sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
)
const mostRecent = sortedByUpdated[0] ?? null
const recentRuns = sortedByUpdated.slice(0, recentCount)
const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure')
const topFailingWorkflows = toTopCounts(
failureRuns.map((run) => run.name),
topCount
).map((entry) => ({ name: entry.key, failures: entry.count }))
const failingBranches = toTopCounts(
failureRuns.map((run) => run.head_branch),
topCount
).map((entry) => ({ branch: entry.key, failures: entry.count }))
const failingEvents = toTopCounts(
failureRuns.map((run) => run.event),
topCount
).map((entry) => ({ event: entry.key, failures: entry.count }))
) {
const parsedRuns = parseWorkflowRuns(runs)
const summary = summarizeWorkflowRuns(parsedRuns, options)
return {
total,
completed: completedRuns.length,
successful,
failed,
cancelled,
inProgress,
successRate,
mostRecent,
recentRuns,
topFailingWorkflows,
failingBranches,
failingEvents,
summary,
formatted: formatWorkflowRunAnalysis(summary),
}
}
export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) {
const lines: string[] = []
lines.push('Workflow Run Analysis')
lines.push('---------------------')
lines.push(`Total runs: ${summary.total}`)
lines.push(
`Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})`
)
lines.push(`In progress: ${summary.inProgress}`)
lines.push(`Success rate: ${summary.successRate}%`)
if (summary.mostRecent) {
lines.push('')
lines.push('Most recent run:')
lines.push(
`- ${summary.mostRecent.name} | ${summary.mostRecent.status}${
summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : ''
} | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}`
)
}
if (summary.recentRuns.length > 0) {
lines.push('')
lines.push('Recent runs:')
summary.recentRuns.forEach((run) => {
lines.push(
`- ${run.name} | ${run.status}${
run.conclusion ? `/${run.conclusion}` : ''
} | ${run.head_branch} | ${run.updated_at}`
)
})
}
if (summary.topFailingWorkflows.length > 0) {
lines.push('')
lines.push('Top failing workflows:')
summary.topFailingWorkflows.forEach((entry) => {
lines.push(`- ${entry.name}: ${entry.failures}`)
})
}
if (summary.failingBranches.length > 0) {
lines.push('')
lines.push('Failing branches:')
summary.failingBranches.forEach((entry) => {
lines.push(`- ${entry.branch}: ${entry.failures}`)
})
}
if (summary.failingEvents.length > 0) {
lines.push('')
lines.push('Failing events:')
summary.failingEvents.forEach((entry) => {
lines.push(`- ${entry.event}: ${entry.failures}`)
})
}
if (summary.total === 0) {
lines.push('')
lines.push('No workflow runs available to analyze.')
}
return lines.join('\n')
}
@@ -0,0 +1,50 @@
export type WorkflowRunLike = {
id: number
name: string
status: string
conclusion: string | null
created_at: string
updated_at: string
head_branch: string
event: string
}
const FALLBACK_NAME = 'Unknown workflow'
const FALLBACK_STATUS = 'unknown'
const FALLBACK_BRANCH = 'unknown'
const FALLBACK_EVENT = 'unknown'
function toStringOrFallback(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value : fallback
}
export function parseWorkflowRuns(runs: unknown[]): WorkflowRunLike[] {
if (!Array.isArray(runs)) {
return []
}
return runs
.map((run) => {
const candidate = run as Partial<WorkflowRunLike> & { id?: unknown }
const id = Number(candidate.id)
if (!Number.isFinite(id)) {
return null
}
return {
id,
name: toStringOrFallback(candidate.name, FALLBACK_NAME),
status: toStringOrFallback(candidate.status, FALLBACK_STATUS),
conclusion:
candidate.conclusion === null || typeof candidate.conclusion === 'string'
? candidate.conclusion
: null,
created_at: toStringOrFallback(candidate.created_at, ''),
updated_at: toStringOrFallback(candidate.updated_at, ''),
head_branch: toStringOrFallback(candidate.head_branch, FALLBACK_BRANCH),
event: toStringOrFallback(candidate.event, FALLBACK_EVENT),
}
})
.filter((run): run is WorkflowRunLike => Boolean(run))
}
@@ -0,0 +1,153 @@
import { WorkflowRunLike } from './parser'
export type WorkflowRunSummary = {
total: number
completed: number
successful: number
failed: number
cancelled: number
inProgress: number
successRate: number
mostRecent: WorkflowRunLike | null
recentRuns: WorkflowRunLike[]
topFailingWorkflows: Array<{ name: string; failures: number }>
failingBranches: Array<{ branch: string; failures: number }>
failingEvents: Array<{ event: string; failures: number }>
}
const DEFAULT_RECENT_COUNT = 5
const DEFAULT_TOP_COUNT = 3
function toTopCounts(
values: string[],
topCount: number
): Array<{ key: string; count: number }> {
const counts = new Map<string, number>()
values.forEach((value) => {
counts.set(value, (counts.get(value) || 0) + 1)
})
return Array.from(counts.entries())
.map(([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
.slice(0, topCount)
}
export function summarizeWorkflowRuns(
runs: WorkflowRunLike[],
options?: { recentCount?: number; topCount?: number }
): WorkflowRunSummary {
const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT
const topCount = options?.topCount ?? DEFAULT_TOP_COUNT
const total = runs.length
const completedRuns = runs.filter((run) => run.status === 'completed')
const successful = completedRuns.filter((run) => run.conclusion === 'success').length
const failed = completedRuns.filter((run) => run.conclusion === 'failure').length
const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length
const inProgress = total - completedRuns.length
const successRate = completedRuns.length
? Math.round((successful / completedRuns.length) * 100)
: 0
const sortedByUpdated = [...runs].sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
)
const mostRecent = sortedByUpdated[0] ?? null
const recentRuns = sortedByUpdated.slice(0, recentCount)
const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure')
const topFailingWorkflows = toTopCounts(
failureRuns.map((run) => run.name),
topCount
).map((entry) => ({ name: entry.key, failures: entry.count }))
const failingBranches = toTopCounts(
failureRuns.map((run) => run.head_branch),
topCount
).map((entry) => ({ branch: entry.key, failures: entry.count }))
const failingEvents = toTopCounts(
failureRuns.map((run) => run.event),
topCount
).map((entry) => ({ event: entry.key, failures: entry.count }))
return {
total,
completed: completedRuns.length,
successful,
failed,
cancelled,
inProgress,
successRate,
mostRecent,
recentRuns,
topFailingWorkflows,
failingBranches,
failingEvents,
}
}
export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) {
const lines: string[] = []
lines.push('Workflow Run Analysis')
lines.push('---------------------')
lines.push(`Total runs: ${summary.total}`)
lines.push(
`Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})`
)
lines.push(`In progress: ${summary.inProgress}`)
lines.push(`Success rate: ${summary.successRate}%`)
if (summary.mostRecent) {
lines.push('')
lines.push('Most recent run:')
lines.push(
`- ${summary.mostRecent.name} | ${summary.mostRecent.status}${
summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : ''
} | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}`
)
}
if (summary.recentRuns.length > 0) {
lines.push('')
lines.push('Recent runs:')
summary.recentRuns.forEach((run) => {
lines.push(
`- ${run.name} | ${run.status}${run.conclusion ? `/${run.conclusion}` : ''} | ${run.head_branch} | ${run.updated_at}`
)
})
}
if (summary.topFailingWorkflows.length > 0) {
lines.push('')
lines.push('Top failing workflows:')
summary.topFailingWorkflows.forEach((entry) => {
lines.push(`- ${entry.name}: ${entry.failures}`)
})
}
if (summary.failingBranches.length > 0) {
lines.push('')
lines.push('Failing branches:')
summary.failingBranches.forEach((entry) => {
lines.push(`- ${entry.branch}: ${entry.failures}`)
})
}
if (summary.failingEvents.length > 0) {
lines.push('')
lines.push('Failing events:')
summary.failingEvents.forEach((entry) => {
lines.push(`- ${entry.event}: ${entry.failures}`)
})
}
if (summary.total === 0) {
lines.push('')
lines.push('No workflow runs available to analyze.')
}
return lines.join('\n')
}
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { LuaEngine, createLuaEngine } from '../lua-engine'
describe('lua-engine events', () => {
let engine: LuaEngine
beforeEach(() => {
engine = createLuaEngine()
})
afterEach(() => {
engine.destroy()
})
describe('logging', () => {
it('should capture log() calls', async () => {
const result = await engine.execute(`
log("message 1")
log("message 2")
return "done"
`)
expect(result.success).toBe(true)
expect(result.logs).toContain('message 1')
expect(result.logs).toContain('message 2')
})
it('should capture print() calls', async () => {
const result = await engine.execute(`
print("printed output")
return true
`)
expect(result.success).toBe(true)
expect(result.logs).toContain('printed output')
})
it.each([
{ name: 'number', code: 'log(42)', expected: '42' },
{ name: 'boolean', code: 'log(true)', expected: 'true' },
{ name: 'multiple args', code: 'log("a", "b", "c")', expected: 'a b c' },
])('should log $name correctly', async ({ code, expected }) => {
const result = await engine.execute(code)
expect(result.logs).toContain(expected)
})
})
describe('error handling', () => {
it.each([
{
name: 'syntax error',
code: 'this is not valid lua !!!',
errorContains: 'Syntax error',
},
{
name: 'undefined variable',
code: 'return undefinedVar.property',
errorContains: 'Runtime error',
},
{
name: 'type error',
code: 'return "string" + 5',
errorContains: 'error',
},
{
name: 'explicit error',
code: 'error("intentional error")',
errorContains: 'intentional error',
},
])('should handle $name', async ({ code, errorContains }) => {
const result = await engine.execute(code)
expect(result.success).toBe(false)
expect(result.error?.toLowerCase()).toContain(errorContains.toLowerCase())
})
})
})
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { LuaEngine, createLuaEngine, type LuaExecutionContext } from './lua-engine'
import { LuaEngine, createLuaEngine, type LuaExecutionContext } from '../lua-engine'
describe('lua-engine', () => {
describe('lua-engine execution', () => {
let engine: LuaEngine
beforeEach(() => {
@@ -81,31 +81,31 @@ describe('lua-engine', () => {
{
name: 'access context.data number',
code: 'return context.data.value * 2',
context: { data: { value: 21 } },
context: { data: { value: 21 } } satisfies LuaExecutionContext,
expected: 42,
},
{
name: 'access context.data string',
code: 'return context.data.name',
context: { data: { name: 'test' } },
context: { data: { name: 'test' } } satisfies LuaExecutionContext,
expected: 'test',
},
{
name: 'access context.data boolean',
code: 'return context.data.active',
context: { data: { active: true } },
context: { data: { active: true } } satisfies LuaExecutionContext,
expected: true,
},
{
name: 'access nested context.data',
code: 'return context.data.user.name',
context: { data: { user: { name: 'Alice' } } },
context: { data: { user: { name: 'Alice' } } } satisfies LuaExecutionContext,
expected: 'Alice',
},
{
name: 'access context.data array',
code: 'return context.data.items[2]',
context: { data: { items: [10, 20, 30] } },
context: { data: { items: [10, 20, 30] } } satisfies LuaExecutionContext,
expected: 20,
},
])('should $name', async ({ code, context, expected }) => {
@@ -241,66 +241,6 @@ describe('lua-engine', () => {
})
})
describe('logging', () => {
it('should capture log() calls', async () => {
const result = await engine.execute(`
log("message 1")
log("message 2")
return "done"
`)
expect(result.success).toBe(true)
expect(result.logs).toContain('message 1')
expect(result.logs).toContain('message 2')
})
it('should capture print() calls', async () => {
const result = await engine.execute(`
print("printed output")
return true
`)
expect(result.success).toBe(true)
expect(result.logs).toContain('printed output')
})
it.each([
{ name: 'number', code: 'log(42)', expected: '42' },
{ name: 'boolean', code: 'log(true)', expected: 'true' },
{ name: 'multiple args', code: 'log("a", "b", "c")', expected: 'a b c' },
])('should log $name correctly', async ({ code, expected }) => {
const result = await engine.execute(code)
expect(result.logs).toContain(expected)
})
})
describe('error handling', () => {
it.each([
{
name: 'syntax error',
code: 'this is not valid lua !!!',
errorContains: 'Syntax error',
},
{
name: 'undefined variable',
code: 'return undefinedVar.property',
errorContains: 'Runtime error',
},
{
name: 'type error',
code: 'return "string" + 5',
errorContains: 'error',
},
{
name: 'explicit error',
code: 'error("intentional error")',
errorContains: 'intentional error',
},
])('should handle $name', async ({ code, errorContains }) => {
const result = await engine.execute(code)
expect(result.success).toBe(false)
expect(result.error?.toLowerCase()).toContain(errorContains.toLowerCase())
})
})
describe('multiple return values', () => {
it('should handle multiple return values', async () => {
const result = await engine.execute('return 1, 2, 3')
@@ -0,0 +1,3 @@
import type { PackageTemplateConfig } from '../../types'
export const ADVANCED_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []
@@ -0,0 +1,267 @@
import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../../types'
export const BASE_REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = {
id: 'react_next_starter',
name: 'Next.js Web App',
description: 'A clean Next.js starter with app router, hero component, and typed config files.',
rootName: 'web_app',
tags: ['nextjs', 'react', 'web', 'starter'],
}
const socialHubComponents = [
{
id: 'social_hub_root',
type: 'Stack',
props: { className: 'flex flex-col gap-6' },
children: [
{
id: 'social_hub_hero',
type: 'Card',
props: { className: 'p-6' },
children: [
{
id: 'social_hub_heading',
type: 'Heading',
props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' },
children: [],
},
{
id: 'social_hub_subtitle',
type: 'Text',
props: { children: 'A modern feed for creator updates, curated stories, and live moments.' },
children: [],
},
],
},
{
id: 'social_hub_stats',
type: 'Grid',
props: { className: 'grid grid-cols-3 gap-4' },
children: [
{
id: 'social_hub_stat_1',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_1',
type: 'Text',
props: { children: 'Creators live', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_1',
type: 'Heading',
props: { children: '128', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
{
id: 'social_hub_stat_2',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_2',
type: 'Text',
props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_2',
type: 'Heading',
props: { children: '42', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
{
id: 'social_hub_stat_3',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_3',
type: 'Text',
props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_3',
type: 'Heading',
props: { children: '7', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
],
},
{
id: 'social_hub_composer',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_composer_label',
type: 'Label',
props: { children: 'Share a quick update' },
children: [],
},
{
id: 'social_hub_composer_input',
type: 'Textarea',
props: { placeholder: 'What are you building today?', rows: 3 },
children: [],
},
{
id: 'social_hub_composer_actions',
type: 'Flex',
props: { className: 'flex gap-2' },
children: [
{
id: 'social_hub_composer_publish',
type: 'Button',
props: { children: 'Publish', variant: 'default' },
children: [],
},
{
id: 'social_hub_composer_media',
type: 'Button',
props: { children: 'Add media', variant: 'outline' },
children: [],
},
],
},
],
},
{
id: 'social_hub_feed',
type: 'Stack',
props: { className: 'flex flex-col gap-4' },
children: [
{
id: 'social_hub_feed_post_1',
type: 'Card',
props: { className: 'p-5' },
children: [
{
id: 'social_hub_feed_post_1_title',
type: 'Heading',
props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' },
children: [],
},
{
id: 'social_hub_feed_post_1_body',
type: 'Text',
props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' },
children: [],
},
{
id: 'social_hub_feed_post_1_badge',
type: 'Badge',
props: { children: 'Community' },
children: [],
},
],
},
{
id: 'social_hub_feed_post_2',
type: 'Card',
props: { className: 'p-5' },
children: [
{
id: 'social_hub_feed_post_2_title',
type: 'Heading',
props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' },
children: [],
},
{
id: 'social_hub_feed_post_2_body',
type: 'Text',
props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' },
children: [],
},
{
id: 'social_hub_feed_post_2_badge',
type: 'Badge',
props: { children: 'Spotlight', variant: 'secondary' },
children: [],
},
],
},
],
},
],
},
]
const socialHubExamples = {
feedItems: [
{
id: 'post_001',
author: 'Nova',
title: 'Launch day recap',
summary: 'We shipped live rooms and doubled community sessions.',
tags: ['launch', 'community'],
},
{
id: 'post_002',
author: 'Kai',
title: 'Build log: day 42',
summary: 'Refined the moderation pipeline and added creator scorecards.',
tags: ['buildinpublic'],
},
],
trendingTags: ['#buildinpublic', '#metabuilder', '#live'],
rooms: [
{ id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true },
{ id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false },
],
}
const socialHubLuaScripts = [
{
fileName: 'init.lua',
description: 'Lifecycle hooks for package installation.',
code: 'local M = {}\\n\\nfunction M.on_install(context)\\n return { message = "Social Hub installed", version = context.version }\\nend\\n\\nfunction M.on_uninstall()\\n return { message = "Social Hub removed" }\\nend\\n\\nreturn M',
},
{
fileName: 'permissions.lua',
description: 'Role-based access rules for posting and moderation.',
code: 'local Permissions = {}\\n\\nfunction Permissions.can_post(user)\\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\\nend\\n\\nfunction Permissions.can_moderate(user)\\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\\nend\\n\\nreturn Permissions',
},
{
fileName: 'feed_rank.lua',
description: 'Score feed items based on recency and engagement.',
code: 'local FeedRank = {}\\n\\nfunction FeedRank.score(item)\\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\\n return freshness + engagement\\nend\\n\\nreturn FeedRank',
},
{
fileName: 'moderation.lua',
description: 'Flag content for review using lightweight heuristics.',
code: 'local Moderation = {}\\n\\nfunction Moderation.flag(content)\\n local lowered = string.lower(content or "")\\n if string.find(lowered, "spam") then\\n return { flagged = true, reason = "spam_keyword" }\\n end\\n return { flagged = false }\\nend\\n\\nreturn Moderation',
},
{
fileName: 'analytics.lua',
description: 'Aggregate engagement signals for dashboards.',
code: 'local Analytics = {}\\n\\nfunction Analytics.aggregate(events)\\n local summary = { views = 0, likes = 0, comments = 0 }\\n for _, event in ipairs(events or {}) do\\n summary.views = summary.views + (event.views or 0)\\n summary.likes = summary.likes + (event.likes or 0)\\n summary.comments = summary.comments + (event.comments or 0)\\n end\\n return summary\\nend\\n\\nreturn Analytics',
},
]
export const BASE_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [
{
id: 'package_social_hub',
name: 'Social Hub Package',
description: 'A package blueprint for social feeds, creator updates, and live rooms.',
rootName: 'social_hub',
packageId: 'social_hub',
author: 'MetaBuilder',
version: '1.0.0',
category: 'social',
summary: 'Modern social feed with creator tools and live rooms.',
components: socialHubComponents,
examples: socialHubExamples,
luaScripts: socialHubLuaScripts,
tags: ['package', 'social', 'feed', 'lua'],
},
]
@@ -0,0 +1,3 @@
import type { PackageTemplateConfig } from '../../types'
export const EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []
@@ -1,267 +1,12 @@
import type { PackageTemplateConfig, ReactAppTemplateConfig } from './types'
import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../types'
import { ADVANCED_PACKAGE_TEMPLATE_CONFIGS } from './configs/advanced'
import { BASE_PACKAGE_TEMPLATE_CONFIGS, BASE_REACT_APP_TEMPLATE_CONFIG } from './configs/base'
import { EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS } from './configs/experimental'
export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = {
id: 'react_next_starter',
name: 'Next.js Web App',
description: 'A clean Next.js starter with app router, hero component, and typed config files.',
rootName: 'web_app',
tags: ['nextjs', 'react', 'web', 'starter'],
}
const socialHubComponents = [
{
id: 'social_hub_root',
type: 'Stack',
props: { className: 'flex flex-col gap-6' },
children: [
{
id: 'social_hub_hero',
type: 'Card',
props: { className: 'p-6' },
children: [
{
id: 'social_hub_heading',
type: 'Heading',
props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' },
children: [],
},
{
id: 'social_hub_subtitle',
type: 'Text',
props: { children: 'A modern feed for creator updates, curated stories, and live moments.' },
children: [],
},
],
},
{
id: 'social_hub_stats',
type: 'Grid',
props: { className: 'grid grid-cols-3 gap-4' },
children: [
{
id: 'social_hub_stat_1',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_1',
type: 'Text',
props: { children: 'Creators live', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_1',
type: 'Heading',
props: { children: '128', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
{
id: 'social_hub_stat_2',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_2',
type: 'Text',
props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_2',
type: 'Heading',
props: { children: '42', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
{
id: 'social_hub_stat_3',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_3',
type: 'Text',
props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_3',
type: 'Heading',
props: { children: '7', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
],
},
{
id: 'social_hub_composer',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_composer_label',
type: 'Label',
props: { children: 'Share a quick update' },
children: [],
},
{
id: 'social_hub_composer_input',
type: 'Textarea',
props: { placeholder: 'What are you building today?', rows: 3 },
children: [],
},
{
id: 'social_hub_composer_actions',
type: 'Flex',
props: { className: 'flex gap-2' },
children: [
{
id: 'social_hub_composer_publish',
type: 'Button',
props: { children: 'Publish', variant: 'default' },
children: [],
},
{
id: 'social_hub_composer_media',
type: 'Button',
props: { children: 'Add media', variant: 'outline' },
children: [],
},
],
},
],
},
{
id: 'social_hub_feed',
type: 'Stack',
props: { className: 'flex flex-col gap-4' },
children: [
{
id: 'social_hub_feed_post_1',
type: 'Card',
props: { className: 'p-5' },
children: [
{
id: 'social_hub_feed_post_1_title',
type: 'Heading',
props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' },
children: [],
},
{
id: 'social_hub_feed_post_1_body',
type: 'Text',
props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' },
children: [],
},
{
id: 'social_hub_feed_post_1_badge',
type: 'Badge',
props: { children: 'Community' },
children: [],
},
],
},
{
id: 'social_hub_feed_post_2',
type: 'Card',
props: { className: 'p-5' },
children: [
{
id: 'social_hub_feed_post_2_title',
type: 'Heading',
props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' },
children: [],
},
{
id: 'social_hub_feed_post_2_body',
type: 'Text',
props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' },
children: [],
},
{
id: 'social_hub_feed_post_2_badge',
type: 'Badge',
props: { children: 'Spotlight', variant: 'secondary' },
children: [],
},
],
},
],
},
],
},
]
const socialHubExamples = {
feedItems: [
{
id: 'post_001',
author: 'Nova',
title: 'Launch day recap',
summary: 'We shipped live rooms and doubled community sessions.',
tags: ['launch', 'community'],
},
{
id: 'post_002',
author: 'Kai',
title: 'Build log: day 42',
summary: 'Refined the moderation pipeline and added creator scorecards.',
tags: ['buildinpublic'],
},
],
trendingTags: ['#buildinpublic', '#metabuilder', '#live'],
rooms: [
{ id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true },
{ id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false },
],
}
const socialHubLuaScripts = [
{
fileName: 'init.lua',
description: 'Lifecycle hooks for package installation.',
code: 'local M = {}\n\nfunction M.on_install(context)\n return { message = "Social Hub installed", version = context.version }\nend\n\nfunction M.on_uninstall()\n return { message = "Social Hub removed" }\nend\n\nreturn M',
},
{
fileName: 'permissions.lua',
description: 'Role-based access rules for posting and moderation.',
code: 'local Permissions = {}\n\nfunction Permissions.can_post(user)\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\nend\n\nfunction Permissions.can_moderate(user)\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\nend\n\nreturn Permissions',
},
{
fileName: 'feed_rank.lua',
description: 'Score feed items based on recency and engagement.',
code: 'local FeedRank = {}\n\nfunction FeedRank.score(item)\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\n return freshness + engagement\nend\n\nreturn FeedRank',
},
{
fileName: 'moderation.lua',
description: 'Flag content for review using lightweight heuristics.',
code: 'local Moderation = {}\n\nfunction Moderation.flag(content)\n local lowered = string.lower(content or "")\n if string.find(lowered, "spam") then\n return { flagged = true, reason = "spam_keyword" }\n end\n return { flagged = false }\nend\n\nreturn Moderation',
},
{
fileName: 'analytics.lua',
description: 'Aggregate engagement signals for dashboards.',
code: 'local Analytics = {}\n\nfunction Analytics.aggregate(events)\n local summary = { views = 0, likes = 0, comments = 0 }\n for _, event in ipairs(events or {}) do\n summary.views = summary.views + (event.views or 0)\n summary.likes = summary.likes + (event.likes or 0)\n summary.comments = summary.comments + (event.comments or 0)\n end\n return summary\nend\n\nreturn Analytics',
},
]
export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = BASE_REACT_APP_TEMPLATE_CONFIG
export const PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [
{
id: 'package_social_hub',
name: 'Social Hub Package',
description: 'A package blueprint for social feeds, creator updates, and live rooms.',
rootName: 'social_hub',
packageId: 'social_hub',
author: 'MetaBuilder',
version: '1.0.0',
category: 'social',
summary: 'Modern social feed with creator tools and live rooms.',
components: socialHubComponents,
examples: socialHubExamples,
luaScripts: socialHubLuaScripts,
tags: ['package', 'social', 'feed', 'lua'],
},
...BASE_PACKAGE_TEMPLATE_CONFIGS,
...ADVANCED_PACKAGE_TEMPLATE_CONFIGS,
...EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS,
]
@@ -1,238 +1,44 @@
import type { PackageContent } from '../../package-types'
import { ircWebchatComponentConfig } from './irc-webchat/schema/layout'
type IrcWebchatUiSchema = Pick<PackageContent, 'pages' | 'componentHierarchy' | 'componentConfigs'>
export const createIrcWebchatUiSchema = (): IrcWebchatUiSchema => ({
pages: [
{
id: 'page_chat',
path: '/chat',
title: 'IRC Webchat',
level: 2,
componentTree: [
{
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {
channelName: 'general',
},
children: [],
const pages: IrcWebchatUiSchema['pages'] = [
{
id: 'page_chat',
path: '/chat',
title: 'IRC Webchat',
level: 2,
componentTree: [
{
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {
channelName: 'general',
},
],
requiresAuth: true,
requiredRole: 'user',
},
],
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.',
},
},
],
},
],
},
],
children: [],
},
},
],
requiresAuth: true,
requiredRole: 'user',
},
]
const componentHierarchy: IrcWebchatUiSchema['componentHierarchy'] = {
page_chat: {
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {},
children: [],
},
}
const componentConfigs: IrcWebchatUiSchema['componentConfigs'] = {
IRCWebchat: ircWebchatComponentConfig,
}
export const createIrcWebchatUiSchema = (): IrcWebchatUiSchema => ({
pages,
componentHierarchy,
componentConfigs,
})
@@ -1,197 +1,13 @@
import type { PackageContent } from '../../package-types'
import { commandActions } from './irc-webchat/actions/commands'
import { eventActions } from './irc-webchat/actions/events'
type IrcWebchatWorkflows = Pick<PackageContent, 'workflows' | 'luaScripts'>
export const createIrcWebchatWorkflowActions = (): IrcWebchatWorkflows => ({
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',
},
],
const mergeActions = (...actions: IrcWebchatWorkflows[]): IrcWebchatWorkflows => ({
workflows: actions.flatMap((action) => action.workflows),
luaScripts: actions.flatMap((action) => action.luaScripts),
})
export const createIrcWebchatWorkflowActions = (): IrcWebchatWorkflows =>
mergeActions(commandActions, eventActions)
@@ -0,0 +1,103 @@
import type { PackageContent } from '../../../../package-types'
type IrcWebchatWorkflowActions = Pick<PackageContent, 'workflows' | 'luaScripts'>
export const commandActions: IrcWebchatWorkflowActions = {
workflows: [
{
id: 'workflow_send_message',
name: 'Send Chat Message',
description: 'Workflow for sending a chat message',
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',
},
],
}
@@ -0,0 +1,104 @@
import type { PackageContent } from '../../../../package-types'
type IrcWebchatWorkflowActions = Pick<PackageContent, 'workflows' | 'luaScripts'>
export const eventActions: IrcWebchatWorkflowActions = {
workflows: [
{
id: 'workflow_join_channel',
name: 'Join Channel',
description: 'Workflow for joining a chat channel',
nodes: [],
edges: [],
enabled: true,
},
],
luaScripts: [
{
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',
},
],
}
@@ -0,0 +1,143 @@
import { createMessageArea, createMessageInputArea } from './messages'
const createHeaderSection = () => ({
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',
},
},
],
},
],
},
],
})
const createSidebar = () => ({
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',
},
},
],
})
export const ircWebchatComponentConfig = {
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: [
createHeaderSection(),
{
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: [createMessageArea(), createSidebar()],
},
createMessageInputArea(),
],
},
],
},
}
@@ -0,0 +1,65 @@
export const createMessageArea = () => ({
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',
},
},
],
})
export const createMessageInputArea = () => ({
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.',
},
},
],
})
@@ -1,30 +1,13 @@
/**
* Tests for package-glue module - Package registry utilities
* Following parameterized test pattern per project conventions
* Shared helpers for package-glue test suites.
* Individual suites live under ./package-glue/.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { PackageRegistry, PackageDefinition, LuaScriptFile } from './package-glue'
import {
getPackage,
getPackagesByCategory,
getPackageComponents,
getPackageScripts,
getPackageScriptFiles,
getAllPackageScripts,
getPackageExamples,
checkDependencies,
installPackageComponents,
installPackageScripts,
installPackage,
uninstallPackage,
getInstalledPackages,
isPackageInstalled,
exportAllPackagesForSeed,
} from './package-glue'
import { vi } from 'vitest'
// Helper to create mock package definitions
function createMockPackage(
import type { LuaScriptFile, PackageDefinition, PackageRegistry } from '../package-glue'
export function createMockPackage(
id: string,
options: Partial<PackageDefinition> = {}
): PackageDefinition {
@@ -44,8 +27,7 @@ function createMockPackage(
}
}
// Helper to create mock registry
function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
export function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
const registry: PackageRegistry = {}
for (const pkg of packages) {
registry[pkg.packageId] = pkg
@@ -53,8 +35,9 @@ function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
return registry
}
// Helper to create mock database
function createMockDb() {
export type MockDb = ReturnType<typeof createMockDb>
export function createMockDb() {
const data: Record<string, Record<string, any>> = {}
return {
set: vi.fn(async (table: string, key: string, value: any) => {
@@ -74,546 +57,7 @@ function createMockDb() {
}
}
describe('package-glue', () => {
describe('getPackage', () => {
it.each([
{
name: 'returns package when found',
registry: createMockRegistry([createMockPackage('test_pkg')]),
packageId: 'test_pkg',
expectFound: true,
},
{
name: 'returns undefined when not found',
registry: createMockRegistry([createMockPackage('other_pkg')]),
packageId: 'test_pkg',
expectFound: false,
},
{
name: 'returns undefined from empty registry',
registry: createMockRegistry([]),
packageId: 'test_pkg',
expectFound: false,
},
])('should handle $name', ({ registry, packageId, expectFound }) => {
const result = getPackage(registry, packageId)
if (expectFound) {
expect(result).toBeDefined()
expect(result?.packageId).toBe(packageId)
} else {
expect(result).toBeUndefined()
}
})
})
describe('getPackagesByCategory', () => {
const mixedRegistry = createMockRegistry([
createMockPackage('pkg1', { category: 'ui' }),
createMockPackage('pkg2', { category: 'ui' }),
createMockPackage('pkg3', { category: 'data' }),
createMockPackage('pkg4', { category: 'util' }),
])
it.each([
{
name: 'returns packages in category',
registry: mixedRegistry,
category: 'ui',
expectedCount: 2,
},
{
name: 'returns single package in category',
registry: mixedRegistry,
category: 'data',
expectedCount: 1,
},
{
name: 'returns empty array for unknown category',
registry: mixedRegistry,
category: 'unknown',
expectedCount: 0,
},
{
name: 'returns empty array from empty registry',
registry: createMockRegistry([]),
category: 'ui',
expectedCount: 0,
},
])('should handle $name', ({ registry, category, expectedCount }) => {
const result = getPackagesByCategory(registry, category)
expect(result).toHaveLength(expectedCount)
result.forEach((pkg) => {
expect(pkg.category).toBe(category)
})
})
})
describe('getPackageComponents', () => {
it.each([
{
name: 'returns components array',
pkg: createMockPackage('test', {
components: [{ id: 'c1' }, { id: 'c2' }],
}),
expectedLength: 2,
},
{
name: 'returns empty array when no components',
pkg: createMockPackage('test', { components: [] }),
expectedLength: 0,
},
{
name: 'returns empty array when components is undefined',
pkg: { ...createMockPackage('test'), components: undefined as any },
expectedLength: 0,
},
])('should handle $name', ({ pkg, expectedLength }) => {
const result = getPackageComponents(pkg)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(expectedLength)
})
})
describe('getPackageScripts', () => {
it.each([
{
name: 'returns scripts string',
pkg: createMockPackage('test', { scripts: 'return 42' }),
expected: 'return 42',
},
{
name: 'returns empty string when undefined',
pkg: createMockPackage('test'),
expected: '',
},
{
name: 'returns empty string when null',
pkg: { ...createMockPackage('test'), scripts: null as any },
expected: '',
},
])('should handle $name', ({ pkg, expected }) => {
const result = getPackageScripts(pkg)
expect(result).toBe(expected)
})
})
describe('getPackageScriptFiles', () => {
const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
{ name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
]
it.each([
{
name: 'returns script files array',
pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
expectedLength: 2,
},
{
name: 'returns empty array when undefined',
pkg: createMockPackage('test'),
expectedLength: 0,
},
{
name: 'returns empty array when empty',
pkg: createMockPackage('test', { scriptFiles: [] }),
expectedLength: 0,
},
])('should handle $name', ({ pkg, expectedLength }) => {
const result = getPackageScriptFiles(pkg)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(expectedLength)
})
})
describe('getAllPackageScripts', () => {
const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
]
it.each([
{
name: 'returns both legacy and files',
pkg: createMockPackage('test', {
scripts: 'legacy code',
scriptFiles: mockScriptFiles,
}),
expectedLegacy: 'legacy code',
expectedFilesLength: 1,
},
{
name: 'handles missing legacy',
pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
expectedLegacy: '',
expectedFilesLength: 1,
},
{
name: 'handles missing files',
pkg: createMockPackage('test', { scripts: 'code' }),
expectedLegacy: 'code',
expectedFilesLength: 0,
},
{
name: 'handles both missing',
pkg: createMockPackage('test'),
expectedLegacy: '',
expectedFilesLength: 0,
},
])('should handle $name', ({ pkg, expectedLegacy, expectedFilesLength }) => {
const result = getAllPackageScripts(pkg)
expect(result.legacy).toBe(expectedLegacy)
expect(result.files).toHaveLength(expectedFilesLength)
})
})
describe('getPackageExamples', () => {
it.each([
{
name: 'returns examples object',
pkg: createMockPackage('test', {
examples: { demo: 'code' },
}),
hasExamples: true,
},
{
name: 'returns empty object when undefined',
pkg: createMockPackage('test'),
hasExamples: false,
},
])('should handle $name', ({ pkg, hasExamples }) => {
const result = getPackageExamples(pkg)
expect(typeof result).toBe('object')
if (hasExamples) {
expect(result.demo).toBe('code')
} else {
expect(Object.keys(result)).toHaveLength(0)
}
})
})
describe('checkDependencies', () => {
it.each([
{
name: 'satisfied when no dependencies',
registry: createMockRegistry([createMockPackage('test')]),
packageId: 'test',
expectedSatisfied: true,
expectedMissing: [],
},
{
name: 'satisfied when all dependencies present',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
createMockPackage('dep1'),
createMockPackage('dep2'),
]),
packageId: 'test',
expectedSatisfied: true,
expectedMissing: [],
},
{
name: 'not satisfied when dependencies missing',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
createMockPackage('dep1'),
]),
packageId: 'test',
expectedSatisfied: false,
expectedMissing: ['dep2'],
},
{
name: 'not satisfied when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectedSatisfied: false,
expectedMissing: ['nonexistent'],
},
])('should handle $name', ({ registry, packageId, expectedSatisfied, expectedMissing }) => {
const result = checkDependencies(registry, packageId)
expect(result.satisfied).toBe(expectedSatisfied)
expect(result.missing).toEqual(expectedMissing)
})
})
describe('installPackageComponents', () => {
it('should install all components to database', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
components: [
{ id: 'comp1', type: 'button' },
{ id: 'comp2', type: 'form' },
],
})
await installPackageComponents(pkg, db)
expect(db.set).toHaveBeenCalledTimes(2)
expect(db.set).toHaveBeenCalledWith('components', 'comp1', { id: 'comp1', type: 'button' })
expect(db.set).toHaveBeenCalledWith('components', 'comp2', { id: 'comp2', type: 'form' })
})
it('should handle empty components array', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', { components: [] })
await installPackageComponents(pkg, db)
expect(db.set).not.toHaveBeenCalled()
})
})
describe('installPackageScripts', () => {
it('should install legacy script', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', { scripts: 'return 42' })
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test', {
id: 'package_test',
name: 'Package test Scripts',
code: 'return 42',
category: 'package',
packageId: 'test',
})
})
it('should install multi-file scripts', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
scriptFiles: [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}', category: 'setup', description: 'Init script' },
],
})
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test_init', {
id: 'package_test_init',
name: 'Package test - init',
code: 'return {}',
category: 'setup',
packageId: 'test',
path: 'scripts/init.lua',
description: 'Init script',
})
})
it('should install both legacy and multi-file scripts', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
scripts: 'legacy',
scriptFiles: [{ name: 'utils', path: 'scripts/utils.lua', code: 'helpers' }],
})
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledTimes(2)
})
})
describe('installPackage', () => {
it.each([
{
name: 'successfully installs package',
registry: createMockRegistry([createMockPackage('test')]),
packageId: 'test',
expectSuccess: true,
},
{
name: 'fails when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectSuccess: false,
expectedError: 'Package nonexistent not found',
},
{
name: 'fails when dependencies missing',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['missing'] }),
]),
packageId: 'test',
expectSuccess: false,
expectedError: 'Missing dependencies: missing',
},
])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
const db = createMockDb()
const result = await installPackage(registry, packageId, db)
expect(result.success).toBe(expectSuccess)
if (expectedError) {
expect(result.error).toContain(expectedError)
}
if (expectSuccess) {
expect(db.set).toHaveBeenCalledWith(
'installed_packages',
packageId,
expect.objectContaining({ packageId, name: expect.any(String) })
)
}
})
})
describe('uninstallPackage', () => {
it.each([
{
name: 'successfully uninstalls package',
registry: createMockRegistry([
createMockPackage('test', { components: [{ id: 'c1' }] }),
]),
packageId: 'test',
expectSuccess: true,
},
{
name: 'fails when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectSuccess: false,
expectedError: 'Package nonexistent not found',
},
])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
const db = createMockDb()
const result = await uninstallPackage(registry, packageId, db)
expect(result.success).toBe(expectSuccess)
if (expectedError) {
expect(result.error).toContain(expectedError)
}
if (expectSuccess) {
expect(db.delete).toHaveBeenCalledWith('installed_packages', packageId)
}
})
})
describe('getInstalledPackages', () => {
it('should return installed package IDs', async () => {
const db = createMockDb()
db._data['installed_packages'] = { pkg1: {}, pkg2: {} }
const result = await getInstalledPackages(db)
expect(result).toEqual(['pkg1', 'pkg2'])
})
it('should return empty array on error', async () => {
const db = createMockDb()
db.getAll = vi.fn().mockRejectedValue(new Error('DB error'))
const result = await getInstalledPackages(db)
expect(result).toEqual([])
})
it('should return empty array when no packages', async () => {
const db = createMockDb()
const result = await getInstalledPackages(db)
expect(result).toEqual([])
})
})
describe('isPackageInstalled', () => {
it.each([
{
name: 'returns true when installed',
setupDb: (db: ReturnType<typeof createMockDb>) => {
db._data['installed_packages'] = { test: { packageId: 'test' } }
},
packageId: 'test',
expected: true,
},
{
name: 'returns false when not installed',
setupDb: () => {},
packageId: 'test',
expected: false,
},
])('should handle $name', async ({ setupDb, packageId, expected }) => {
const db = createMockDb()
setupDb(db)
const result = await isPackageInstalled(packageId, db)
expect(result).toBe(expected)
})
it('should return false on error', async () => {
const db = createMockDb()
db.get = vi.fn().mockRejectedValue(new Error('DB error'))
const result = await isPackageInstalled('test', db)
expect(result).toBe(false)
})
})
describe('exportAllPackagesForSeed', () => {
it('should export all package data', () => {
const registry = createMockRegistry([
createMockPackage('pkg1', {
name: 'Package 1',
components: [{ id: 'c1' }],
scripts: 'lua code',
}),
createMockPackage('pkg2', {
name: 'Package 2',
scriptFiles: [{ name: 'init', path: 'scripts/init.lua', code: 'return {}' }],
}),
])
const result = exportAllPackagesForSeed(registry)
expect(result.components).toHaveLength(1)
expect(result.scripts).toHaveLength(2) // 1 legacy + 1 file
expect(result.packages).toHaveLength(2)
expect(result.packages[0].packageId).toBe('pkg1')
expect(result.packages[1].packageId).toBe('pkg2')
})
it('should handle empty registry', () => {
const result = exportAllPackagesForSeed({})
expect(result.components).toEqual([])
expect(result.scripts).toEqual([])
expect(result.packages).toEqual([])
})
it('should include script metadata', () => {
const registry = createMockRegistry([
createMockPackage('pkg', {
scriptFiles: [
{
name: 'utils',
path: 'scripts/utils.lua',
code: 'return true',
category: 'helpers',
description: 'Utility functions',
},
],
}),
])
const result = exportAllPackagesForSeed(registry)
expect(result.scripts[0]).toMatchObject({
id: 'package_pkg_utils',
name: 'Package pkg - utils',
code: 'return true',
category: 'helpers',
path: 'scripts/utils.lua',
description: 'Utility functions',
})
})
})
})
export const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
{ name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
]
@@ -0,0 +1,229 @@
import { describe, it, expect, vi } from 'vitest'
import {
getInstalledPackages,
installPackage,
installPackageComponents,
installPackageScripts,
isPackageInstalled,
uninstallPackage,
} from '../../package-glue'
import { createMockDb, createMockPackage, createMockRegistry, mockScriptFiles } from '../package-glue.test'
describe('package-glue execution', () => {
describe('installPackageComponents', () => {
it('should install all components to database', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
components: [
{ id: 'comp1', type: 'button' },
{ id: 'comp2', type: 'form' },
],
})
await installPackageComponents(pkg, db)
expect(db.set).toHaveBeenCalledTimes(2)
expect(db.set).toHaveBeenCalledWith('components', 'comp1', { id: 'comp1', type: 'button' })
expect(db.set).toHaveBeenCalledWith('components', 'comp2', { id: 'comp2', type: 'form' })
})
it('should handle empty components array', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', { components: [] })
await installPackageComponents(pkg, db)
expect(db.set).not.toHaveBeenCalled()
})
})
describe('installPackageScripts', () => {
it('should install legacy script', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', { scripts: 'return 42' })
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test', {
id: 'package_test',
name: 'Package test Scripts',
code: 'return 42',
category: 'package',
packageId: 'test',
})
})
it('should install multi-file scripts', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
scriptFiles: [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}', category: 'setup', description: 'Init script' },
],
})
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test_init', {
id: 'package_test_init',
name: 'Package test - init',
code: 'return {}',
category: 'setup',
packageId: 'test',
path: 'scripts/init.lua',
description: 'Init script',
})
})
it('should install both legacy and multi-file scripts', async () => {
const db = createMockDb()
const scriptFiles = mockScriptFiles.slice(0, 1)
const pkg = createMockPackage('test', {
scripts: 'legacy',
scriptFiles,
})
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledTimes(2)
})
})
describe('installPackage', () => {
it.each([
{
name: 'successfully installs package',
registry: createMockRegistry([createMockPackage('test')]),
packageId: 'test',
expectSuccess: true,
},
{
name: 'fails when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectSuccess: false,
expectedError: 'Package nonexistent not found',
},
{
name: 'fails when dependencies missing',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['missing'] }),
]),
packageId: 'test',
expectSuccess: false,
expectedError: 'Missing dependencies: missing',
},
])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
const db = createMockDb()
const result = await installPackage(registry, packageId, db)
expect(result.success).toBe(expectSuccess)
if (expectedError) {
expect(result.error).toContain(expectedError)
}
if (expectSuccess) {
expect(db.set).toHaveBeenCalledWith(
'installed_packages',
packageId,
expect.objectContaining({ packageId, name: expect.any(String) })
)
}
})
})
describe('uninstallPackage', () => {
it.each([
{
name: 'successfully uninstalls package',
registry: createMockRegistry([
createMockPackage('test', { components: [{ id: 'c1' }] }),
]),
packageId: 'test',
expectSuccess: true,
},
{
name: 'fails when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectSuccess: false,
expectedError: 'Package nonexistent not found',
},
])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
const db = createMockDb()
const result = await uninstallPackage(registry, packageId, db)
expect(result.success).toBe(expectSuccess)
if (expectedError) {
expect(result.error).toContain(expectedError)
}
if (expectSuccess) {
expect(db.delete).toHaveBeenCalledWith('installed_packages', packageId)
}
})
})
describe('getInstalledPackages', () => {
it('should return installed package IDs', async () => {
const db = createMockDb()
db._data['installed_packages'] = { pkg1: {}, pkg2: {} }
const result = await getInstalledPackages(db)
expect(result).toEqual(['pkg1', 'pkg2'])
})
it('should return empty array on error', async () => {
const db = createMockDb()
db.getAll = vi.fn().mockRejectedValue(new Error('DB error'))
const result = await getInstalledPackages(db)
expect(result).toEqual([])
})
it('should return empty array when no packages', async () => {
const db = createMockDb()
const result = await getInstalledPackages(db)
expect(result).toEqual([])
})
})
describe('isPackageInstalled', () => {
it.each([
{
name: 'returns true when installed',
setupDb: (db: ReturnType<typeof createMockDb>) => {
db._data['installed_packages'] = { test: { packageId: 'test' } }
},
packageId: 'test',
expected: true,
},
{
name: 'returns false when not installed',
setupDb: () => {},
packageId: 'test',
expected: false,
},
])('should handle $name', async ({ setupDb, packageId, expected }) => {
const db = createMockDb()
setupDb(db)
const result = await isPackageInstalled(packageId, db)
expect(result).toBe(expected)
})
it('should return false on error', async () => {
const db = createMockDb()
db.get = vi.fn().mockRejectedValue(new Error('DB error'))
const result = await isPackageInstalled('test', db)
expect(result).toBe(false)
})
})
})
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import { exportAllPackagesForSeed } from '../../package-glue'
import { createMockPackage, createMockRegistry } from '../package-glue.test'
describe('package-glue regressions', () => {
describe('exportAllPackagesForSeed', () => {
it('should export all package data', () => {
const registry = createMockRegistry([
createMockPackage('pkg1', {
name: 'Package 1',
components: [{ id: 'c1' }],
scripts: 'lua code',
}),
createMockPackage('pkg2', {
name: 'Package 2',
scriptFiles: [{ name: 'init', path: 'scripts/init.lua', code: 'return {}' }],
}),
])
const result = exportAllPackagesForSeed(registry)
expect(result.components).toHaveLength(1)
expect(result.scripts).toHaveLength(2)
expect(result.packages).toHaveLength(2)
expect(result.packages[0].packageId).toBe('pkg1')
expect(result.packages[1].packageId).toBe('pkg2')
})
it('should handle empty registry', () => {
const result = exportAllPackagesForSeed({})
expect(result.components).toEqual([])
expect(result.scripts).toEqual([])
expect(result.packages).toEqual([])
})
it('should include script metadata', () => {
const registry = createMockRegistry([
createMockPackage('pkg', {
scriptFiles: [
{
name: 'utils',
path: 'scripts/utils.lua',
code: 'return true',
category: 'helpers',
description: 'Utility functions',
},
],
}),
])
const result = exportAllPackagesForSeed(registry)
expect(result.scripts[0]).toMatchObject({
id: 'package_pkg_utils',
name: 'Package pkg - utils',
code: 'return true',
category: 'helpers',
path: 'scripts/utils.lua',
description: 'Utility functions',
})
})
})
})
@@ -0,0 +1,284 @@
import { describe, it, expect } from 'vitest'
import type { LuaScriptFile } from '../../package-glue'
import {
checkDependencies,
getAllPackageScripts,
getPackage,
getPackageComponents,
getPackageExamples,
getPackageScriptFiles,
getPackageScripts,
getPackagesByCategory,
} from '../../package-glue'
import { createMockPackage, createMockRegistry } from '../package-glue.test'
describe('package-glue validation', () => {
describe('getPackage', () => {
it.each([
{
name: 'returns package when found',
registry: createMockRegistry([createMockPackage('test_pkg')]),
packageId: 'test_pkg',
expectFound: true,
},
{
name: 'returns undefined when not found',
registry: createMockRegistry([createMockPackage('other_pkg')]),
packageId: 'test_pkg',
expectFound: false,
},
{
name: 'returns undefined from empty registry',
registry: createMockRegistry([]),
packageId: 'test_pkg',
expectFound: false,
},
])('should handle $name', ({ registry, packageId, expectFound }) => {
const result = getPackage(registry, packageId)
if (expectFound) {
expect(result).toBeDefined()
expect(result?.packageId).toBe(packageId)
} else {
expect(result).toBeUndefined()
}
})
})
describe('getPackagesByCategory', () => {
const mixedRegistry = createMockRegistry([
createMockPackage('pkg1', { category: 'ui' }),
createMockPackage('pkg2', { category: 'ui' }),
createMockPackage('pkg3', { category: 'data' }),
createMockPackage('pkg4', { category: 'util' }),
])
it.each([
{
name: 'returns packages in category',
registry: mixedRegistry,
category: 'ui',
expectedCount: 2,
},
{
name: 'returns single package in category',
registry: mixedRegistry,
category: 'data',
expectedCount: 1,
},
{
name: 'returns empty array for unknown category',
registry: mixedRegistry,
category: 'unknown',
expectedCount: 0,
},
{
name: 'returns empty array from empty registry',
registry: createMockRegistry([]),
category: 'ui',
expectedCount: 0,
},
])('should handle $name', ({ registry, category, expectedCount }) => {
const result = getPackagesByCategory(registry, category)
expect(result).toHaveLength(expectedCount)
result.forEach((pkg) => {
expect(pkg.category).toBe(category)
})
})
})
describe('getPackageComponents', () => {
it.each([
{
name: 'returns components array',
pkg: createMockPackage('test', {
components: [{ id: 'c1' }, { id: 'c2' }],
}),
expectedLength: 2,
},
{
name: 'returns empty array when no components',
pkg: createMockPackage('test', { components: [] }),
expectedLength: 0,
},
{
name: 'returns empty array when components is undefined',
pkg: { ...createMockPackage('test'), components: undefined as any },
expectedLength: 0,
},
])('should handle $name', ({ pkg, expectedLength }) => {
const result = getPackageComponents(pkg)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(expectedLength)
})
})
describe('getPackageScripts', () => {
it.each([
{
name: 'returns scripts string',
pkg: createMockPackage('test', { scripts: 'return 42' }),
expected: 'return 42',
},
{
name: 'returns empty string when undefined',
pkg: createMockPackage('test'),
expected: '',
},
{
name: 'returns empty string when null',
pkg: { ...createMockPackage('test'), scripts: null as any },
expected: '',
},
])('should handle $name', ({ pkg, expected }) => {
const result = getPackageScripts(pkg)
expect(result).toBe(expected)
})
})
describe('getPackageScriptFiles', () => {
const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
{ name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
]
it.each([
{
name: 'returns script files array',
pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
expectedLength: 2,
},
{
name: 'returns empty array when undefined',
pkg: createMockPackage('test'),
expectedLength: 0,
},
{
name: 'returns empty array when empty',
pkg: createMockPackage('test', { scriptFiles: [] }),
expectedLength: 0,
},
])('should handle $name', ({ pkg, expectedLength }) => {
const result = getPackageScriptFiles(pkg)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(expectedLength)
})
})
describe('getAllPackageScripts', () => {
const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
]
it.each([
{
name: 'returns both legacy and files',
pkg: createMockPackage('test', {
scripts: 'legacy code',
scriptFiles: mockScriptFiles,
}),
expectedLegacy: 'legacy code',
expectedFilesLength: 1,
},
{
name: 'handles missing legacy',
pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
expectedLegacy: '',
expectedFilesLength: 1,
},
{
name: 'handles missing files',
pkg: createMockPackage('test', { scripts: 'code' }),
expectedLegacy: 'code',
expectedFilesLength: 0,
},
{
name: 'handles both missing',
pkg: createMockPackage('test'),
expectedLegacy: '',
expectedFilesLength: 0,
},
])('should handle $name', ({ pkg, expectedLegacy, expectedFilesLength }) => {
const result = getAllPackageScripts(pkg)
expect(result.legacy).toBe(expectedLegacy)
expect(result.files).toHaveLength(expectedFilesLength)
})
})
describe('getPackageExamples', () => {
it.each([
{
name: 'returns examples object',
pkg: createMockPackage('test', {
examples: { demo: 'code' },
}),
hasExamples: true,
},
{
name: 'returns empty object when undefined',
pkg: createMockPackage('test'),
hasExamples: false,
},
])('should handle $name', ({ pkg, hasExamples }) => {
const result = getPackageExamples(pkg)
expect(typeof result).toBe('object')
if (hasExamples) {
expect(result.demo).toBe('code')
} else {
expect(Object.keys(result)).toHaveLength(0)
}
})
})
describe('checkDependencies', () => {
it.each([
{
name: 'satisfied when no dependencies',
registry: createMockRegistry([createMockPackage('test')]),
packageId: 'test',
expectedSatisfied: true,
expectedMissing: [],
},
{
name: 'satisfied when all dependencies present',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
createMockPackage('dep1'),
createMockPackage('dep2'),
]),
packageId: 'test',
expectedSatisfied: true,
expectedMissing: [],
},
{
name: 'not satisfied when dependencies missing',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
createMockPackage('dep1'),
]),
packageId: 'test',
expectedSatisfied: false,
expectedMissing: ['dep2'],
},
{
name: 'not satisfied when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectedSatisfied: false,
expectedMissing: ['nonexistent'],
},
])('should handle $name', ({ registry, packageId, expectedSatisfied, expectedMissing }) => {
const result = checkDependencies(registry, packageId)
expect(result.satisfied).toBe(expectedSatisfied)
expect(result.missing).toEqual(expectedMissing)
})
})
})
@@ -1 +1 @@
export * from '../builder-types'
export * from '@/lib/types/builder-types'
@@ -0,0 +1,99 @@
import type { ComponentInstance } from '@/lib/types/builder-types'
export const buildHeaderActions = (): ComponentInstance[] => [
{
id: 'header_login_btn',
type: 'Button',
props: {
children: 'Login',
variant: 'default',
size: 'sm',
},
children: [],
},
]
export const buildProfileCard = (): ComponentInstance => ({
id: 'comp_profile',
type: 'Card',
props: {
className: 'p-6',
},
children: [
{
id: 'comp_profile_header',
type: 'Heading',
props: {
level: 2,
children: 'User Profile',
className: 'text-2xl font-bold mb-4',
},
children: [],
},
{
id: 'comp_profile_content',
type: 'Container',
props: {
className: 'space-y-4',
},
children: [
{
id: 'comp_profile_bio',
type: 'Textarea',
props: {
placeholder: 'Tell us about yourself...',
className: 'min-h-32',
},
children: [],
},
{
id: 'comp_profile_save',
type: 'Button',
props: {
children: 'Save Profile',
variant: 'default',
},
children: [],
},
],
},
],
})
export const buildCommentsCard = (): ComponentInstance => ({
id: 'comp_comments',
type: 'Card',
props: {
className: 'p-6',
},
children: [
{
id: 'comp_comments_header',
type: 'Heading',
props: {
level: 2,
children: 'Community Comments',
className: 'text-2xl font-bold mb-4',
},
children: [],
},
{
id: 'comp_comments_input',
type: 'Textarea',
props: {
placeholder: 'Share your thoughts...',
className: 'mb-4',
},
children: [],
},
{
id: 'comp_comments_post',
type: 'Button',
props: {
children: 'Post Comment',
variant: 'default',
},
children: [],
},
],
})
@@ -1,131 +1,46 @@
import type { PageDefinition } from './page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
import { buildCommentsCard, buildProfileCard } from '@/lib/rendering/page/components'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
export function buildLevel2UserDashboard(): PageDefinition {
const profileCard: ComponentInstance = {
id: 'comp_profile',
type: 'Card',
props: {
className: 'p-6'
},
children: [
return {
id: 'page_level2_dashboard',
level: 2,
title: 'User Dashboard',
description: 'User dashboard with profile and comments',
layout: 'dashboard',
components: [buildProfileCard(), buildCommentsCard()],
permissions: {
requiresAuth: true,
requiredRole: 'user',
},
metadata: {
showHeader: true,
showFooter: false,
headerTitle: 'Dashboard',
sidebarItems: [
{
id: 'comp_profile_header',
type: 'Heading',
props: {
level: 2,
children: 'User Profile',
className: 'text-2xl font-bold mb-4'
},
children: []
id: 'nav_home',
label: 'Home',
icon: '🏠',
action: 'navigate',
target: '1',
},
{
id: 'comp_profile_content',
type: 'Container',
props: {
className: 'space-y-4'
},
children: [
{
id: 'comp_profile_bio',
type: 'Textarea',
props: {
placeholder: 'Tell us about yourself...',
className: 'min-h-32'
},
children: []
},
{
id: 'comp_profile_save',
type: 'Button',
props: {
children: 'Save Profile',
variant: 'default'
},
children: []
}
]
}
]
}
const commentsCard: ComponentInstance = {
id: 'comp_comments',
type: 'Card',
props: {
className: 'p-6'
},
children: [
{
id: 'comp_comments_header',
type: 'Heading',
props: {
level: 2,
children: 'Community Comments',
className: 'text-2xl font-bold mb-4'
},
children: []
id: 'nav_profile',
label: 'Profile',
icon: '👤',
action: 'navigate',
target: '2',
},
{
id: 'comp_comments_input',
type: 'Textarea',
props: {
placeholder: 'Share your thoughts...',
className: 'mb-4'
},
children: []
id: 'nav_chat',
label: 'Chat',
icon: '💬',
action: 'navigate',
target: '2',
},
{
id: 'comp_comments_post',
type: 'Button',
props: {
children: 'Post Comment',
variant: 'default'
},
children: []
}
]
}
return {
id: 'page_level2_dashboard',
level: 2,
title: 'User Dashboard',
description: 'User dashboard with profile and comments',
layout: 'dashboard',
components: [profileCard, commentsCard],
permissions: {
requiresAuth: true,
requiredRole: 'user'
},
metadata: {
showHeader: true,
showFooter: false,
headerTitle: 'Dashboard',
sidebarItems: [
{
id: 'nav_home',
label: 'Home',
icon: '🏠',
action: 'navigate',
target: '1'
},
{
id: 'nav_profile',
label: 'Profile',
icon: '👤',
action: 'navigate',
target: '2'
},
{
id: 'nav_chat',
label: 'Chat',
icon: '💬',
action: 'navigate',
target: '2'
}
]
}
}
],
},
}
}
@@ -1,4 +1,4 @@
import type { PageDefinition } from './page-renderer'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
@@ -1,4 +1,4 @@
import type { PageDefinition } from './page-renderer'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
@@ -1,4 +1,4 @@
import type { PageDefinition } from './page-renderer'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
@@ -1,21 +1,9 @@
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from '@/lib/rendering/page/builder-types'
import { buildHeaderActions } from '@/lib/rendering/page/components'
import { buildFeaturesComponent } from './build-features-component'
import { buildHeroComponent } from './build-hero-component'
const buildHeaderActions = (): ComponentInstance[] => [
{
id: 'header_login_btn',
type: 'Button',
props: {
children: 'Login',
variant: 'default',
size: 'sm'
},
children: []
}
]
export const buildLevel1Homepage = (): PageDefinition => {
const heroComponent = buildHeroComponent()
const featuresComponent = buildFeaturesComponent()
@@ -1,4 +1,4 @@
import type { PageDefinition } from './page-renderer'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
@@ -1,8 +1,8 @@
import type { ComponentInstance } from '../types/builder-types'
import type { User } from '../types/level-types'
import { Database } from '../database'
import type { LuaEngine } from '../lua-engine'
import { executeLuaScriptWithProfile } from '../lua/execute-lua-script-with-profile'
import { Database } from '@/lib/database'
import type { LuaEngine } from '@/lib/lua-engine'
import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile'
import type { ComponentInstance } from '@/lib/types/builder-types'
import type { User } from '@/lib/types/level-types'
export interface PageDefinition {
id: string
@@ -0,0 +1,29 @@
import type { ComponentInstance } from '@/lib/types/builder-types'
import type { User, UserRole } from '@/lib/types/level-types'
import type { PageDefinition } from './page-renderer'
export function createMockPage(
id: string,
options: Partial<PageDefinition> = {}
): PageDefinition {
return {
id,
level: options.level ?? 1,
title: options.title ?? `Page ${id}`,
layout: options.layout ?? 'default',
components: (options.components as ComponentInstance[] | undefined) ?? [],
permissions: options.permissions,
luaScripts: options.luaScripts,
metadata: options.metadata,
}
}
export function createMockUser(role: UserRole | string, id = 'user1'): User {
return {
id,
username: `User ${id}`,
role: role as UserRole,
email: `${id}@test.com`,
createdAt: Date.now(),
}
}
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { DeclarativeComponentRenderer } from '@/lib/rendering/declarative-component-renderer'
describe('declarative-component-renderer evaluation', () => {
let renderer: DeclarativeComponentRenderer
beforeEach(() => {
renderer = new DeclarativeComponentRenderer()
})
describe('interpolateValue', () => {
it.each([
{
name: 'simple interpolation',
template: 'Hello {name}!',
context: { name: 'World' },
expected: 'Hello World!',
},
{
name: 'multiple placeholders',
template: '{greeting} {name}, welcome to {place}',
context: { greeting: 'Hi', name: 'Alice', place: 'Wonderland' },
expected: 'Hi Alice, welcome to Wonderland',
},
{
name: 'missing placeholder',
template: 'Hello {name}, age: {age}',
context: { name: 'Bob' },
expected: 'Hello Bob, age: {age}',
},
{
name: 'numeric value',
template: 'Count: {count}',
context: { count: 42 },
expected: 'Count: 42',
},
{
name: 'boolean value',
template: 'Active: {active}',
context: { active: true },
expected: 'Active: true',
},
{
name: 'empty template',
template: '',
context: { name: 'test' },
expected: '',
},
{
name: 'no placeholders',
template: 'Plain text',
context: { name: 'ignored' },
expected: 'Plain text',
},
{
name: 'null template',
template: null as any,
context: { name: 'test' },
expected: null,
},
{
name: 'undefined value in context',
template: 'Value: {val}',
context: { val: undefined },
expected: 'Value: {val}',
},
])('should handle $name', ({ template, context, expected }) => {
expect(renderer.interpolateValue(template, context)).toBe(expected)
})
})
describe('evaluateConditional', () => {
it.each([
{ name: 'boolean true', condition: true, context: {}, expected: true },
{ name: 'boolean false', condition: false, context: {}, expected: false },
{ name: 'empty string condition', condition: '', context: {}, expected: true },
{ name: 'null condition', condition: null as any, context: {}, expected: true },
{ name: 'undefined condition', condition: undefined as any, context: {}, expected: true },
{ name: 'truthy context value', condition: 'isActive', context: { isActive: true }, expected: true },
{ name: 'falsy context value', condition: 'isActive', context: { isActive: false }, expected: false },
{ name: 'missing context key', condition: 'missing', context: {}, expected: false },
{ name: 'truthy string value', condition: 'name', context: { name: 'test' }, expected: true },
{ name: 'empty string value', condition: 'name', context: { name: '' }, expected: false },
{ name: 'zero value', condition: 'count', context: { count: 0 }, expected: false },
{ name: 'positive number', condition: 'count', context: { count: 5 }, expected: true },
])('should return $expected for $name', ({ condition, context, expected }) => {
expect(renderer.evaluateConditional(condition, context)).toBe(expected)
})
})
describe('resolveDataSource', () => {
it.each([
{
name: 'existing array data source',
dataSource: 'items',
context: { items: [1, 2, 3] },
expected: [1, 2, 3],
},
{
name: 'empty array data source',
dataSource: 'items',
context: { items: [] },
expected: [],
},
{
name: 'missing data source',
dataSource: 'missing',
context: {},
expected: [],
},
{
name: 'null data source key',
dataSource: '',
context: { items: [1] },
expected: [],
},
{
name: 'object array data source',
dataSource: 'users',
context: { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] },
expected: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
},
])('should resolve $name', ({ dataSource, context, expected }) => {
expect(renderer.resolveDataSource(dataSource, context)).toEqual(expected)
})
})
})
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
DeclarativeComponentRenderer,
getDeclarativeRenderer,
loadPackageComponents,
type DeclarativeComponentConfig,
} from '@/lib/rendering/declarative-component-renderer'
describe('declarative-component-renderer lifecycle', () => {
let renderer: DeclarativeComponentRenderer
beforeEach(() => {
renderer = new DeclarativeComponentRenderer()
})
describe('registerComponentConfig', () => {
it.each([
{
name: 'basic component',
type: 'button',
config: {
type: 'button',
category: 'input',
label: 'Button',
description: 'A clickable button',
icon: 'click',
props: [],
config: { layout: 'inline', styling: { className: 'btn' }, children: [] },
},
},
{
name: 'component with props',
type: 'input',
config: {
type: 'input',
category: 'form',
label: 'Input Field',
description: 'Text input',
icon: 'text',
props: [
{ name: 'placeholder', type: 'string', label: 'Placeholder', required: false },
{ name: 'value', type: 'string', label: 'Value', required: true, defaultValue: '' },
],
config: { layout: 'block', styling: { className: 'input' }, children: [] },
},
},
])('should register $name', ({ type, config }) => {
renderer.registerComponentConfig(type, config as DeclarativeComponentConfig)
expect(renderer.hasComponentConfig(type)).toBe(true)
expect(renderer.getComponentConfig(type)).toEqual(config)
})
})
describe('hasComponentConfig', () => {
it.each([
{ type: 'registered', shouldRegister: true, expected: true },
{ type: 'unregistered', shouldRegister: false, expected: false },
])('should return $expected for $type component', ({ type, shouldRegister, expected }) => {
if (shouldRegister) {
renderer.registerComponentConfig(type, {
type,
category: 'test',
label: 'Test',
description: '',
icon: '',
props: [],
config: { layout: '', styling: { className: '' }, children: [] },
})
}
expect(renderer.hasComponentConfig(type)).toBe(expected)
})
})
describe('getComponentConfig', () => {
it('should return undefined for non-existent component', () => {
expect(renderer.getComponentConfig('nonexistent')).toBeUndefined()
})
it('should return config for registered component', () => {
const config: DeclarativeComponentConfig = {
type: 'test',
category: 'test',
label: 'Test Component',
description: 'A test',
icon: 'test',
props: [],
config: { layout: 'block', styling: { className: 'test' }, children: [] },
}
renderer.registerComponentConfig('test', config)
expect(renderer.getComponentConfig('test')).toEqual(config)
})
})
describe('getDeclarativeRenderer', () => {
it('should return a global renderer instance', () => {
const renderer1 = getDeclarativeRenderer()
const renderer2 = getDeclarativeRenderer()
expect(renderer1).toBe(renderer2)
expect(renderer1).toBeInstanceOf(DeclarativeComponentRenderer)
})
})
describe('loadPackageComponents', () => {
it('should load component configs from package', () => {
const renderer = getDeclarativeRenderer()
const testType = `loadTest_${Date.now()}`
loadPackageComponents({
componentConfigs: {
[testType]: {
type: testType,
category: 'test',
label: 'Loaded Component',
description: 'Loaded from package',
icon: 'package',
props: [],
config: { layout: 'block', styling: { className: 'loaded' }, children: [] },
},
},
})
expect(renderer.hasComponentConfig(testType)).toBe(true)
})
it('should load Lua scripts from package', () => {
const luaExecuteSpy = vi.spyOn(DeclarativeComponentRenderer.prototype as any, 'executeLuaScript')
loadPackageComponents({
luaScripts: [
{
id: `pkgScript_${Date.now()}`,
code: 'function formatTime() return 1 end',
parameters: [],
returnType: 'number',
},
],
})
expect(luaExecuteSpy).not.toHaveBeenCalled()
})
it('should handle empty package content', () => {
loadPackageComponents({})
loadPackageComponents({ componentConfigs: {} })
loadPackageComponents({ luaScripts: [] })
expect(true).toBe(true)
})
it('should handle package with both configs and scripts', () => {
const renderer = getDeclarativeRenderer()
const uniqueId = Date.now()
loadPackageComponents({
componentConfigs: {
[`combo_${uniqueId}`]: {
type: `combo_${uniqueId}`,
category: 'combo',
label: 'Combo',
description: 'Combined',
icon: 'combo',
props: [],
config: { layout: 'flex', styling: { className: 'combo' }, children: [] },
},
},
luaScripts: [
{
id: `comboScript_${uniqueId}`,
code: 'function userJoin(name) return "Welcome " .. name end',
parameters: [{ name: 'name' }],
returnType: 'string',
},
],
})
expect(renderer.hasComponentConfig(`combo_${uniqueId}`)).toBe(true)
})
})
})
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { DeclarativeComponentRenderer } from '@/lib/rendering/declarative-component-renderer'
describe('declarative-component-renderer lua integration', () => {
let renderer: DeclarativeComponentRenderer
beforeEach(() => {
renderer = new DeclarativeComponentRenderer()
})
describe('registerLuaScript', () => {
it('should register and store Lua scripts', () => {
const script = {
code: 'return x + y',
parameters: [{ name: 'x' }, { name: 'y' }],
returnType: 'number',
}
renderer.registerLuaScript('add', script)
expect(renderer.executeLuaScript('add', [1, 2])).resolves.toBeDefined()
})
})
describe('executeLuaScript', () => {
it('should throw error for non-existent script', async () => {
await expect(renderer.executeLuaScript('nonexistent', [])).rejects.toThrow(
'Lua script not found: nonexistent'
)
})
it('should execute script with parameters', async () => {
renderer.registerLuaScript('testScript', {
code: `
function formatTime(timestamp)
return timestamp * 1000
end
`,
parameters: [{ name: 'timestamp' }],
returnType: 'number',
})
const result = await renderer.executeLuaScript('testScript', [5])
expect(result).toBe(5000)
})
it('should handle script with no parameters', async () => {
renderer.registerLuaScript('constantScript', {
code: `
function formatTime()
return 42
end
`,
parameters: [],
returnType: 'number',
})
const result = await renderer.executeLuaScript('constantScript', [])
expect(result).toBe(42)
})
})
})
@@ -1,355 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
DeclarativeComponentRenderer,
getDeclarativeRenderer,
loadPackageComponents,
type DeclarativeComponentConfig,
} from './declarative-component-renderer'
describe('declarative-component-renderer', () => {
let renderer: DeclarativeComponentRenderer
beforeEach(() => {
renderer = new DeclarativeComponentRenderer()
})
describe('DeclarativeComponentRenderer', () => {
describe('registerComponentConfig', () => {
it.each([
{
name: 'basic component',
type: 'button',
config: {
type: 'button',
category: 'input',
label: 'Button',
description: 'A clickable button',
icon: 'click',
props: [],
config: { layout: 'inline', styling: { className: 'btn' }, children: [] },
},
},
{
name: 'component with props',
type: 'input',
config: {
type: 'input',
category: 'form',
label: 'Input Field',
description: 'Text input',
icon: 'text',
props: [
{ name: 'placeholder', type: 'string', label: 'Placeholder', required: false },
{ name: 'value', type: 'string', label: 'Value', required: true, defaultValue: '' },
],
config: { layout: 'block', styling: { className: 'input' }, children: [] },
},
},
])('should register $name', ({ type, config }) => {
renderer.registerComponentConfig(type, config as DeclarativeComponentConfig)
expect(renderer.hasComponentConfig(type)).toBe(true)
expect(renderer.getComponentConfig(type)).toEqual(config)
})
})
describe('hasComponentConfig', () => {
it.each([
{ type: 'registered', shouldRegister: true, expected: true },
{ type: 'unregistered', shouldRegister: false, expected: false },
])('should return $expected for $type component', ({ type, shouldRegister, expected }) => {
if (shouldRegister) {
renderer.registerComponentConfig(type, {
type,
category: 'test',
label: 'Test',
description: '',
icon: '',
props: [],
config: { layout: '', styling: { className: '' }, children: [] },
})
}
expect(renderer.hasComponentConfig(type)).toBe(expected)
})
})
describe('getComponentConfig', () => {
it('should return undefined for non-existent component', () => {
expect(renderer.getComponentConfig('nonexistent')).toBeUndefined()
})
it('should return config for registered component', () => {
const config: DeclarativeComponentConfig = {
type: 'test',
category: 'test',
label: 'Test Component',
description: 'A test',
icon: 'test',
props: [],
config: { layout: 'block', styling: { className: 'test' }, children: [] },
}
renderer.registerComponentConfig('test', config)
expect(renderer.getComponentConfig('test')).toEqual(config)
})
})
describe('interpolateValue', () => {
it.each([
{
name: 'simple interpolation',
template: 'Hello {name}!',
context: { name: 'World' },
expected: 'Hello World!',
},
{
name: 'multiple placeholders',
template: '{greeting} {name}, welcome to {place}',
context: { greeting: 'Hi', name: 'Alice', place: 'Wonderland' },
expected: 'Hi Alice, welcome to Wonderland',
},
{
name: 'missing placeholder',
template: 'Hello {name}, age: {age}',
context: { name: 'Bob' },
expected: 'Hello Bob, age: {age}',
},
{
name: 'numeric value',
template: 'Count: {count}',
context: { count: 42 },
expected: 'Count: 42',
},
{
name: 'boolean value',
template: 'Active: {active}',
context: { active: true },
expected: 'Active: true',
},
{
name: 'empty template',
template: '',
context: { name: 'test' },
expected: '',
},
{
name: 'no placeholders',
template: 'Plain text',
context: { name: 'ignored' },
expected: 'Plain text',
},
{
name: 'null template',
template: null as any,
context: { name: 'test' },
expected: null,
},
{
name: 'undefined value in context',
template: 'Value: {val}',
context: { val: undefined },
expected: 'Value: {val}',
},
])('should handle $name', ({ template, context, expected }) => {
expect(renderer.interpolateValue(template, context)).toBe(expected)
})
})
describe('evaluateConditional', () => {
it.each([
{ name: 'boolean true', condition: true, context: {}, expected: true },
{ name: 'boolean false', condition: false, context: {}, expected: false },
{ name: 'empty string condition', condition: '', context: {}, expected: true },
{ name: 'null condition', condition: null as any, context: {}, expected: true },
{ name: 'undefined condition', condition: undefined as any, context: {}, expected: true },
{ name: 'truthy context value', condition: 'isActive', context: { isActive: true }, expected: true },
{ name: 'falsy context value', condition: 'isActive', context: { isActive: false }, expected: false },
{ name: 'missing context key', condition: 'missing', context: {}, expected: false },
{ name: 'truthy string value', condition: 'name', context: { name: 'test' }, expected: true },
{ name: 'empty string value', condition: 'name', context: { name: '' }, expected: false },
{ name: 'zero value', condition: 'count', context: { count: 0 }, expected: false },
{ name: 'positive number', condition: 'count', context: { count: 5 }, expected: true },
])('should return $expected for $name', ({ condition, context, expected }) => {
expect(renderer.evaluateConditional(condition, context)).toBe(expected)
})
})
describe('resolveDataSource', () => {
it.each([
{
name: 'existing array data source',
dataSource: 'items',
context: { items: [1, 2, 3] },
expected: [1, 2, 3],
},
{
name: 'empty array data source',
dataSource: 'items',
context: { items: [] },
expected: [],
},
{
name: 'missing data source',
dataSource: 'missing',
context: {},
expected: [],
},
{
name: 'null data source key',
dataSource: '',
context: { items: [1] },
expected: [],
},
{
name: 'object array data source',
dataSource: 'users',
context: { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] },
expected: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
},
])('should resolve $name', ({ dataSource, context, expected }) => {
expect(renderer.resolveDataSource(dataSource, context)).toEqual(expected)
})
})
describe('registerLuaScript', () => {
it('should register and store Lua scripts', () => {
const script = {
code: 'return x + y',
parameters: [{ name: 'x' }, { name: 'y' }],
returnType: 'number',
}
renderer.registerLuaScript('add', script)
// Verify registration by attempting to execute
// The script is stored internally
expect(true).toBe(true) // Script registered without error
})
})
describe('executeLuaScript', () => {
it('should throw error for non-existent script', async () => {
await expect(renderer.executeLuaScript('nonexistent', [])).rejects.toThrow(
'Lua script not found: nonexistent'
)
})
it('should execute script with parameters', async () => {
renderer.registerLuaScript('testScript', {
code: `
function formatTime(timestamp)
return timestamp * 1000
end
`,
parameters: [{ name: 'timestamp' }],
returnType: 'number',
})
const result = await renderer.executeLuaScript('testScript', [5])
expect(result).toBe(5000)
})
it('should handle script with no parameters', async () => {
renderer.registerLuaScript('constantScript', {
code: `
function formatTime()
return 42
end
`,
parameters: [],
returnType: 'number',
})
const result = await renderer.executeLuaScript('constantScript', [])
expect(result).toBe(42)
})
})
})
describe('getDeclarativeRenderer', () => {
it('should return a global renderer instance', () => {
const renderer1 = getDeclarativeRenderer()
const renderer2 = getDeclarativeRenderer()
expect(renderer1).toBe(renderer2)
expect(renderer1).toBeInstanceOf(DeclarativeComponentRenderer)
})
})
describe('loadPackageComponents', () => {
it('should load component configs from package', () => {
const renderer = getDeclarativeRenderer()
const testType = `loadTest_${Date.now()}`
loadPackageComponents({
componentConfigs: {
[testType]: {
type: testType,
category: 'test',
label: 'Loaded Component',
description: 'Loaded from package',
icon: 'package',
props: [],
config: { layout: 'block', styling: { className: 'loaded' }, children: [] },
},
},
})
expect(renderer.hasComponentConfig(testType)).toBe(true)
})
it('should load Lua scripts from package', () => {
loadPackageComponents({
luaScripts: [
{
id: `pkgScript_${Date.now()}`,
code: 'function formatTime() return 1 end',
parameters: [],
returnType: 'number',
},
],
})
// Script loaded without error
expect(true).toBe(true)
})
it('should handle empty package content', () => {
// Should not throw
loadPackageComponents({})
loadPackageComponents({ componentConfigs: {} })
loadPackageComponents({ luaScripts: [] })
expect(true).toBe(true)
})
it('should handle package with both configs and scripts', () => {
const renderer = getDeclarativeRenderer()
const uniqueId = Date.now()
loadPackageComponents({
componentConfigs: {
[`combo_${uniqueId}`]: {
type: `combo_${uniqueId}`,
category: 'combo',
label: 'Combo',
description: 'Combined',
icon: 'combo',
props: [],
config: { layout: 'flex', styling: { className: 'combo' }, children: [] },
},
},
luaScripts: [
{
id: `comboScript_${uniqueId}`,
code: 'function userJoin(name) return "Welcome " .. name end',
parameters: [{ name: 'name' }],
returnType: 'string',
},
],
})
expect(renderer.hasComponentConfig(`combo_${uniqueId}`)).toBe(true)
})
})
})
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PageRenderer } from '@/lib/rendering/page/page-renderer'
import { createMockPage } from '@/lib/rendering/page/utils'
const { Database, MockLuaEngine } = vi.hoisted(() => {
class MockLuaEngine {
execute = vi.fn()
}
return {
Database: {
getPages: vi.fn(),
addPage: vi.fn(),
getLuaScripts: vi.fn(),
},
MockLuaEngine,
}
})
vi.mock('@/lib/database', () => ({ Database }))
vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
describe('page-renderer layout queries', () => {
let renderer: PageRenderer
beforeEach(() => {
vi.clearAllMocks()
renderer = new PageRenderer()
Database.getPages.mockResolvedValue([])
Database.addPage.mockResolvedValue(undefined)
Database.getLuaScripts.mockResolvedValue([])
})
describe('getPagesByLevel', () => {
it('should filter pages by level', async () => {
await renderer.registerPage(createMockPage('p1', { level: 1 }))
await renderer.registerPage(createMockPage('p2', { level: 2 }))
await renderer.registerPage(createMockPage('p3', { level: 2 }))
await renderer.registerPage(createMockPage('p4', { level: 3 }))
const level2Pages = renderer.getPagesByLevel(2)
expect(level2Pages).toHaveLength(2)
expect(level2Pages.map(p => p.id)).toContain('p2')
expect(level2Pages.map(p => p.id)).toContain('p3')
})
it('should return empty array for level with no pages', async () => {
await renderer.registerPage(createMockPage('p1', { level: 1 }))
const level5Pages = renderer.getPagesByLevel(5)
expect(level5Pages).toHaveLength(0)
})
})
})
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getPageRenderer, PageRenderer } from '@/lib/rendering/page/page-renderer'
import { createMockPage } from '@/lib/rendering/page/utils'
const { Database, MockLuaEngine } = vi.hoisted(() => {
class MockLuaEngine {
execute = vi.fn()
}
return {
Database: {
getPages: vi.fn(),
addPage: vi.fn(),
getLuaScripts: vi.fn(),
},
MockLuaEngine,
}
})
vi.mock('@/lib/database', () => ({ Database }))
vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
describe('page-renderer lifecycle', () => {
let renderer: PageRenderer
beforeEach(() => {
vi.clearAllMocks()
renderer = new PageRenderer()
Database.getPages.mockResolvedValue([])
Database.addPage.mockResolvedValue(undefined)
Database.getLuaScripts.mockResolvedValue([])
})
describe('registerPage', () => {
it('should register a page and add to database', async () => {
const page = createMockPage('test-page', { title: 'Test Page' })
await renderer.registerPage(page)
expect(Database.addPage).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-page',
title: 'Test Page',
})
)
expect(renderer.getPage('test-page')).toEqual(page)
})
it('should handle pages with permissions', async () => {
const page = createMockPage('auth-page', {
permissions: {
requiresAuth: true,
requiredRole: 'admin',
},
})
await renderer.registerPage(page)
expect(Database.addPage).toHaveBeenCalledWith(
expect.objectContaining({
requiresAuth: true,
requiredRole: 'admin',
})
)
})
})
describe('loadPages', () => {
it('should load pages from database', async () => {
Database.getPages.mockResolvedValue([
{
id: 'page1',
title: 'Page 1',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page2',
title: 'Page 2',
level: 3,
componentTree: [{ id: 'c1', type: 'text' }],
requiresAuth: true,
requiredRole: 'admin',
},
])
await renderer.loadPages()
expect(renderer.getPage('page1')).toBeDefined()
expect(renderer.getPage('page2')).toBeDefined()
expect(renderer.getPage('page1')?.title).toBe('Page 1')
expect(renderer.getPage('page2')?.permissions?.requiresAuth).toBe(true)
})
it('should handle empty database', async () => {
Database.getPages.mockResolvedValue([])
await renderer.loadPages()
expect(renderer.getPage('nonexistent')).toBeUndefined()
})
})
describe('getPage', () => {
it.each([
{
name: 'returns page when exists',
pageId: 'existing',
expectFound: true,
},
{
name: 'returns undefined when not exists',
pageId: 'nonexistent',
expectFound: false,
},
])('should handle $name', async ({ pageId, expectFound }) => {
await renderer.registerPage(createMockPage('existing'))
const result = renderer.getPage(pageId)
if (expectFound) {
expect(result).toBeDefined()
expect(result?.id).toBe(pageId)
} else {
expect(result).toBeUndefined()
}
})
})
describe('getPageRenderer singleton', () => {
it('should return the same instance', () => {
const instance1 = getPageRenderer()
const instance2 = getPageRenderer()
expect(instance1).toBe(instance2)
})
})
})
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PageRenderer, type PageDefinition } from '@/lib/rendering/page/page-renderer'
import { createMockPage, createMockUser } from '@/lib/rendering/page/utils'
const { Database, MockLuaEngine } = vi.hoisted(() => {
class MockLuaEngine {
execute = vi.fn()
}
return {
Database: {
getPages: vi.fn(),
addPage: vi.fn(),
getLuaScripts: vi.fn(),
},
MockLuaEngine,
}
})
vi.mock('@/lib/database', () => ({ Database }))
vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
describe('page-renderer permissions', () => {
let renderer: PageRenderer
beforeEach(() => {
vi.clearAllMocks()
renderer = new PageRenderer()
Database.getPages.mockResolvedValue([])
Database.addPage.mockResolvedValue(undefined)
Database.getLuaScripts.mockResolvedValue([])
})
describe('checkPermissions', () => {
it.each([
{
name: 'allows when no permissions defined',
page: createMockPage('open'),
user: null,
expectedAllowed: true,
},
{
name: 'blocks unauthenticated user when auth required',
page: createMockPage('auth', {
permissions: { requiresAuth: true },
}),
user: null,
expectedAllowed: false,
expectedReason: 'Authentication required',
},
{
name: 'allows authenticated user when auth required',
page: createMockPage('auth', {
permissions: { requiresAuth: true },
}),
user: createMockUser('user'),
expectedAllowed: true,
},
{
name: 'blocks user with insufficient role',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('user'),
expectedAllowed: false,
expectedReason: 'Insufficient permissions',
},
{
name: 'allows user with sufficient role',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('admin'),
expectedAllowed: true,
},
{
name: 'allows god role for admin page',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('god'),
expectedAllowed: true,
},
{
name: 'allows supergod role for god page',
page: createMockPage('god', {
permissions: { requiresAuth: true, requiredRole: 'god' },
}),
user: createMockUser('supergod'),
expectedAllowed: true,
},
])('should handle $name', async ({ page, user, expectedAllowed, expectedReason }) => {
const result = await renderer.checkPermissions(page as PageDefinition, user)
expect(result.allowed).toBe(expectedAllowed)
if (expectedReason) {
expect(result.reason).toBe(expectedReason)
}
})
})
})

Some files were not shown because too many files have changed in this diff Show More