From b6b48eafb35e827ee23c790ff490048066cb0273 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:30:57 +0000
Subject: [PATCH 01/80] feat: modularize theme editor
---
.../src/components/editors/ThemeEditor.tsx | 210 +++---------------
.../editors/theme/PaletteEditor.tsx | 86 +++++++
.../components/editors/theme/PreviewPane.tsx | 33 +++
.../src/components/editors/theme/constants.ts | 41 ++++
.../src/components/editors/theme/types.ts | 25 +++
5 files changed, 210 insertions(+), 185 deletions(-)
create mode 100644 frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx
create mode 100644 frontends/nextjs/src/components/editors/theme/PreviewPane.tsx
create mode 100644 frontends/nextjs/src/components/editors/theme/constants.ts
create mode 100644 frontends/nextjs/src/components/editors/theme/types.ts
diff --git a/frontends/nextjs/src/components/editors/ThemeEditor.tsx b/frontends/nextjs/src/components/editors/ThemeEditor.tsx
index 03c56b878..9800bdb50 100644
--- a/frontends/nextjs/src/components/editors/ThemeEditor.tsx
+++ b/frontends/nextjs/src/components/editors/ThemeEditor.tsx
@@ -1,79 +1,15 @@
-import { useState, useEffect } from 'react'
+import { useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Label } from '@/components/ui'
-import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Switch } from '@/components/ui'
import { Palette, Sun, Moon, FloppyDisk, ArrowCounterClockwise } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { useKV } from '@github/spark/hooks'
-
-interface ThemeColors {
- background: string
- foreground: string
- card: string
- cardForeground: string
- primary: string
- primaryForeground: string
- secondary: string
- secondaryForeground: string
- muted: string
- mutedForeground: string
- accent: string
- accentForeground: string
- destructive: string
- destructiveForeground: string
- border: string
- input: string
- ring: string
-}
-
-interface ThemeConfig {
- light: ThemeColors
- dark: ThemeColors
- radius: string
-}
-
-const DEFAULT_LIGHT_THEME: ThemeColors = {
- background: 'oklch(0.92 0.03 290)',
- foreground: 'oklch(0.25 0.02 260)',
- card: 'oklch(1 0 0)',
- cardForeground: 'oklch(0.25 0.02 260)',
- primary: 'oklch(0.55 0.18 290)',
- primaryForeground: 'oklch(0.98 0 0)',
- secondary: 'oklch(0.35 0.02 260)',
- secondaryForeground: 'oklch(0.90 0.01 260)',
- muted: 'oklch(0.95 0.02 290)',
- mutedForeground: 'oklch(0.50 0.02 260)',
- accent: 'oklch(0.70 0.17 195)',
- accentForeground: 'oklch(0.2 0.02 260)',
- destructive: 'oklch(0.55 0.22 25)',
- destructiveForeground: 'oklch(0.98 0 0)',
- border: 'oklch(0.85 0.02 290)',
- input: 'oklch(0.85 0.02 290)',
- ring: 'oklch(0.70 0.17 195)',
-}
-
-const DEFAULT_DARK_THEME: ThemeColors = {
- background: 'oklch(0.145 0 0)',
- foreground: 'oklch(0.985 0 0)',
- card: 'oklch(0.205 0 0)',
- cardForeground: 'oklch(0.985 0 0)',
- primary: 'oklch(0.922 0 0)',
- primaryForeground: 'oklch(0.205 0 0)',
- secondary: 'oklch(0.269 0 0)',
- secondaryForeground: 'oklch(0.985 0 0)',
- muted: 'oklch(0.269 0 0)',
- mutedForeground: 'oklch(0.708 0 0)',
- accent: 'oklch(0.269 0 0)',
- accentForeground: 'oklch(0.985 0 0)',
- destructive: 'oklch(0.704 0.191 22.216)',
- destructiveForeground: 'oklch(0.98 0 0)',
- border: 'oklch(1 0 0 / 10%)',
- input: 'oklch(1 0 0 / 15%)',
- ring: 'oklch(0.556 0 0)',
-}
+import { PaletteEditor } from './theme/PaletteEditor'
+import { PreviewPane } from './theme/PreviewPane'
+import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from './theme/constants'
+import { ThemeColors, ThemeConfig } from './theme/types'
export function ThemeEditor() {
const [themeConfig, setThemeConfig] = useKV('theme_config', {
@@ -81,7 +17,7 @@ export function ThemeEditor() {
dark: DEFAULT_DARK_THEME,
radius: '0.5rem',
})
-
+
const [isDarkMode, setIsDarkMode] = useKV('dark_mode_enabled', false)
const [editingTheme, setEditingTheme] = useState<'light' | 'dark'>('light')
const [localColors, setLocalColors] = useState(DEFAULT_LIGHT_THEME)
@@ -95,30 +31,19 @@ export function ThemeEditor() {
}, [editingTheme, themeConfig])
useEffect(() => {
- if (themeConfig) {
- applyTheme()
- }
- }, [themeConfig, isDarkMode])
-
- const applyTheme = () => {
if (!themeConfig) return
-
+
const root = document.documentElement
const colors = isDarkMode ? themeConfig.dark : themeConfig.light
-
+
Object.entries(colors).forEach(([key, value]) => {
const cssVarName = key.replace(/([A-Z])/g, '-$1').toLowerCase()
root.style.setProperty(`--${cssVarName}`, value)
})
-
+
root.style.setProperty('--radius', themeConfig.radius)
-
- if (isDarkMode) {
- root.classList.add('dark')
- } else {
- root.classList.remove('dark')
- }
- }
+ root.classList.toggle('dark', isDarkMode)
+ }, [isDarkMode, themeConfig])
const handleColorChange = (colorKey: keyof ThemeColors, value: string) => {
setLocalColors((current) => ({
@@ -130,12 +55,14 @@ export function ThemeEditor() {
const handleSave = () => {
setThemeConfig((current) => {
if (!current) return { light: localColors, dark: DEFAULT_DARK_THEME, radius: localRadius }
+
return {
...current,
[editingTheme]: localColors,
radius: localRadius,
}
})
+
toast.success('Theme saved successfully')
}
@@ -151,41 +78,6 @@ export function ThemeEditor() {
toast.success(checked ? 'Dark mode enabled' : 'Light mode enabled')
}
- const colorGroups = [
- {
- title: 'Base Colors',
- colors: [
- { key: 'background' as const, label: 'Background' },
- { key: 'foreground' as const, label: 'Foreground' },
- { key: 'card' as const, label: 'Card' },
- { key: 'cardForeground' as const, label: 'Card Foreground' },
- ],
- },
- {
- title: 'Action Colors',
- colors: [
- { key: 'primary' as const, label: 'Primary' },
- { key: 'primaryForeground' as const, label: 'Primary Foreground' },
- { key: 'secondary' as const, label: 'Secondary' },
- { key: 'secondaryForeground' as const, label: 'Secondary Foreground' },
- { key: 'accent' as const, label: 'Accent' },
- { key: 'accentForeground' as const, label: 'Accent Foreground' },
- { key: 'destructive' as const, label: 'Destructive' },
- { key: 'destructiveForeground' as const, label: 'Destructive Foreground' },
- ],
- },
- {
- title: 'Supporting Colors',
- colors: [
- { key: 'muted' as const, label: 'Muted' },
- { key: 'mutedForeground' as const, label: 'Muted Foreground' },
- { key: 'border' as const, label: 'Border' },
- { key: 'input' as const, label: 'Input' },
- { key: 'ring' as const, label: 'Ring' },
- ],
- },
- ]
-
return (
@@ -196,9 +88,7 @@ export function ThemeEditor() {
Theme Editor
-
- Customize the application theme colors and appearance
-
+ Customize the application theme colors and appearance
@@ -207,52 +97,21 @@ export function ThemeEditor() {
-
- setEditingTheme(v as 'light' | 'dark')}>
+
+
+ setEditingTheme(value as 'light' | 'dark')}>
Light Theme
Dark Theme
-
-
-
-
- Border Radius
- setLocalRadius(e.target.value)}
- placeholder="e.g., 0.5rem"
- className="mt-1.5"
- />
-
-
- {colorGroups.map((group) => (
-
-
{group.title}
-
- {group.colors.map(({ key, label }) => (
-
-
{label}
-
-
-
handleColorChange(key, e.target.value)}
- placeholder="oklch(...)"
- className="font-mono text-sm"
- />
-
-
- ))}
-
-
- ))}
+
+
@@ -267,26 +126,7 @@ export function ThemeEditor() {
-
-
Theme Preview
-
-
- Primary Button
- Secondary
- Outline
- Destructive
-
-
-
- Card Example
- This is a card description
-
-
- Card content with muted text
-
-
-
-
+
diff --git a/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx b/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx
new file mode 100644
index 000000000..2950b135b
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx
@@ -0,0 +1,86 @@
+import { Input, Label } from '@/components/ui'
+import { ThemeColors } from './types'
+
+const colorGroups = [
+ {
+ title: 'Base Colors',
+ colors: [
+ { key: 'background' as const, label: 'Background' },
+ { key: 'foreground' as const, label: 'Foreground' },
+ { key: 'card' as const, label: 'Card' },
+ { key: 'cardForeground' as const, label: 'Card Foreground' },
+ ],
+ },
+ {
+ title: 'Action Colors',
+ colors: [
+ { key: 'primary' as const, label: 'Primary' },
+ { key: 'primaryForeground' as const, label: 'Primary Foreground' },
+ { key: 'secondary' as const, label: 'Secondary' },
+ { key: 'secondaryForeground' as const, label: 'Secondary Foreground' },
+ { key: 'accent' as const, label: 'Accent' },
+ { key: 'accentForeground' as const, label: 'Accent Foreground' },
+ { key: 'destructive' as const, label: 'Destructive' },
+ { key: 'destructiveForeground' as const, label: 'Destructive Foreground' },
+ ],
+ },
+ {
+ title: 'Supporting Colors',
+ colors: [
+ { key: 'muted' as const, label: 'Muted' },
+ { key: 'mutedForeground' as const, label: 'Muted Foreground' },
+ { key: 'border' as const, label: 'Border' },
+ { key: 'input' as const, label: 'Input' },
+ { key: 'ring' as const, label: 'Ring' },
+ ],
+ },
+]
+
+interface PaletteEditorProps {
+ colors: ThemeColors
+ radius: string
+ onColorChange: (colorKey: keyof ThemeColors, value: string) => void
+ onRadiusChange: (value: string) => void
+}
+
+export function PaletteEditor({ colors, radius, onColorChange, onRadiusChange }: PaletteEditorProps) {
+ return (
+
+
+
+ Border Radius
+ onRadiusChange(e.target.value)}
+ placeholder="e.g., 0.5rem"
+ className="mt-1.5"
+ />
+
+
+
+ {colorGroups.map((group) => (
+
+
{group.title}
+
+ {group.colors.map(({ key, label }) => (
+
+
{label}
+
+
+
onColorChange(key, e.target.value)}
+ placeholder="oklch(...)"
+ className="font-mono text-sm"
+ />
+
+
+ ))}
+
+
+ ))}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx b/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx
new file mode 100644
index 000000000..795ccf45d
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx
@@ -0,0 +1,33 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+import { Button } from '@/components/ui'
+
+export function PreviewPane() {
+ return (
+
+
Theme Preview
+
+
+ Primary Button
+
+ Secondary
+
+
+ Outline
+
+
+ Destructive
+
+
+
+
+ Card Example
+ This is a card description
+
+
+ Card content with muted text
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/editors/theme/constants.ts b/frontends/nextjs/src/components/editors/theme/constants.ts
new file mode 100644
index 000000000..7e7b8f8b2
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/theme/constants.ts
@@ -0,0 +1,41 @@
+import { ThemeColors } from './types'
+
+export const DEFAULT_LIGHT_THEME: ThemeColors = {
+ background: 'oklch(0.92 0.03 290)',
+ foreground: 'oklch(0.25 0.02 260)',
+ card: 'oklch(1 0 0)',
+ cardForeground: 'oklch(0.25 0.02 260)',
+ primary: 'oklch(0.55 0.18 290)',
+ primaryForeground: 'oklch(0.98 0 0)',
+ secondary: 'oklch(0.35 0.02 260)',
+ secondaryForeground: 'oklch(0.90 0.01 260)',
+ muted: 'oklch(0.95 0.02 290)',
+ mutedForeground: 'oklch(0.50 0.02 260)',
+ accent: 'oklch(0.70 0.17 195)',
+ accentForeground: 'oklch(0.2 0.02 260)',
+ destructive: 'oklch(0.55 0.22 25)',
+ destructiveForeground: 'oklch(0.98 0 0)',
+ border: 'oklch(0.85 0.02 290)',
+ input: 'oklch(0.85 0.02 290)',
+ ring: 'oklch(0.70 0.17 195)',
+}
+
+export const DEFAULT_DARK_THEME: ThemeColors = {
+ background: 'oklch(0.145 0 0)',
+ foreground: 'oklch(0.985 0 0)',
+ card: 'oklch(0.205 0 0)',
+ cardForeground: 'oklch(0.985 0 0)',
+ primary: 'oklch(0.922 0 0)',
+ primaryForeground: 'oklch(0.205 0 0)',
+ secondary: 'oklch(0.269 0 0)',
+ secondaryForeground: 'oklch(0.985 0 0)',
+ muted: 'oklch(0.269 0 0)',
+ mutedForeground: 'oklch(0.708 0 0)',
+ accent: 'oklch(0.269 0 0)',
+ accentForeground: 'oklch(0.985 0 0)',
+ destructive: 'oklch(0.704 0.191 22.216)',
+ destructiveForeground: 'oklch(0.98 0 0)',
+ border: 'oklch(1 0 0 / 10%)',
+ input: 'oklch(1 0 0 / 15%)',
+ ring: 'oklch(0.556 0 0)',
+}
diff --git a/frontends/nextjs/src/components/editors/theme/types.ts b/frontends/nextjs/src/components/editors/theme/types.ts
new file mode 100644
index 000000000..8440c06d8
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/theme/types.ts
@@ -0,0 +1,25 @@
+export interface ThemeColors {
+ background: string
+ foreground: string
+ card: string
+ cardForeground: string
+ primary: string
+ primaryForeground: string
+ secondary: string
+ secondaryForeground: string
+ muted: string
+ mutedForeground: string
+ accent: string
+ accentForeground: string
+ destructive: string
+ destructiveForeground: string
+ border: string
+ input: string
+ ring: string
+}
+
+export interface ThemeConfig {
+ light: ThemeColors
+ dark: ThemeColors
+ radius: string
+}
From 07efe7609a58e55349da4807cd4c85540e60cc12 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:31:22 +0000
Subject: [PATCH 02/80] refactor: extract json editor ui components
---
.../src/components/editors/JsonEditor.tsx | 77 +++++++++----------
.../components/editors/json/SchemaSection.tsx | 26 +++++++
.../src/components/editors/json/Toolbar.tsx | 31 ++++++++
3 files changed, 93 insertions(+), 41 deletions(-)
create mode 100644 frontends/nextjs/src/components/editors/json/SchemaSection.tsx
create mode 100644 frontends/nextjs/src/components/editors/json/Toolbar.tsx
diff --git a/frontends/nextjs/src/components/editors/JsonEditor.tsx b/frontends/nextjs/src/components/editors/JsonEditor.tsx
index 7b6beb59a..f6ebcf593 100644
--- a/frontends/nextjs/src/components/editors/JsonEditor.tsx
+++ b/frontends/nextjs/src/components/editors/JsonEditor.tsx
@@ -1,12 +1,13 @@
-import { useState, useEffect } from 'react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
-import { Button } from '@/components/ui'
-import { Alert, AlertDescription } from '@/components/ui'
-import { FloppyDisk, X, Warning, ShieldCheck } from '@phosphor-icons/react'
+import { useEffect, useState } from 'react'
+import { Alert, AlertDescription, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
+import { Warning } from '@phosphor-icons/react'
import Editor from '@monaco-editor/react'
+import { toast } from 'sonner'
+
+import { SchemaSection } from './json/SchemaSection'
+import { Toolbar } from './json/Toolbar'
import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner'
import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog'
-import { toast } from 'sonner'
interface JsonEditorProps {
open: boolean
@@ -32,10 +33,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
}
}, [open, value])
+ const parseJson = () => JSON.parse(jsonText)
+
const handleSave = () => {
try {
- const parsed = JSON.parse(jsonText)
-
+ const parsed = parseJson()
+
const scanResult = securityScanner.scanJSON(jsonText)
setSecurityScanResult(scanResult)
@@ -66,8 +69,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const handleForceSave = () => {
try {
- const parsed = JSON.parse(jsonText)
- onSave(parsed)
+ onSave(parseJson())
setError(null)
setPendingSave(false)
setShowSecurityDialog(false)
@@ -81,7 +83,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const scanResult = securityScanner.scanJSON(jsonText)
setSecurityScanResult(scanResult)
setShowSecurityDialog(true)
-
+
if (scanResult.safe) {
toast.success('No security issues detected')
} else {
@@ -91,8 +93,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const handleFormat = () => {
try {
- const parsed = JSON.parse(jsonText)
- setJsonText(JSON.stringify(parsed, null, 2))
+ setJsonText(JSON.stringify(parseJson(), null, 2))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid JSON - cannot format')
@@ -106,7 +107,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
{title}
-
+
{error && (
@@ -115,16 +116,21 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
)}
- {securityScanResult && securityScanResult.severity !== 'safe' && securityScanResult.severity !== 'low' && !showSecurityDialog && (
-
-
-
- {securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'} detected.
- Click Security Scan to review.
-
-
- )}
-
+ {securityScanResult &&
+ securityScanResult.severity !== 'safe' &&
+ securityScanResult.severity !== 'low' &&
+ !showSecurityDialog && (
+
+
+
+ {securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'}
+ detected. Click Security Scan to review.
+
+
+ )}
+
+
+
-
-
-
- Security Scan
-
-
- Format JSON
-
-
-
- Cancel
-
-
-
- Save
-
-
+
diff --git a/frontends/nextjs/src/components/editors/json/SchemaSection.tsx b/frontends/nextjs/src/components/editors/json/SchemaSection.tsx
new file mode 100644
index 000000000..b77ca8486
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/json/SchemaSection.tsx
@@ -0,0 +1,26 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+
+interface SchemaSectionProps {
+ schema?: unknown
+}
+
+export function SchemaSection({ schema }: SchemaSectionProps) {
+ if (!schema) return null
+
+ const formattedSchema =
+ typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2)
+
+ return (
+
+
+ Schema
+ Reference for the expected JSON structure
+
+
+
+ {formattedSchema}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/editors/json/Toolbar.tsx b/frontends/nextjs/src/components/editors/json/Toolbar.tsx
new file mode 100644
index 000000000..a642ce6fc
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/json/Toolbar.tsx
@@ -0,0 +1,31 @@
+import { Button, DialogFooter } from '@/components/ui'
+import { FloppyDisk, ShieldCheck, X } from '@phosphor-icons/react'
+
+interface ToolbarProps {
+ onScan: () => void
+ onFormat: () => void
+ onCancel: () => void
+ onSave: () => void
+}
+
+export function Toolbar({ onScan, onFormat, onCancel, onSave }: ToolbarProps) {
+ return (
+
+
+
+ Security Scan
+
+
+ Format JSON
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+ )
+}
From 149ee90339b9b6321d6a1d899d8797a44dc431d8 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:32:01 +0000
Subject: [PATCH 03/80] chore: add foundation type modules
---
.../src/core/foundation/types/entities.ts | 19 +++++++++++++++++++
.../src/core/foundation/types/events.ts | 13 +++++++++++++
.../src/core/foundation/types/index.ts | 3 +++
.../src/core/foundation/types/operations.ts | 19 +++++++++++++++++++
4 files changed, 54 insertions(+)
create mode 100644 dbal/development/src/core/foundation/types/entities.ts
create mode 100644 dbal/development/src/core/foundation/types/events.ts
create mode 100644 dbal/development/src/core/foundation/types/operations.ts
diff --git a/dbal/development/src/core/foundation/types/entities.ts b/dbal/development/src/core/foundation/types/entities.ts
new file mode 100644
index 000000000..dcd20b271
--- /dev/null
+++ b/dbal/development/src/core/foundation/types/entities.ts
@@ -0,0 +1,19 @@
+export type EntityId = string
+
+export interface BaseEntity {
+ id: EntityId
+ createdAt: Date
+ updatedAt: Date
+}
+
+export interface SoftDeletableEntity extends BaseEntity {
+ deletedAt?: Date
+}
+
+export interface TenantScopedEntity extends BaseEntity {
+ tenantId: string
+}
+
+export interface EntityMetadata {
+ metadata?: Record
+}
diff --git a/dbal/development/src/core/foundation/types/events.ts b/dbal/development/src/core/foundation/types/events.ts
new file mode 100644
index 000000000..5679fb156
--- /dev/null
+++ b/dbal/development/src/core/foundation/types/events.ts
@@ -0,0 +1,13 @@
+import type { OperationContext } from './operations'
+
+export interface DomainEvent> {
+ id: string
+ name: string
+ occurredAt: Date
+ payload: TPayload
+ context?: OperationContext
+}
+
+export interface EventHandler> {
+ (event: DomainEvent): void | Promise
+}
diff --git a/dbal/development/src/core/foundation/types/index.ts b/dbal/development/src/core/foundation/types/index.ts
index 4c2264d64..293944c9a 100644
--- a/dbal/development/src/core/foundation/types/index.ts
+++ b/dbal/development/src/core/foundation/types/index.ts
@@ -4,3 +4,6 @@ export * from './content'
export * from './automation'
export * from './packages'
export * from './shared'
+export * from './entities'
+export * from './operations'
+export * from './events'
diff --git a/dbal/development/src/core/foundation/types/operations.ts b/dbal/development/src/core/foundation/types/operations.ts
new file mode 100644
index 000000000..c411a9ea9
--- /dev/null
+++ b/dbal/development/src/core/foundation/types/operations.ts
@@ -0,0 +1,19 @@
+export interface OperationContext {
+ tenantId?: string
+ userId?: string
+ correlationId?: string
+ traceId?: string
+ metadata?: Record
+}
+
+export interface OperationOptions {
+ timeoutMs?: number
+ retryCount?: number
+ dryRun?: boolean
+}
+
+export interface OperationAuditTrail {
+ performedAt: Date
+ performedBy?: string
+ context?: OperationContext
+}
From 01ae4c753fd662f9dda8e9be9dbe4789f4b6f587 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:32:40 +0000
Subject: [PATCH 04/80] refactor: modularize tenant-aware blob storage
---
.../blob/providers/tenant-aware-storage.ts | 4 ++++
.../tenant-aware-storage/audit-hooks.ts | 17 +++++++++++++++
.../providers/tenant-aware-storage/context.ts | 21 +------------------
.../tenant-aware-storage/mutations.ts | 16 +++++++-------
.../providers/tenant-aware-storage/reads.ts | 13 ++++++------
.../tenant-aware-storage/tenant-context.ts | 21 +++++++++++++++++++
.../providers/tenant-aware-storage/uploads.ts | 14 +++++++------
7 files changed, 67 insertions(+), 39 deletions(-)
create mode 100644 dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts
create mode 100644 dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts
diff --git a/dbal/development/src/blob/providers/tenant-aware-storage.ts b/dbal/development/src/blob/providers/tenant-aware-storage.ts
index 33fa59a9d..c8f14cee9 100644
--- a/dbal/development/src/blob/providers/tenant-aware-storage.ts
+++ b/dbal/development/src/blob/providers/tenant-aware-storage.ts
@@ -1 +1,5 @@
export { TenantAwareBlobStorage } from './tenant-aware-storage/index'
+export type { TenantAwareDeps } from './tenant-aware-storage/context'
+export { scopeKey, unscopeKey } from './tenant-aware-storage/context'
+export { ensurePermission, resolveTenantContext } from './tenant-aware-storage/tenant-context'
+export { auditCopy, auditDeletion, auditUpload } from './tenant-aware-storage/audit-hooks'
diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts b/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts
new file mode 100644
index 000000000..8aeb80c80
--- /dev/null
+++ b/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts
@@ -0,0 +1,17 @@
+import type { TenantAwareDeps } from './context'
+
+const recordUsageChange = async (deps: TenantAwareDeps, bytesChange: number, countChange: number): Promise => {
+ await deps.tenantManager.updateBlobUsage(deps.tenantId, bytesChange, countChange)
+}
+
+export const auditUpload = async (deps: TenantAwareDeps, sizeBytes: number): Promise => {
+ await recordUsageChange(deps, sizeBytes, 1)
+}
+
+export const auditDeletion = async (deps: TenantAwareDeps, sizeBytes: number): Promise => {
+ await recordUsageChange(deps, -sizeBytes, -1)
+}
+
+export const auditCopy = async (deps: TenantAwareDeps, sizeBytes: number): Promise => {
+ await recordUsageChange(deps, sizeBytes, 1)
+}
diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/context.ts b/dbal/development/src/blob/providers/tenant-aware-storage/context.ts
index 234816666..067d7ff99 100644
--- a/dbal/development/src/blob/providers/tenant-aware-storage/context.ts
+++ b/dbal/development/src/blob/providers/tenant-aware-storage/context.ts
@@ -1,5 +1,4 @@
-import { DBALError } from '../../core/foundation/errors'
-import type { TenantContext, TenantManager } from '../../core/foundation/tenant-context'
+import type { TenantManager } from '../../core/foundation/tenant-context'
import type { BlobStorage } from '../blob-storage'
export interface TenantAwareDeps {
@@ -9,10 +8,6 @@ export interface TenantAwareDeps {
userId: string
}
-export const getContext = async ({ tenantManager, tenantId, userId }: TenantAwareDeps): Promise => {
- return tenantManager.getTenantContext(tenantId, userId)
-}
-
export const scopeKey = (key: string, namespace: string): string => {
const cleanKey = key.startsWith('/') ? key.substring(1) : key
return `${namespace}${cleanKey}`
@@ -24,17 +19,3 @@ export const unscopeKey = (scopedKey: string, namespace: string): string => {
}
return scopedKey
}
-
-export const ensurePermission = (context: TenantContext, action: 'read' | 'write' | 'delete'): void => {
- const accessCheck =
- action === 'read' ? context.canRead('blob') : action === 'write' ? context.canWrite('blob') : context.canDelete('blob')
-
- if (!accessCheck) {
- const verbs: Record = {
- read: 'read',
- write: 'write',
- delete: 'delete',
- }
- throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`)
- }
-}
diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts b/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts
index 6ec400af4..b518eb1c0 100644
--- a/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts
+++ b/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts
@@ -1,10 +1,12 @@
import { DBALError } from '../../core/foundation/errors'
import type { BlobMetadata } from '../blob-storage'
-import { ensurePermission, getContext, scopeKey } from './context'
+import { auditCopy, auditDeletion } from './audit-hooks'
import type { TenantAwareDeps } from './context'
+import { scopeKey } from './context'
+import { ensurePermission, resolveTenantContext } from './tenant-context'
export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'delete')
const scopedKey = scopeKey(key, context.namespace)
@@ -14,7 +16,7 @@ export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
@@ -36,7 +38,7 @@ export const copyBlob = async (
sourceKey: string,
destKey: string,
): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
ensurePermission(context, 'write')
@@ -50,7 +52,7 @@ export const copyBlob = async (
const destScoped = scopeKey(destKey, context.namespace)
const metadata = await deps.baseStorage.copy(sourceScoped, destScoped)
- await deps.tenantManager.updateBlobUsage(deps.tenantId, sourceMetadata.size, 1)
+ await auditCopy(deps, sourceMetadata.size)
return {
...metadata,
@@ -59,7 +61,7 @@ export const copyBlob = async (
}
export const getStats = async (deps: TenantAwareDeps) => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
return {
count: context.quota.currentBlobCount,
totalSize: context.quota.currentBlobStorageBytes,
diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts b/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts
index 5ba718d0d..9fc52a58b 100644
--- a/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts
+++ b/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts
@@ -1,9 +1,10 @@
import type { DownloadOptions, BlobMetadata, BlobListOptions, BlobListResult } from '../blob-storage'
-import { ensurePermission, getContext, scopeKey, unscopeKey } from './context'
import type { TenantAwareDeps } from './context'
+import { scopeKey, unscopeKey } from './context'
+import { ensurePermission, resolveTenantContext } from './tenant-context'
export const downloadBuffer = async (deps: TenantAwareDeps, key: string): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
@@ -15,7 +16,7 @@ export const downloadStream = async (
key: string,
options?: DownloadOptions,
): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
@@ -26,7 +27,7 @@ export const listBlobs = async (
deps: TenantAwareDeps,
options: BlobListOptions = {},
): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedOptions: BlobListOptions = {
@@ -46,7 +47,7 @@ export const listBlobs = async (
}
export const getMetadata = async (deps: TenantAwareDeps, key: string): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
@@ -63,7 +64,7 @@ export const generatePresignedUrl = async (
key: string,
expiresIn: number,
): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts b/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts
new file mode 100644
index 000000000..acdd36720
--- /dev/null
+++ b/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts
@@ -0,0 +1,21 @@
+import { DBALError } from '../../core/foundation/errors'
+import type { TenantContext } from '../../core/foundation/tenant-context'
+import type { TenantAwareDeps } from './context'
+
+export const resolveTenantContext = async ({ tenantManager, tenantId, userId }: TenantAwareDeps): Promise => {
+ return tenantManager.getTenantContext(tenantId, userId)
+}
+
+export const ensurePermission = (context: TenantContext, action: 'read' | 'write' | 'delete'): void => {
+ const accessCheck =
+ action === 'read' ? context.canRead('blob') : action === 'write' ? context.canWrite('blob') : context.canDelete('blob')
+
+ if (!accessCheck) {
+ const verbs: Record = {
+ read: 'read',
+ write: 'write',
+ delete: 'delete',
+ }
+ throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`)
+ }
+}
diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts b/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts
index cd787a533..382fc4881 100644
--- a/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts
+++ b/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts
@@ -1,7 +1,9 @@
import { DBALError } from '../../core/foundation/errors'
-import type { UploadOptions, BlobMetadata } from '../blob-storage'
+import { auditUpload } from './audit-hooks'
import type { TenantAwareDeps } from './context'
-import { ensurePermission, getContext, scopeKey } from './context'
+import { scopeKey } from './context'
+import { ensurePermission, resolveTenantContext } from './tenant-context'
+import type { UploadOptions, BlobMetadata } from '../blob-storage'
export const uploadBuffer = async (
deps: TenantAwareDeps,
@@ -9,7 +11,7 @@ export const uploadBuffer = async (
data: Buffer,
options?: UploadOptions,
): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'write')
if (!context.canUploadBlob(data.length)) {
@@ -18,7 +20,7 @@ export const uploadBuffer = async (
const scopedKey = scopeKey(key, context.namespace)
const metadata = await deps.baseStorage.upload(scopedKey, data, options)
- await deps.tenantManager.updateBlobUsage(deps.tenantId, data.length, 1)
+ await auditUpload(deps, data.length)
return {
...metadata,
@@ -33,7 +35,7 @@ export const uploadStream = async (
size: number,
options?: UploadOptions,
): Promise => {
- const context = await getContext(deps)
+ const context = await resolveTenantContext(deps)
ensurePermission(context, 'write')
if (!context.canUploadBlob(size)) {
@@ -42,7 +44,7 @@ export const uploadStream = async (
const scopedKey = scopeKey(key, context.namespace)
const metadata = await deps.baseStorage.uploadStream(scopedKey, stream, size, options)
- await deps.tenantManager.updateBlobUsage(deps.tenantId, size, 1)
+ await auditUpload(deps, size)
return {
...metadata,
From a320a8535368842cb0ed606047babcc78c35d916 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:33:06 +0000
Subject: [PATCH 05/80] refactor: split page routes manager components
---
.../components/managers/PageRoutesManager.tsx | 233 ++++--------------
.../managers/page-routes/Preview.tsx | 37 +++
.../managers/page-routes/RouteEditor.tsx | 107 ++++++++
.../managers/page-routes/RoutesTable.tsx | 98 ++++++++
4 files changed, 288 insertions(+), 187 deletions(-)
create mode 100644 frontends/nextjs/src/components/managers/page-routes/Preview.tsx
create mode 100644 frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx
create mode 100644 frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx
diff --git a/frontends/nextjs/src/components/managers/PageRoutesManager.tsx b/frontends/nextjs/src/components/managers/PageRoutesManager.tsx
index e6ed6411f..e11958f24 100644
--- a/frontends/nextjs/src/components/managers/PageRoutesManager.tsx
+++ b/frontends/nextjs/src/components/managers/PageRoutesManager.tsx
@@ -1,29 +1,39 @@
-import { useState, useEffect } from 'react'
-import { Button } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Label } from '@/components/ui'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui'
-import { Plus, Pencil, Trash, Eye, LockKey } from '@phosphor-icons/react'
+import { useEffect, useState } from 'react'
+import {
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui'
+import { Plus } from '@phosphor-icons/react'
import { Database } from '@/lib/database'
+import type { PageConfig } from '@/lib/level-types'
import { toast } from 'sonner'
-import type { PageConfig, UserRole, AppLevel } from '@/lib/level-types'
-import { Switch } from '@/components/ui'
+import { RoutesTable } from './page-routes/RoutesTable'
+import { Preview } from './page-routes/Preview'
+import { RouteEditor, RouteFormData } from './page-routes/RouteEditor'
+
+const defaultFormData: RouteFormData = {
+ path: '/',
+ title: '',
+ level: 1,
+ requiresAuth: false,
+ componentTree: [],
+}
export function PageRoutesManager() {
const [pages, setPages] = useState([])
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingPage, setEditingPage] = useState(null)
- const [formData, setFormData] = useState>({
- path: '/',
- title: '',
- level: 1,
- requiresAuth: false,
- componentTree: [],
- })
+ const [formData, setFormData] = useState({ ...defaultFormData })
useEffect(() => {
loadPages()
@@ -40,13 +50,7 @@ export function PageRoutesManager() {
setFormData(page)
} else {
setEditingPage(null)
- setFormData({
- path: '/',
- title: '',
- level: 1,
- requiresAuth: false,
- componentTree: [],
- })
+ setFormData({ ...defaultFormData })
}
setIsDialogOpen(true)
}
@@ -54,13 +58,7 @@ export function PageRoutesManager() {
const handleCloseDialog = () => {
setIsDialogOpen(false)
setEditingPage(null)
- setFormData({
- path: '/',
- title: '',
- level: 1,
- requiresAuth: false,
- componentTree: [],
- })
+ setFormData({ ...defaultFormData })
}
const handleSavePage = async () => {
@@ -98,18 +96,6 @@ export function PageRoutesManager() {
}
}
- const getLevelBadgeColor = (level: AppLevel) => {
- switch (level) {
- case 1: return 'bg-blue-500'
- case 2: return 'bg-green-500'
- case 3: return 'bg-orange-500'
- case 4: return 'bg-sky-500'
- case 5: return 'bg-purple-500'
- case 6: return 'bg-rose-500'
- default: return 'bg-gray-500'
- }
- }
-
return (
@@ -124,94 +110,23 @@ export function PageRoutesManager() {
New Page Route
-
+
{editingPage ? 'Edit Page Route' : 'Create New Page Route'}
Configure the route path, access level, and authentication requirements
-
-
-
-
-
-
- Application Level
- setFormData({ ...formData, level: Number(value) as AppLevel })}
- >
-
-
-
-
- Level 1 - Public
- Level 2 - User Area
- Level 3 - Moderator Desk
- Level 4 - Admin Panel
- Level 5 - God Builder
- Level 6 - Supergod Console
-
-
-
-
-
- Required Role (if auth)
- setFormData({ ...formData, requiredRole: value as UserRole })}
- >
-
-
-
-
- Public
- User
- Moderator
- Admin
- God
- Supergod
-
-
-
-
-
-
- setFormData({ ...formData, requiresAuth: checked })}
- />
- Requires Authentication
-
+
-
-
- Cancel
-
- {editingPage ? 'Update Page' : 'Create Page'}
-
-
@@ -222,67 +137,11 @@ export function PageRoutesManager() {
All page routes in your application
- {pages.length === 0 ? (
-
-
No pages configured yet. Create your first page route!
-
- ) : (
-
-
-
- Path
- Title
- Level
- Auth
- Required Role
- Actions
-
-
-
- {pages.map((page) => (
-
- {page.path}
- {page.title}
-
-
- Level {page.level}
-
-
-
- {page.requiresAuth ? (
-
- ) : (
-
- )}
-
-
-
- {page.requiredRole || 'public'}
-
-
-
-
-
handleOpenDialog(page)}
- >
-
-
-
handleDeletePage(page.id)}
- >
-
-
-
-
-
- ))}
-
-
- )}
+
diff --git a/frontends/nextjs/src/components/managers/page-routes/Preview.tsx b/frontends/nextjs/src/components/managers/page-routes/Preview.tsx
new file mode 100644
index 000000000..2bfd2e207
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/page-routes/Preview.tsx
@@ -0,0 +1,37 @@
+import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+import { Eye, LockKey } from '@phosphor-icons/react'
+import type { PageConfig } from '@/lib/level-types'
+
+interface PreviewProps {
+ formData: Partial
+}
+
+export function Preview({ formData }: PreviewProps) {
+ return (
+
+
+ Route Preview
+ Quick glance at the route details
+
+
+
+
Path
+
{formData.path || '/your-path'}
+
+
+
Title
+
{formData.title || 'Untitled Page'}
+
+
+ Level {formData.level || 1}
+ {formData.requiredRole || 'public'}
+ {formData.requiresAuth ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx b/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx
new file mode 100644
index 000000000..e772c97ff
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx
@@ -0,0 +1,107 @@
+import {
+ Button,
+ Input,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Switch,
+} from '@/components/ui'
+import type { PageConfig, UserRole, AppLevel } from '@/lib/level-types'
+
+export type RouteFormData = Partial
+
+interface RouteEditorProps {
+ formData: RouteFormData
+ onChange: (value: RouteFormData) => void
+ onSave: () => void
+ onCancel: () => void
+ isEdit: boolean
+}
+
+export function RouteEditor({ formData, onChange, onSave, onCancel, isEdit }: RouteEditorProps) {
+ return (
+
+
+
+
+
+ Application Level
+ onChange({ ...formData, level: Number(value) as AppLevel })}
+ >
+
+
+
+
+ Level 1 - Public
+ Level 2 - User Area
+ Level 3 - Moderator Desk
+ Level 4 - Admin Panel
+ Level 5 - God Builder
+ Level 6 - Supergod Console
+
+
+
+
+
+ Required Role (if auth)
+ onChange({ ...formData, requiredRole: value as UserRole })}
+ >
+
+
+
+
+ Public
+ User
+ Moderator
+ Admin
+ God
+ Supergod
+
+
+
+
+
+
+ onChange({ ...formData, requiresAuth: checked })}
+ />
+ Requires Authentication
+
+
+
+ Cancel
+
+ {isEdit ? 'Update Page' : 'Create Page'}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx b/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx
new file mode 100644
index 000000000..be7f98038
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx
@@ -0,0 +1,98 @@
+import {
+ Badge,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui'
+import { Eye, LockKey, Pencil, Trash } from '@phosphor-icons/react'
+import type { PageConfig, AppLevel } from '@/lib/level-types'
+
+interface RoutesTableProps {
+ pages: PageConfig[]
+ onEdit: (page: PageConfig) => void
+ onDelete: (pageId: string) => void
+}
+
+const getLevelBadgeColor = (level: AppLevel) => {
+ switch (level) {
+ case 1: return 'bg-blue-500'
+ case 2: return 'bg-green-500'
+ case 3: return 'bg-orange-500'
+ case 4: return 'bg-sky-500'
+ case 5: return 'bg-purple-500'
+ case 6: return 'bg-rose-500'
+ default: return 'bg-gray-500'
+ }
+}
+
+export function RoutesTable({ pages, onEdit, onDelete }: RoutesTableProps) {
+ if (pages.length === 0) {
+ return (
+
+
No pages configured yet. Create your first page route!
+
+ )
+ }
+
+ return (
+
+
+
+ Path
+ Title
+ Level
+ Auth
+ Required Role
+ Actions
+
+
+
+ {pages.map((page) => (
+
+ {page.path}
+ {page.title}
+
+
+ Level {page.level}
+
+
+
+ {page.requiresAuth ? (
+
+ ) : (
+
+ )}
+
+
+
+ {page.requiredRole || 'public'}
+
+
+
+
+
onEdit(page)}
+ >
+
+
+
onDelete(page.id)}
+ >
+
+
+
+
+
+ ))}
+
+
+ )
+}
From d9a8e75fbfcc33da118344a4263acc9175231ac3 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:34:09 +0000
Subject: [PATCH 06/80] refactor: extract dropdown manager components
---
.../managers/DropdownConfigManager.tsx | 201 +++---------------
.../managers/dropdown/DropdownConfigForm.tsx | 182 ++++++++++++++++
.../managers/dropdown/PreviewPane.tsx | 44 ++++
3 files changed, 260 insertions(+), 167 deletions(-)
create mode 100644 frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx
create mode 100644 frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx
diff --git a/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx b/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx
index 254d0132e..dbd2edc00 100644
--- a/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx
+++ b/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx
@@ -1,26 +1,16 @@
-import { useState, useEffect } from 'react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
-import { Button } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Label } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
-import { Card } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { Separator } from '@/components/ui'
+import { useEffect, useState } from 'react'
+import { Button, Card } from '@/components/ui'
import { Database } from '@/lib/database'
-import { Plus, X, FloppyDisk, Trash, Pencil } from '@phosphor-icons/react'
+import { Plus } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { DropdownConfig } from '@/lib/database'
+import { DropdownConfigForm } from './dropdown/DropdownConfigForm'
+import { PreviewPane } from './dropdown/PreviewPane'
export function DropdownConfigManager() {
const [dropdowns, setDropdowns] = useState([])
const [isEditing, setIsEditing] = useState(false)
const [editingDropdown, setEditingDropdown] = useState(null)
- const [dropdownName, setDropdownName] = useState('')
- const [dropdownLabel, setDropdownLabel] = useState('')
- const [options, setOptions] = useState>([])
- const [newOptionValue, setNewOptionValue] = useState('')
- const [newOptionLabel, setNewOptionLabel] = useState('')
useEffect(() => {
loadDropdowns()
@@ -31,63 +21,34 @@ export function DropdownConfigManager() {
setDropdowns(configs)
}
- const startEdit = (dropdown?: DropdownConfig) => {
- if (dropdown) {
- setEditingDropdown(dropdown)
- setDropdownName(dropdown.name)
- setDropdownLabel(dropdown.label)
- setOptions(dropdown.options)
- } else {
- setEditingDropdown(null)
- setDropdownName('')
- setDropdownLabel('')
- setOptions([])
- }
+ const openEditor = (dropdown?: DropdownConfig) => {
+ setEditingDropdown(dropdown ?? null)
setIsEditing(true)
}
- const addOption = () => {
- if (newOptionValue && newOptionLabel) {
- setOptions(current => [...current, { value: newOptionValue, label: newOptionLabel }])
- setNewOptionValue('')
- setNewOptionLabel('')
- }
- }
-
- const removeOption = (index: number) => {
- setOptions(current => current.filter((_, i) => i !== index))
- }
-
- const handleSave = async () => {
- if (!dropdownName || !dropdownLabel || options.length === 0) {
- toast.error('Please fill all fields and add at least one option')
- return
- }
-
- const newDropdown: DropdownConfig = {
- id: editingDropdown?.id || `dropdown_${Date.now()}`,
- name: dropdownName,
- label: dropdownLabel,
- options,
- }
-
- if (editingDropdown) {
- await Database.updateDropdownConfig(newDropdown.id, newDropdown)
+ const handleSave = async (config: DropdownConfig, isEdit: boolean) => {
+ if (isEdit) {
+ await Database.updateDropdownConfig(config.id, config)
toast.success('Dropdown updated successfully')
} else {
- await Database.addDropdownConfig(newDropdown)
+ await Database.addDropdownConfig(config)
toast.success('Dropdown created successfully')
}
setIsEditing(false)
- loadDropdowns()
+ await loadDropdowns()
}
const handleDelete = async (id: string) => {
- if (confirm('Are you sure you want to delete this dropdown configuration?')) {
- await Database.deleteDropdownConfig(id)
- toast.success('Dropdown deleted')
- loadDropdowns()
+ await Database.deleteDropdownConfig(id)
+ toast.success('Dropdown deleted')
+ await loadDropdowns()
+ }
+
+ const handleDialogChange = (open: boolean) => {
+ setIsEditing(open)
+ if (!open) {
+ setEditingDropdown(null)
}
}
@@ -98,7 +59,7 @@ export function DropdownConfigManager() {
Dropdown Configurations
Manage dynamic dropdown options for properties
- startEdit()}>
+ openEditor()}>
Create Dropdown
@@ -106,30 +67,12 @@ export function DropdownConfigManager() {
{dropdowns.map(dropdown => (
-
-
-
-
{dropdown.label}
-
{dropdown.name}
-
-
-
startEdit(dropdown)}>
-
-
-
handleDelete(dropdown.id)}>
-
-
-
-
-
-
- {dropdown.options.map((opt, i) => (
-
- {opt.label}
-
- ))}
-
-
+
))}
@@ -139,88 +82,12 @@ export function DropdownConfigManager() {
)}
-
-
-
- {editingDropdown ? 'Edit' : 'Create'} Dropdown Configuration
-
-
-
-
-
Dropdown Name (ID)
-
setDropdownName(e.target.value)}
- />
-
Unique identifier for this dropdown
-
-
-
- Display Label
- setDropdownLabel(e.target.value)}
- />
-
-
-
-
-
-
- {options.length > 0 && (
-
-
- {options.map((opt, i) => (
-
-
- {opt.value}
- →
- {opt.label}
-
-
removeOption(i)}
- >
-
-
-
- ))}
-
-
- )}
-
-
-
- setIsEditing(false)}>
- Cancel
-
-
-
- Save
-
-
-
-
+
)
}
diff --git a/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx b/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx
new file mode 100644
index 000000000..754d4cf73
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx
@@ -0,0 +1,182 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Badge, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, ScrollArea, Separator } from '@/components/ui'
+import { FloppyDisk, Plus, X } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import type { DropdownConfig } from '@/lib/database'
+
+interface DropdownConfigFormProps {
+ open: boolean
+ editingDropdown: DropdownConfig | null
+ onOpenChange: (open: boolean) => void
+ onSave: (config: DropdownConfig, isEdit: boolean) => Promise | void
+}
+
+const getDefaultOptions = (dropdown?: DropdownConfig | null) => dropdown?.options ?? []
+
+const buildDropdownConfig = (
+ dropdown: DropdownConfig | null,
+ name: string,
+ label: string,
+ options: Array<{ value: string; label: string }>
+): DropdownConfig => ({
+ id: dropdown?.id ?? `dropdown_${Date.now()}`,
+ name: name.trim(),
+ label: label.trim(),
+ options,
+})
+
+export function DropdownConfigForm({ open, editingDropdown, onOpenChange, onSave }: DropdownConfigFormProps) {
+ const [dropdownName, setDropdownName] = useState('')
+ const [dropdownLabel, setDropdownLabel] = useState('')
+ const [options, setOptions] = useState>([])
+ const [newOptionValue, setNewOptionValue] = useState('')
+ const [newOptionLabel, setNewOptionLabel] = useState('')
+
+ const isEditMode = useMemo(() => Boolean(editingDropdown), [editingDropdown])
+
+ useEffect(() => {
+ if (open) {
+ setDropdownName(editingDropdown?.name ?? '')
+ setDropdownLabel(editingDropdown?.label ?? '')
+ setOptions(getDefaultOptions(editingDropdown))
+ } else {
+ setDropdownName('')
+ setDropdownLabel('')
+ setOptions([])
+ setNewOptionValue('')
+ setNewOptionLabel('')
+ }
+ }, [open, editingDropdown])
+
+ const addOption = () => {
+ if (!newOptionValue.trim() || !newOptionLabel.trim()) {
+ toast.error('Please provide both a value and label for the option')
+ return
+ }
+
+ const duplicate = options.some(
+ (opt) => opt.value.toLowerCase() === newOptionValue.trim().toLowerCase()
+ )
+
+ if (duplicate) {
+ toast.error('An option with this value already exists')
+ return
+ }
+
+ setOptions((current) => [
+ ...current,
+ { value: newOptionValue.trim(), label: newOptionLabel.trim() },
+ ])
+ setNewOptionValue('')
+ setNewOptionLabel('')
+ }
+
+ const removeOption = (index: number) => {
+ setOptions((current) => current.filter((_, i) => i !== index))
+ }
+
+ const handleSave = async () => {
+ if (!dropdownName.trim() || !dropdownLabel.trim() || options.length === 0) {
+ toast.error('Please fill all fields and add at least one option')
+ return
+ }
+
+ const config = buildDropdownConfig(editingDropdown, dropdownName, dropdownLabel, options)
+ await onSave(config, isEditMode)
+ onOpenChange(false)
+ }
+
+ return (
+
+
+
+ {isEditMode ? 'Edit' : 'Create'} Dropdown Configuration
+
+
+
+
+
Dropdown Name (ID)
+
setDropdownName(e.target.value)}
+ />
+
Unique identifier for this dropdown
+
+
+
+ Display Label
+ setDropdownLabel(e.target.value)}
+ />
+
+
+
+
+
+
+ {options.length > 0 && (
+
+
+ {options.map((opt, i) => (
+
+
+ {opt.value}
+ →
+ {opt.label}
+
+
removeOption(i)}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ {options.length === 0 && (
+
+ Tip
+ Add at least one option to save this dropdown configuration.
+
+ )}
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+
+ Save
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx b/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx
new file mode 100644
index 000000000..bbc144249
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx
@@ -0,0 +1,44 @@
+import { Badge, Button, Card, Separator } from '@/components/ui'
+import { Pencil, Trash } from '@phosphor-icons/react'
+import type { DropdownConfig } from '@/lib/database'
+
+interface PreviewPaneProps {
+ dropdown: DropdownConfig
+ onEdit: (dropdown: DropdownConfig) => void
+ onDelete: (id: string) => void
+}
+
+export function PreviewPane({ dropdown, onEdit, onDelete }: PreviewPaneProps) {
+ const handleDelete = () => {
+ if (confirm('Are you sure you want to delete this dropdown configuration?')) {
+ onDelete(dropdown.id)
+ }
+ }
+
+ return (
+
+
+
+
{dropdown.label}
+
{dropdown.name}
+
+
+
onEdit(dropdown)}>
+
+
+
+
+
+
+
+
+
+ {dropdown.options.map((opt, i) => (
+
+ {opt.label}
+
+ ))}
+
+
+ )
+}
From fcd73228614d269baed33672fe609711fc3ee49b Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:34:46 +0000
Subject: [PATCH 07/80] refactor: modularize moderator panel components
---
.../level/panels/ModeratorPanel.tsx | 142 +++---------------
.../level/panels/ModeratorPanel/Actions.tsx | 56 +++++++
.../level/panels/ModeratorPanel/Header.tsx | 12 ++
.../level/panels/ModeratorPanel/LogList.tsx | 83 ++++++++++
4 files changed, 168 insertions(+), 125 deletions(-)
create mode 100644 frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx
create mode 100644 frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx
create mode 100644 frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx
diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx
index 514ccdb25..f811601a0 100644
--- a/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx
+++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx
@@ -1,22 +1,13 @@
"use client"
import { useEffect, useMemo, useState } from 'react'
-import { Button } from '@/components/ui'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui'
-import { Stack, Typography } from '@/components/ui'
import { toast } from 'sonner'
+import { AppHeader } from '@/components/shared/AppHeader'
import { Database } from '@/lib/database'
import type { Comment, User } from '@/lib/level-types'
-import { AppHeader } from '@/components/shared/AppHeader'
+import { ModeratorActions } from './ModeratorPanel/Actions'
+import { ModeratorHeader } from './ModeratorPanel/Header'
+import { ModeratorLogList } from './ModeratorPanel/LogList'
const FLAGGED_TERMS = ['spam', 'error', 'abuse', 'illegal', 'urgent', 'offensive']
@@ -70,8 +61,6 @@ export function ModeratorPanel({ user, onLogout, onNavigate }: ModeratorPanelPro
toast.success('Flag resolved and archived from the queue')
}
- const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1)
-
return (
-
- Moderation queue
-
- Keep the community healthy by resolving flags, reviewing reports, and guiding the tone.
-
-
-
-
-
-
- Flagged content
- Automated signal based on keywords
-
-
- {flaggedComments.length}
-
- Pending items in the moderation queue
-
-
-
-
-
-
- Resolved this session
-
-
- {resolvedIds.length}
-
- Items you flagged as handled
-
-
-
-
-
-
- Community signals
-
-
-
- {FLAGGED_TERMS.map((term) => (
- {highlightLabel(term)}
- ))}
-
-
- Track the keywords that pulled items into the queue
-
-
-
-
-
-
-
-
-
- Flagged comments
- A curated view of the comments that triggered a signal
-
-
onNavigate(2)}>
- Go to user dashboard
-
-
-
-
- {isLoading ? (
- Loading flagged comments…
- ) : flaggedComments.length === 0 ? (
-
- No flagged comments at the moment. Enjoy the calm.
-
- ) : (
-
-
-
- User
- Comment
- Matched terms
- Actions
-
-
-
- {flaggedComments.map((comment) => {
- const matches = FLAGGED_TERMS.filter((term) =>
- comment.content.toLowerCase().includes(term)
- )
- return (
-
- {comment.userId}
- {comment.content}
-
-
- {matches.map((match) => (
-
- {match}
-
- ))}
-
-
-
- handleResolve(comment.id)}>
- Mark safe
-
-
-
- )
- })}
-
-
- )}
-
-
+
+
+
)
diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx
new file mode 100644
index 000000000..8bfa78c49
--- /dev/null
+++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx
@@ -0,0 +1,56 @@
+import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack, Typography } from '@/components/ui'
+
+interface ModeratorActionsProps {
+ flaggedCount: number
+ resolvedCount: number
+ flaggedTerms: string[]
+}
+
+export function ModeratorActions({ flaggedCount, resolvedCount, flaggedTerms }: ModeratorActionsProps) {
+ const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1)
+
+ return (
+
+
+
+ Flagged content
+ Automated signal based on keywords
+
+
+ {flaggedCount}
+
+ Pending items in the moderation queue
+
+
+
+
+
+
+ Resolved this session
+
+
+ {resolvedCount}
+
+ Items you flagged as handled
+
+
+
+
+
+
+ Community signals
+
+
+
+ {flaggedTerms.map((term) => (
+ {highlightLabel(term)}
+ ))}
+
+
+ Track the keywords that pulled items into the queue
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx
new file mode 100644
index 000000000..cb86c4a0a
--- /dev/null
+++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx
@@ -0,0 +1,12 @@
+import { Typography } from '@/components/ui'
+
+export function ModeratorHeader() {
+ return (
+
+ Moderation queue
+
+ Keep the community healthy by resolving flags, reviewing reports, and guiding the tone.
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx
new file mode 100644
index 000000000..b133c6b0b
--- /dev/null
+++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx
@@ -0,0 +1,83 @@
+import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack } from '@/components/ui'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@/components/ui'
+import type { Comment } from '@/lib/level-types'
+
+interface ModeratorLogListProps {
+ flaggedComments: Comment[]
+ flaggedTerms: string[]
+ isLoading: boolean
+ onNavigate: (level: number) => void
+ onResolve: (commentId: string) => void
+}
+
+export function ModeratorLogList({
+ flaggedComments,
+ flaggedTerms,
+ isLoading,
+ onNavigate,
+ onResolve,
+}: ModeratorLogListProps) {
+ return (
+
+
+
+
+ Flagged comments
+ A curated view of the comments that triggered a signal
+
+
onNavigate(2)}>
+ Go to user dashboard
+
+
+
+
+ {isLoading ? (
+ Loading flagged comments…
+ ) : flaggedComments.length === 0 ? (
+
+ No flagged comments at the moment. Enjoy the calm.
+
+ ) : (
+
+
+
+ User
+ Comment
+ Matched terms
+ Actions
+
+
+
+ {flaggedComments.map((comment) => {
+ const matches = flaggedTerms.filter((term) =>
+ comment.content.toLowerCase().includes(term)
+ )
+
+ return (
+
+ {comment.userId}
+ {comment.content}
+
+
+ {matches.map((match) => (
+
+ {match}
+
+ ))}
+
+
+
+ onResolve(comment.id)}>
+ Mark safe
+
+
+
+ )
+ })}
+
+
+ )}
+
+
+ )
+}
From 756c48fc8357b2626916c1e5273edcc3a4db83f0 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:35:35 +0000
Subject: [PATCH 08/80] refactor: modularize cpp build assistant
---
dbal/shared/tools/cpp-build-assistant.ts | 9 +-
dbal/shared/tools/cpp-build-assistant/cli.ts | 125 ++++++++++++++++++
.../tools/cpp-build-assistant/config.ts | 20 +++
.../shared/tools/cpp-build-assistant/index.ts | 112 +++-------------
.../tools/cpp-build-assistant/runner.ts | 10 ++
5 files changed, 183 insertions(+), 93 deletions(-)
create mode 100644 dbal/shared/tools/cpp-build-assistant/cli.ts
create mode 100644 dbal/shared/tools/cpp-build-assistant/config.ts
create mode 100644 dbal/shared/tools/cpp-build-assistant/runner.ts
diff --git a/dbal/shared/tools/cpp-build-assistant.ts b/dbal/shared/tools/cpp-build-assistant.ts
index fcd23a64b..7becf72bc 100644
--- a/dbal/shared/tools/cpp-build-assistant.ts
+++ b/dbal/shared/tools/cpp-build-assistant.ts
@@ -1,12 +1,15 @@
import path from 'path'
-import { CppBuildAssistant, runCppBuildAssistant } from './cpp-build-assistant/index'
+import { runCppBuildAssistant } from './cpp-build-assistant/runner'
-export { CppBuildAssistant, runCppBuildAssistant }
+export { CppBuildAssistant, createAssistant } from './cpp-build-assistant'
+export { createCppBuildAssistantConfig } from './cpp-build-assistant/config'
+export { runCppBuildAssistant } from './cpp-build-assistant/runner'
if (require.main === module) {
const args = process.argv.slice(2)
+ const projectRoot = path.join(__dirname, '..')
- runCppBuildAssistant(args, path.join(__dirname, '..'))
+ runCppBuildAssistant(args, projectRoot)
.then(success => {
process.exit(success ? 0 : 1)
})
diff --git a/dbal/shared/tools/cpp-build-assistant/cli.ts b/dbal/shared/tools/cpp-build-assistant/cli.ts
new file mode 100644
index 000000000..c29c525a8
--- /dev/null
+++ b/dbal/shared/tools/cpp-build-assistant/cli.ts
@@ -0,0 +1,125 @@
+import os from 'os'
+import { BuildType } from './config'
+import { COLORS, log } from './logging'
+import { CppBuildAssistant } from './index'
+
+export type CliCommand =
+ | 'check'
+ | 'init'
+ | 'install'
+ | 'configure'
+ | 'build'
+ | 'test'
+ | 'clean'
+ | 'rebuild'
+ | 'full'
+ | 'help'
+
+export interface ParsedCliArgs {
+ command: CliCommand
+ buildType: BuildType
+ jobs: number
+ target?: string
+ options: string[]
+}
+
+const parseBuildType = (options: string[]): BuildType => (options.includes('--debug') ? 'Debug' : 'Release')
+
+const parseJobs = (options: string[]): number => {
+ const jobsArg = options.find(option => option.startsWith('--jobs='))
+ const parsedJobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : Number.NaN
+
+ return Number.isNaN(parsedJobs) ? os.cpus().length : parsedJobs
+}
+
+const parseTarget = (command: CliCommand, options: string[]): string | undefined => {
+ if (command !== 'build') return undefined
+
+ return options.find(option => !option.startsWith('--')) || 'all'
+}
+
+export const parseCliArgs = (args: string[]): ParsedCliArgs => {
+ const command = (args[0] as CliCommand | undefined) || 'help'
+ const options = args.slice(1)
+
+ return {
+ command,
+ buildType: parseBuildType(options),
+ jobs: parseJobs(options),
+ target: parseTarget(command, options),
+ options,
+ }
+}
+
+export const showHelp = (): void => {
+ console.log(`
+${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper
+
+${COLORS.cyan}USAGE:${COLORS.reset}
+ npm run cpp:build [command] [options]
+
+${COLORS.cyan}COMMANDS:${COLORS.reset}
+ ${COLORS.green}check${COLORS.reset} Check if all dependencies are installed
+ ${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing)
+ ${COLORS.green}install${COLORS.reset} Install Conan dependencies
+ ${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator
+ ${COLORS.green}build${COLORS.reset} [target] Build the project (default: all)
+ ${COLORS.green}test${COLORS.reset} Run tests with CTest
+ ${COLORS.green}clean${COLORS.reset} Remove build artifacts
+ ${COLORS.green}rebuild${COLORS.reset} Clean and rebuild
+ ${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build
+ ${COLORS.green}help${COLORS.reset} Show this help message
+
+${COLORS.cyan}OPTIONS:${COLORS.reset}
+ --debug Use Debug build type
+ --release Use Release build type (default)
+ --jobs=N Number of parallel build jobs (default: CPU count)
+
+${COLORS.cyan}EXAMPLES:${COLORS.reset}
+ npm run cpp:build check
+ npm run cpp:build full
+ npm run cpp:build build dbal_daemon
+ npm run cpp:build build -- --debug
+ npm run cpp:build test
+`)
+}
+
+export const runCli = async (args: string[], assistant: CppBuildAssistant): Promise => {
+ const parsed = parseCliArgs(args)
+
+ switch (parsed.command) {
+ case 'check':
+ return assistant.checkDependencies()
+ case 'init':
+ return assistant.createConanfile()
+ case 'install':
+ if (!assistant.checkDependencies()) return false
+ return assistant.installConanDeps()
+ case 'configure':
+ if (!assistant.checkDependencies()) return false
+ return assistant.configureCMake(parsed.buildType)
+ case 'build':
+ if (!assistant.checkDependencies()) return false
+ return assistant.build(parsed.target, parsed.jobs)
+ case 'test':
+ return assistant.test()
+ case 'clean':
+ return assistant.clean()
+ case 'rebuild':
+ assistant.clean()
+ if (!assistant.checkDependencies()) return false
+ if (!assistant.configureCMake(parsed.buildType)) return false
+ return assistant.build('all', parsed.jobs)
+ case 'full':
+ log.section('Full Build Workflow')
+ if (!assistant.checkDependencies()) return false
+ if (!assistant.createConanfile()) return false
+ if (!assistant.installConanDeps()) return false
+ if (!assistant.configureCMake(parsed.buildType)) return false
+ return assistant.build('all', parsed.jobs)
+ case 'help':
+ default:
+ showHelp()
+ return true
+ }
+}
diff --git a/dbal/shared/tools/cpp-build-assistant/config.ts b/dbal/shared/tools/cpp-build-assistant/config.ts
new file mode 100644
index 000000000..f160b8cc4
--- /dev/null
+++ b/dbal/shared/tools/cpp-build-assistant/config.ts
@@ -0,0 +1,20 @@
+import path from 'path'
+
+export type BuildType = 'Debug' | 'Release'
+
+export interface CppBuildAssistantConfig {
+ projectRoot: string
+ cppDir: string
+ buildDir: string
+}
+
+export const createCppBuildAssistantConfig = (projectRoot?: string): CppBuildAssistantConfig => {
+ const resolvedProjectRoot = projectRoot || path.join(__dirname, '..')
+ const cppDir = path.join(resolvedProjectRoot, 'cpp')
+
+ return {
+ projectRoot: resolvedProjectRoot,
+ cppDir,
+ buildDir: path.join(cppDir, 'build'),
+ }
+}
diff --git a/dbal/shared/tools/cpp-build-assistant/index.ts b/dbal/shared/tools/cpp-build-assistant/index.ts
index 6810db714..a4f7500b8 100644
--- a/dbal/shared/tools/cpp-build-assistant/index.ts
+++ b/dbal/shared/tools/cpp-build-assistant/index.ts
@@ -1,18 +1,27 @@
import os from 'os'
import path from 'path'
+import { CppBuildAssistantConfig, BuildType, createCppBuildAssistantConfig } from './config'
import { COLORS, log } from './logging'
import { checkDependencies } from './dependencies'
import { cleanBuild, configureCMake, ensureConanFile, execCommand, installConanDeps, buildTarget, runTests } from './workflow'
export class CppBuildAssistant {
- private projectRoot: string
- private cppDir: string
- private buildDir: string
+ private config: CppBuildAssistantConfig
- constructor(projectRoot?: string) {
- this.projectRoot = projectRoot || path.join(__dirname, '..')
- this.cppDir = path.join(this.projectRoot, 'cpp')
- this.buildDir = path.join(this.cppDir, 'build')
+ constructor(config?: CppBuildAssistantConfig) {
+ this.config = config || createCppBuildAssistantConfig()
+ }
+
+ get projectRoot(): string {
+ return this.config.projectRoot
+ }
+
+ get cppDir(): string {
+ return this.config.cppDir
+ }
+
+ get buildDir(): string {
+ return this.config.buildDir
}
checkDependencies(): boolean {
@@ -27,7 +36,7 @@ export class CppBuildAssistant {
return installConanDeps(this.cppDir, execCommand)
}
- configureCMake(buildType: 'Debug' | 'Release' = 'Release'): boolean {
+ configureCMake(buildType: BuildType = 'Release'): boolean {
return configureCMake(this.cppDir, buildType, execCommand)
}
@@ -42,88 +51,11 @@ export class CppBuildAssistant {
clean(): boolean {
return cleanBuild(this.buildDir)
}
-
- async run(args: string[]): Promise {
- const command = args[0] || 'help'
- const options = args.slice(1)
-
- const buildType = options.includes('--debug') ? 'Debug' : 'Release'
- const jobsArg = options.find(option => option.startsWith('--jobs='))
- const jobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : os.cpus().length
-
- switch (command) {
- case 'check':
- return this.checkDependencies()
- case 'init':
- return this.createConanfile()
- case 'install':
- if (!this.checkDependencies()) return false
- return this.installConanDeps()
- case 'configure':
- if (!this.checkDependencies()) return false
- return this.configureCMake(buildType as 'Debug' | 'Release')
- case 'build':
- if (!this.checkDependencies()) return false
- const target = options.find(option => !option.startsWith('--')) || 'all'
- return this.build(target, jobs)
- case 'test':
- return this.test()
- case 'clean':
- return this.clean()
- case 'rebuild':
- this.clean()
- if (!this.checkDependencies()) return false
- if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false
- return this.build('all', jobs)
- case 'full':
- log.section('Full Build Workflow')
- if (!this.checkDependencies()) return false
- if (!this.createConanfile()) return false
- if (!this.installConanDeps()) return false
- if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false
- return this.build('all', jobs)
- case 'help':
- default:
- this.showHelp()
- return true
- }
- }
-
- private showHelp(): void {
- console.log(`
-${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper
-
-${COLORS.cyan}USAGE:${COLORS.reset}
- npm run cpp:build [command] [options]
-
-${COLORS.cyan}COMMANDS:${COLORS.reset}
- ${COLORS.green}check${COLORS.reset} Check if all dependencies are installed
- ${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing)
- ${COLORS.green}install${COLORS.reset} Install Conan dependencies
- ${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator
- ${COLORS.green}build${COLORS.reset} [target] Build the project (default: all)
- ${COLORS.green}test${COLORS.reset} Run tests with CTest
- ${COLORS.green}clean${COLORS.reset} Remove build artifacts
- ${COLORS.green}rebuild${COLORS.reset} Clean and rebuild
- ${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build
- ${COLORS.green}help${COLORS.reset} Show this help message
-
-${COLORS.cyan}OPTIONS:${COLORS.reset}
- --debug Use Debug build type
- --release Use Release build type (default)
- --jobs=N Number of parallel build jobs (default: CPU count)
-
-${COLORS.cyan}EXAMPLES:${COLORS.reset}
- npm run cpp:build check
- npm run cpp:build full
- npm run cpp:build build dbal_daemon
- npm run cpp:build build -- --debug
- npm run cpp:build test
-`)
- }
}
-export const runCppBuildAssistant = async (args: string[], projectRoot?: string) => {
- const assistant = new CppBuildAssistant(projectRoot || path.join(__dirname, '..'))
- return assistant.run(args)
+export const createAssistant = (projectRoot?: string): CppBuildAssistant => {
+ const config = createCppBuildAssistantConfig(projectRoot || path.join(__dirname, '..'))
+ return new CppBuildAssistant(config)
}
+
+export { BuildType, CppBuildAssistantConfig, COLORS, log }
diff --git a/dbal/shared/tools/cpp-build-assistant/runner.ts b/dbal/shared/tools/cpp-build-assistant/runner.ts
new file mode 100644
index 000000000..5d84827d2
--- /dev/null
+++ b/dbal/shared/tools/cpp-build-assistant/runner.ts
@@ -0,0 +1,10 @@
+import { CppBuildAssistant } from './index'
+import { createCppBuildAssistantConfig } from './config'
+import { runCli } from './cli'
+
+export const runCppBuildAssistant = async (args: string[], projectRoot?: string): Promise => {
+ const config = createCppBuildAssistantConfig(projectRoot)
+ const assistant = new CppBuildAssistant(config)
+
+ return runCli(args, assistant)
+}
From fcd0e55125c2612a9c132185e14d67720cdce5d7 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:36:16 +0000
Subject: [PATCH 09/80] refactor: modularize ACL adapter strategies
---
dbal/development/src/adapters/acl-adapter.ts | 4 +-
.../src/adapters/acl-adapter/acl-adapter.ts | 86 +++++++++++++++++
.../src/adapters/acl-adapter/context.ts | 12 +--
.../src/adapters/acl-adapter/guards.ts | 2 +-
.../src/adapters/acl-adapter/index.ts | 95 +------------------
.../src/adapters/acl-adapter/read-strategy.ts | 48 ++++++++++
.../src/adapters/acl-adapter/types.ts | 27 ++++++
.../adapters/acl-adapter/write-strategy.ts | 83 ++++++++++++++++
.../src/adapters/acl/audit-logger.ts | 2 +-
.../src/adapters/acl/check-permission.ts | 2 +-
.../adapters/acl/check-row-level-access.ts | 2 +-
.../src/adapters/acl/default-rules.ts | 2 +-
12 files changed, 256 insertions(+), 109 deletions(-)
create mode 100644 dbal/development/src/adapters/acl-adapter/acl-adapter.ts
create mode 100644 dbal/development/src/adapters/acl-adapter/read-strategy.ts
create mode 100644 dbal/development/src/adapters/acl-adapter/types.ts
create mode 100644 dbal/development/src/adapters/acl-adapter/write-strategy.ts
diff --git a/dbal/development/src/adapters/acl-adapter.ts b/dbal/development/src/adapters/acl-adapter.ts
index a5bfa02e5..9fbcd7d83 100644
--- a/dbal/development/src/adapters/acl-adapter.ts
+++ b/dbal/development/src/adapters/acl-adapter.ts
@@ -1,3 +1,3 @@
-export { ACLAdapter } from './acl-adapter/index'
-export type { User, ACLRule } from './acl/types'
+export { ACLAdapter } from './acl-adapter'
+export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './acl-adapter/types'
export { defaultACLRules } from './acl/default-rules'
diff --git a/dbal/development/src/adapters/acl-adapter/acl-adapter.ts b/dbal/development/src/adapters/acl-adapter/acl-adapter.ts
new file mode 100644
index 000000000..9d2492451
--- /dev/null
+++ b/dbal/development/src/adapters/acl-adapter/acl-adapter.ts
@@ -0,0 +1,86 @@
+import type { AdapterCapabilities, DBALAdapter } from '../adapter'
+import type { ListOptions, ListResult } from '../../core/foundation/types'
+import { createContext } from './context'
+import { createReadStrategy } from './read-strategy'
+import { createWriteStrategy } from './write-strategy'
+import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
+
+export class ACLAdapter implements DBALAdapter {
+ private readonly context: ACLContext
+ private readonly readStrategy: ReturnType
+ private readonly writeStrategy: ReturnType
+
+ constructor(baseAdapter: DBALAdapter, user: User, options?: ACLAdapterOptions) {
+ this.context = createContext(baseAdapter, user, options)
+ this.readStrategy = createReadStrategy(this.context)
+ this.writeStrategy = createWriteStrategy(this.context)
+ }
+
+ async create(entity: string, data: Record): Promise {
+ return this.writeStrategy.create(entity, data)
+ }
+
+ async read(entity: string, id: string): Promise {
+ return this.readStrategy.read(entity, id)
+ }
+
+ async update(entity: string, id: string, data: Record): Promise {
+ return this.writeStrategy.update(entity, id, data)
+ }
+
+ async delete(entity: string, id: string): Promise {
+ return this.writeStrategy.delete(entity, id)
+ }
+
+ async list(entity: string, options?: ListOptions): Promise> {
+ return this.readStrategy.list(entity, options)
+ }
+
+ async findFirst(entity: string, filter?: Record): Promise {
+ return this.readStrategy.findFirst(entity, filter)
+ }
+
+ async findByField(entity: string, field: string, value: unknown): Promise {
+ return this.readStrategy.findByField(entity, field, value)
+ }
+
+ async upsert(
+ entity: string,
+ filter: Record,
+ createData: Record,
+ updateData: Record,
+ ): Promise {
+ return this.writeStrategy.upsert(entity, filter, createData, updateData)
+ }
+
+ async updateByField(entity: string, field: string, value: unknown, data: Record): Promise {
+ return this.writeStrategy.updateByField(entity, field, value, data)
+ }
+
+ async deleteByField(entity: string, field: string, value: unknown): Promise {
+ return this.writeStrategy.deleteByField(entity, field, value)
+ }
+
+ async createMany(entity: string, data: Record[]): Promise {
+ return this.writeStrategy.createMany(entity, data)
+ }
+
+ async updateMany(entity: string, filter: Record, data: Record): Promise {
+ return this.writeStrategy.updateMany(entity, filter, data)
+ }
+
+ async deleteMany(entity: string, filter?: Record): Promise {
+ return this.writeStrategy.deleteMany(entity, filter)
+ }
+
+ async getCapabilities(): Promise {
+ return this.context.baseAdapter.getCapabilities()
+ }
+
+ async close(): Promise {
+ await this.context.baseAdapter.close()
+ }
+}
+
+export type { ACLAdapterOptions, ACLContext, ACLRule, User }
+export { defaultACLRules } from '../acl/default-rules'
diff --git a/dbal/development/src/adapters/acl-adapter/context.ts b/dbal/development/src/adapters/acl-adapter/context.ts
index 9262dd64d..8213926b9 100644
--- a/dbal/development/src/adapters/acl-adapter/context.ts
+++ b/dbal/development/src/adapters/acl-adapter/context.ts
@@ -1,20 +1,12 @@
import type { DBALAdapter } from '../adapter'
-import type { User, ACLRule } from '../acl/types'
+import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
import { logAudit } from '../acl/audit-logger'
import { defaultACLRules } from '../acl/default-rules'
-export interface ACLContext {
- baseAdapter: DBALAdapter
- user: User
- rules: ACLRule[]
- auditLog: boolean
- logger: (entity: string, operation: string, success: boolean, message?: string) => void
-}
-
export const createContext = (
baseAdapter: DBALAdapter,
user: User,
- options?: { rules?: ACLRule[]; auditLog?: boolean },
+ options?: ACLAdapterOptions,
): ACLContext => {
const auditLog = options?.auditLog ?? true
const rules = options?.rules || defaultACLRules
diff --git a/dbal/development/src/adapters/acl-adapter/guards.ts b/dbal/development/src/adapters/acl-adapter/guards.ts
index 8da05a011..be5171354 100644
--- a/dbal/development/src/adapters/acl-adapter/guards.ts
+++ b/dbal/development/src/adapters/acl-adapter/guards.ts
@@ -1,7 +1,7 @@
import { checkPermission } from '../acl/check-permission'
import { checkRowLevelAccess } from '../acl/check-row-level-access'
import { resolvePermissionOperation } from '../acl/resolve-permission-operation'
-import type { ACLContext } from './context'
+import type { ACLContext } from './types'
export const enforcePermission = (context: ACLContext, entity: string, operation: string) => {
checkPermission(entity, operation, context.user, context.rules, context.logger)
diff --git a/dbal/development/src/adapters/acl-adapter/index.ts b/dbal/development/src/adapters/acl-adapter/index.ts
index 354fe2a58..b356927a7 100644
--- a/dbal/development/src/adapters/acl-adapter/index.ts
+++ b/dbal/development/src/adapters/acl-adapter/index.ts
@@ -1,92 +1,3 @@
-import type { AdapterCapabilities, DBALAdapter } from '../adapter'
-import type { ListOptions, ListResult } from '../../core/foundation/types'
-import type { User, ACLRule } from '../acl/types'
-import type { ACLContext } from './context'
-import { createContext } from './context'
-import { createEntity, deleteEntity, listEntities, readEntity, updateEntity } from './crud'
-import {
- createMany,
- deleteByField,
- deleteMany,
- findByField,
- findFirst,
- updateByField,
- updateMany,
- upsert,
-} from './bulk'
-
-export class ACLAdapter implements DBALAdapter {
- private readonly context: ACLContext
-
- constructor(baseAdapter: DBALAdapter, user: User, options?: { rules?: ACLRule[]; auditLog?: boolean }) {
- this.context = createContext(baseAdapter, user, options)
- }
-
- async create(entity: string, data: Record): Promise {
- return createEntity(this.context)(entity, data)
- }
-
- async read(entity: string, id: string): Promise {
- return readEntity(this.context)(entity, id)
- }
-
- async update(entity: string, id: string, data: Record): Promise {
- return updateEntity(this.context)(entity, id, data)
- }
-
- async delete(entity: string, id: string): Promise {
- return deleteEntity(this.context)(entity, id)
- }
-
- async list(entity: string, options?: ListOptions): Promise> {
- return listEntities(this.context)(entity, options)
- }
-
- async findFirst(entity: string, filter?: Record): Promise {
- return findFirst(this.context)(entity, filter)
- }
-
- async findByField(entity: string, field: string, value: unknown): Promise {
- return findByField(this.context)(entity, field, value)
- }
-
- async upsert(
- entity: string,
- filter: Record,
- createData: Record,
- updateData: Record,
- ): Promise {
- return upsert(this.context)(entity, filter, createData, updateData)
- }
-
- async updateByField(entity: string, field: string, value: unknown, data: Record): Promise {
- return updateByField(this.context)(entity, field, value, data)
- }
-
- async deleteByField(entity: string, field: string, value: unknown): Promise {
- return deleteByField(this.context)(entity, field, value)
- }
-
- async createMany(entity: string, data: Record[]): Promise {
- return createMany(this.context)(entity, data)
- }
-
- async updateMany(entity: string, filter: Record, data: Record): Promise {
- return updateMany(this.context)(entity, filter, data)
- }
-
- async deleteMany(entity: string, filter?: Record): Promise {
- return deleteMany(this.context)(entity, filter)
- }
-
- async getCapabilities(): Promise {
- return this.context.baseAdapter.getCapabilities()
- }
-
- async close(): Promise {
- await this.context.baseAdapter.close()
- }
-}
-
-export type { User, ACLRule } from './acl/types'
-export { defaultACLRules } from './acl/default-rules'
+export { ACLAdapter } from './acl-adapter'
+export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
+export { defaultACLRules } from '../acl/default-rules'
diff --git a/dbal/development/src/adapters/acl-adapter/read-strategy.ts b/dbal/development/src/adapters/acl-adapter/read-strategy.ts
new file mode 100644
index 000000000..da2742e26
--- /dev/null
+++ b/dbal/development/src/adapters/acl-adapter/read-strategy.ts
@@ -0,0 +1,48 @@
+import type { ListOptions, ListResult } from '../../core/foundation/types'
+import { enforceRowAccess, resolveOperation, withAudit } from './guards'
+import type { ACLContext } from './types'
+
+export const createReadStrategy = (context: ACLContext) => {
+ const read = async (entity: string, id: string): Promise => {
+ return withAudit(context, entity, 'read', async () => {
+ const result = await context.baseAdapter.read(entity, id)
+ if (result) {
+ enforceRowAccess(context, entity, 'read', result as Record)
+ }
+ return result
+ })
+ }
+
+ const list = async (entity: string, options?: ListOptions): Promise> => {
+ return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options))
+ }
+
+ const findFirst = async (entity: string, filter?: Record): Promise => {
+ const operation = resolveOperation('findFirst')
+ return withAudit(context, entity, operation, async () => {
+ const result = await context.baseAdapter.findFirst(entity, filter)
+ if (result) {
+ enforceRowAccess(context, entity, operation, result as Record)
+ }
+ return result
+ })
+ }
+
+ const findByField = async (entity: string, field: string, value: unknown): Promise => {
+ const operation = resolveOperation('findByField')
+ return withAudit(context, entity, operation, async () => {
+ const result = await context.baseAdapter.findByField(entity, field, value)
+ if (result) {
+ enforceRowAccess(context, entity, operation, result as Record)
+ }
+ return result
+ })
+ }
+
+ return {
+ read,
+ list,
+ findFirst,
+ findByField,
+ }
+}
diff --git a/dbal/development/src/adapters/acl-adapter/types.ts b/dbal/development/src/adapters/acl-adapter/types.ts
new file mode 100644
index 000000000..ea4cf1857
--- /dev/null
+++ b/dbal/development/src/adapters/acl-adapter/types.ts
@@ -0,0 +1,27 @@
+import type { DBALAdapter } from '../adapter'
+
+export interface User {
+ id: string
+ username: string
+ role: 'user' | 'admin' | 'god' | 'supergod'
+}
+
+export interface ACLRule {
+ entity: string
+ roles: string[]
+ operations: string[]
+ rowLevelFilter?: (user: User, data: Record) => boolean
+}
+
+export interface ACLAdapterOptions {
+ rules?: ACLRule[]
+ auditLog?: boolean
+}
+
+export interface ACLContext {
+ baseAdapter: DBALAdapter
+ user: User
+ rules: ACLRule[]
+ auditLog: boolean
+ logger: (entity: string, operation: string, success: boolean, message?: string) => void
+}
diff --git a/dbal/development/src/adapters/acl-adapter/write-strategy.ts b/dbal/development/src/adapters/acl-adapter/write-strategy.ts
new file mode 100644
index 000000000..cf8b6aff5
--- /dev/null
+++ b/dbal/development/src/adapters/acl-adapter/write-strategy.ts
@@ -0,0 +1,83 @@
+import { enforceRowAccess, resolveOperation, withAudit } from './guards'
+import type { ACLContext } from './types'
+
+export const createWriteStrategy = (context: ACLContext) => {
+ const create = async (entity: string, data: Record): Promise => {
+ return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data))
+ }
+
+ const update = async (entity: string, id: string, data: Record): Promise => {
+ return withAudit(context, entity, 'update', async () => {
+ const existing = await context.baseAdapter.read(entity, id)
+ if (existing) {
+ enforceRowAccess(context, entity, 'update', existing as Record)
+ }
+ return context.baseAdapter.update(entity, id, data)
+ })
+ }
+
+ const remove = async (entity: string, id: string): Promise => {
+ return withAudit(context, entity, 'delete', async () => {
+ const existing = await context.baseAdapter.read(entity, id)
+ if (existing) {
+ enforceRowAccess(context, entity, 'delete', existing as Record)
+ }
+ return context.baseAdapter.delete(entity, id)
+ })
+ }
+
+ const upsert = async (
+ entity: string,
+ filter: Record,
+ createData: Record,
+ updateData: Record,
+ ): Promise => {
+ return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData))
+ }
+
+ const updateByField = async (
+ entity: string,
+ field: string,
+ value: unknown,
+ data: Record,
+ ): Promise => {
+ const operation = resolveOperation('updateByField')
+ return withAudit(context, entity, operation, () => context.baseAdapter.updateByField(entity, field, value, data))
+ }
+
+ const deleteByField = async (entity: string, field: string, value: unknown): Promise => {
+ const operation = resolveOperation('deleteByField')
+ return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value))
+ }
+
+ const createMany = async (entity: string, data: Record[]): Promise => {
+ const operation = resolveOperation('createMany')
+ return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data))
+ }
+
+ const updateMany = async (
+ entity: string,
+ filter: Record,
+ data: Record,
+ ): Promise => {
+ const operation = resolveOperation('updateMany')
+ return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data))
+ }
+
+ const deleteMany = async (entity: string, filter?: Record): Promise => {
+ const operation = resolveOperation('deleteMany')
+ return withAudit(context, entity, operation, () => context.baseAdapter.deleteMany(entity, filter))
+ }
+
+ return {
+ create,
+ update,
+ delete: remove,
+ upsert,
+ updateByField,
+ deleteByField,
+ createMany,
+ updateMany,
+ deleteMany,
+ }
+}
diff --git a/dbal/development/src/adapters/acl/audit-logger.ts b/dbal/development/src/adapters/acl/audit-logger.ts
index f67a2736d..864c181e4 100644
--- a/dbal/development/src/adapters/acl/audit-logger.ts
+++ b/dbal/development/src/adapters/acl/audit-logger.ts
@@ -3,7 +3,7 @@
* @description Audit logging for ACL operations
*/
-import type { User } from './types'
+import type { User } from '../acl-adapter/types'
/**
* Log audit entry for ACL operation
diff --git a/dbal/development/src/adapters/acl/check-permission.ts b/dbal/development/src/adapters/acl/check-permission.ts
index 3f1fd4a1b..b27a7b12d 100644
--- a/dbal/development/src/adapters/acl/check-permission.ts
+++ b/dbal/development/src/adapters/acl/check-permission.ts
@@ -4,7 +4,7 @@
*/
import { DBALError } from '../../core/foundation/errors'
-import type { User, ACLRule } from './types'
+import type { ACLRule, User } from '../acl-adapter/types'
/**
* Check if user has permission to perform operation on entity
diff --git a/dbal/development/src/adapters/acl/check-row-level-access.ts b/dbal/development/src/adapters/acl/check-row-level-access.ts
index 3b3403205..70ea72fc7 100644
--- a/dbal/development/src/adapters/acl/check-row-level-access.ts
+++ b/dbal/development/src/adapters/acl/check-row-level-access.ts
@@ -4,7 +4,7 @@
*/
import { DBALError } from '../../core/foundation/errors'
-import type { User, ACLRule } from './types'
+import type { ACLRule, User } from '../acl-adapter/types'
/**
* Check row-level access for specific data
diff --git a/dbal/development/src/adapters/acl/default-rules.ts b/dbal/development/src/adapters/acl/default-rules.ts
index a5ff3f3b0..25a6b803e 100644
--- a/dbal/development/src/adapters/acl/default-rules.ts
+++ b/dbal/development/src/adapters/acl/default-rules.ts
@@ -3,7 +3,7 @@
* @description Default ACL rules for entities
*/
-import type { ACLRule } from './types'
+import type { ACLRule } from '../acl-adapter/types'
export const defaultACLRules: ACLRule[] = [
{
From bcac86fce9157503182b6f26a85d728fce7374c8 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:36:56 +0000
Subject: [PATCH 10/80] refactor: modularize memory storage helpers
---
.../providers/memory-storage/downloads.ts | 8 ++--
.../blob/providers/memory-storage/index.ts | 3 +-
.../providers/memory-storage/management.ts | 36 ++++++++--------
.../providers/memory-storage/serialization.ts | 43 +++++++++++++++++++
.../blob/providers/memory-storage/store.ts | 14 ------
.../blob/providers/memory-storage/uploads.ts | 43 ++++---------------
.../blob/providers/memory-storage/utils.ts | 18 ++++++++
7 files changed, 92 insertions(+), 73 deletions(-)
create mode 100644 dbal/development/src/blob/providers/memory-storage/serialization.ts
create mode 100644 dbal/development/src/blob/providers/memory-storage/utils.ts
diff --git a/dbal/development/src/blob/providers/memory-storage/downloads.ts b/dbal/development/src/blob/providers/memory-storage/downloads.ts
index eb82fac6a..6a722029f 100644
--- a/dbal/development/src/blob/providers/memory-storage/downloads.ts
+++ b/dbal/development/src/blob/providers/memory-storage/downloads.ts
@@ -1,17 +1,15 @@
import { DBALError } from '../../core/foundation/errors'
import type { DownloadOptions } from '../blob-storage'
import type { MemoryStore } from './store'
+import { getBlobOrThrow, normalizeKey } from './utils'
export const downloadBuffer = (
store: MemoryStore,
key: string,
options: DownloadOptions = {},
): Buffer => {
- const blob = store.get(key)
-
- if (!blob) {
- throw DBALError.notFound(`Blob not found: ${key}`)
- }
+ const normalizedKey = normalizeKey(key)
+ const blob = getBlobOrThrow(store, normalizedKey)
let data = blob.data
diff --git a/dbal/development/src/blob/providers/memory-storage/index.ts b/dbal/development/src/blob/providers/memory-storage/index.ts
index 769285344..b1ed68b18 100644
--- a/dbal/development/src/blob/providers/memory-storage/index.ts
+++ b/dbal/development/src/blob/providers/memory-storage/index.ts
@@ -10,6 +10,7 @@ import { createStore } from './store'
import { uploadBuffer, uploadFromStream } from './uploads'
import { downloadBuffer, downloadStream } from './downloads'
import { copyBlob, deleteBlob, getMetadata, listBlobs, getObjectCount, getTotalSize } from './management'
+import { normalizeKey } from './utils'
export class MemoryStorage implements BlobStorage {
private store = createStore()
@@ -43,7 +44,7 @@ export class MemoryStorage implements BlobStorage {
}
async exists(key: string): Promise {
- return this.store.has(key)
+ return this.store.has(normalizeKey(key))
}
async getMetadata(key: string): Promise {
diff --git a/dbal/development/src/blob/providers/memory-storage/management.ts b/dbal/development/src/blob/providers/memory-storage/management.ts
index 8d2ad4f8e..afff2801e 100644
--- a/dbal/development/src/blob/providers/memory-storage/management.ts
+++ b/dbal/development/src/blob/providers/memory-storage/management.ts
@@ -1,29 +1,29 @@
import { DBALError } from '../../core/foundation/errors'
import type { BlobListOptions, BlobListResult, BlobMetadata } from '../blob-storage'
-import { makeBlobMetadata } from './store'
import type { MemoryStore } from './store'
+import { toBlobMetadata } from './serialization'
+import { cleanupStoreEntry, getBlobOrThrow, normalizeKey } from './utils'
export const deleteBlob = async (store: MemoryStore, key: string): Promise => {
- if (!store.has(key)) {
- throw DBALError.notFound(`Blob not found: ${key}`)
+ const normalizedKey = normalizeKey(key)
+
+ if (!store.has(normalizedKey)) {
+ throw DBALError.notFound(`Blob not found: ${normalizedKey}`)
}
- store.delete(key)
+ cleanupStoreEntry(store, normalizedKey)
return true
}
export const getMetadata = (store: MemoryStore, key: string): BlobMetadata => {
- const blob = store.get(key)
+ const normalizedKey = normalizeKey(key)
+ const blob = getBlobOrThrow(store, normalizedKey)
- if (!blob) {
- throw DBALError.notFound(`Blob not found: ${key}`)
- }
-
- return makeBlobMetadata(key, blob)
+ return toBlobMetadata(normalizedKey, blob)
}
export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): BlobListResult => {
- const prefix = options.prefix || ''
+ const prefix = options.prefix ? normalizeKey(options.prefix) : ''
const maxKeys = options.maxKeys || 1000
const items: BlobMetadata[] = []
@@ -35,7 +35,7 @@ export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): Bl
nextToken = key
break
}
- items.push(makeBlobMetadata(key, blob))
+ items.push(toBlobMetadata(key, blob))
}
}
@@ -47,11 +47,9 @@ export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): Bl
}
export const copyBlob = (store: MemoryStore, sourceKey: string, destKey: string): BlobMetadata => {
- const sourceBlob = store.get(sourceKey)
-
- if (!sourceBlob) {
- throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
- }
+ const normalizedSourceKey = normalizeKey(sourceKey)
+ const normalizedDestKey = normalizeKey(destKey)
+ const sourceBlob = getBlobOrThrow(store, normalizedSourceKey)
const destBlob = {
...sourceBlob,
@@ -59,8 +57,8 @@ export const copyBlob = (store: MemoryStore, sourceKey: string, destKey: string)
lastModified: new Date(),
}
- store.set(destKey, destBlob)
- return makeBlobMetadata(destKey, destBlob)
+ store.set(normalizedDestKey, destBlob)
+ return toBlobMetadata(normalizedDestKey, destBlob)
}
export const getTotalSize = (store: MemoryStore): number => {
diff --git a/dbal/development/src/blob/providers/memory-storage/serialization.ts b/dbal/development/src/blob/providers/memory-storage/serialization.ts
new file mode 100644
index 000000000..9b9bcc52c
--- /dev/null
+++ b/dbal/development/src/blob/providers/memory-storage/serialization.ts
@@ -0,0 +1,43 @@
+import { createHash } from 'crypto'
+import type { UploadOptions, BlobMetadata } from '../blob-storage'
+import type { BlobData } from './store'
+
+export const generateEtag = (data: Buffer): string => `"${createHash('md5').update(data).digest('hex')}"`
+
+export const toBlobData = (data: Buffer, options: UploadOptions = {}): BlobData => ({
+ data,
+ contentType: options.contentType || 'application/octet-stream',
+ etag: generateEtag(data),
+ lastModified: new Date(),
+ metadata: options.metadata || {},
+})
+
+export const toBlobMetadata = (key: string, blob: BlobData): BlobMetadata => ({
+ key,
+ size: blob.data.length,
+ contentType: blob.contentType,
+ etag: blob.etag,
+ lastModified: blob.lastModified,
+ customMetadata: blob.metadata,
+})
+
+export const collectStream = async (
+ stream: ReadableStream | NodeJS.ReadableStream,
+): Promise => {
+ const chunks: Buffer[] = []
+
+ if ('getReader' in stream) {
+ const reader = stream.getReader()
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ chunks.push(Buffer.from(value))
+ }
+ } else {
+ for await (const chunk of stream) {
+ chunks.push(Buffer.from(chunk))
+ }
+ }
+
+ return Buffer.concat(chunks)
+}
diff --git a/dbal/development/src/blob/providers/memory-storage/store.ts b/dbal/development/src/blob/providers/memory-storage/store.ts
index d574a84d6..383249c93 100644
--- a/dbal/development/src/blob/providers/memory-storage/store.ts
+++ b/dbal/development/src/blob/providers/memory-storage/store.ts
@@ -1,6 +1,3 @@
-import type { BlobMetadata } from '../blob-storage'
-import { createHash } from 'crypto'
-
export interface BlobData {
data: Buffer
contentType: string
@@ -12,14 +9,3 @@ export interface BlobData {
export type MemoryStore = Map
export const createStore = (): MemoryStore => new Map()
-
-export const generateEtag = (data: Buffer): string => `"${createHash('md5').update(data).digest('hex')}"`
-
-export const makeBlobMetadata = (key: string, blob: BlobData): BlobMetadata => ({
- key,
- size: blob.data.length,
- contentType: blob.contentType,
- etag: blob.etag,
- lastModified: blob.lastModified,
- customMetadata: blob.metadata,
-})
diff --git a/dbal/development/src/blob/providers/memory-storage/uploads.ts b/dbal/development/src/blob/providers/memory-storage/uploads.ts
index f282dc67f..356e37e85 100644
--- a/dbal/development/src/blob/providers/memory-storage/uploads.ts
+++ b/dbal/development/src/blob/providers/memory-storage/uploads.ts
@@ -1,7 +1,8 @@
import { DBALError } from '../../core/foundation/errors'
import type { UploadOptions } from '../blob-storage'
-import type { BlobData, MemoryStore } from './store'
-import { generateEtag, makeBlobMetadata } from './store'
+import type { MemoryStore } from './store'
+import { collectStream, toBlobData, toBlobMetadata } from './serialization'
+import { normalizeKey } from './utils'
export const uploadBuffer = (
store: MemoryStore,
@@ -9,43 +10,17 @@ export const uploadBuffer = (
data: Buffer | Uint8Array,
options: UploadOptions = {},
) => {
+ const normalizedKey = normalizeKey(key)
const buffer = Buffer.from(data)
- if (!options.overwrite && store.has(key)) {
- throw DBALError.conflict(`Blob already exists: ${key}`)
+ if (!options.overwrite && store.has(normalizedKey)) {
+ throw DBALError.conflict(`Blob already exists: ${normalizedKey}`)
}
- const blob: BlobData = {
- data: buffer,
- contentType: options.contentType || 'application/octet-stream',
- etag: generateEtag(buffer),
- lastModified: new Date(),
- metadata: options.metadata || {},
- }
+ const blob = toBlobData(buffer, options)
- store.set(key, blob)
- return makeBlobMetadata(key, blob)
-}
-
-export const collectStream = async (
- stream: ReadableStream | NodeJS.ReadableStream,
-): Promise => {
- const chunks: Buffer[] = []
-
- if ('getReader' in stream) {
- const reader = stream.getReader()
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- chunks.push(Buffer.from(value))
- }
- } else {
- for await (const chunk of stream) {
- chunks.push(Buffer.from(chunk))
- }
- }
-
- return Buffer.concat(chunks)
+ store.set(normalizedKey, blob)
+ return toBlobMetadata(normalizedKey, blob)
}
export const uploadFromStream = async (
diff --git a/dbal/development/src/blob/providers/memory-storage/utils.ts b/dbal/development/src/blob/providers/memory-storage/utils.ts
new file mode 100644
index 000000000..51e2a8618
--- /dev/null
+++ b/dbal/development/src/blob/providers/memory-storage/utils.ts
@@ -0,0 +1,18 @@
+import { DBALError } from '../../core/foundation/errors'
+import type { BlobData, MemoryStore } from './store'
+
+export const normalizeKey = (key: string): string => key.replace(/^\/+/, '').trim()
+
+export const getBlobOrThrow = (store: MemoryStore, key: string): BlobData => {
+ const blob = store.get(key)
+
+ if (!blob) {
+ throw DBALError.notFound(`Blob not found: ${key}`)
+ }
+
+ return blob
+}
+
+export const cleanupStoreEntry = (store: MemoryStore, key: string): void => {
+ store.delete(key)
+}
From b10bef82a9e461b6480615b3e2680266b86754c9 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:38:04 +0000
Subject: [PATCH 11/80] refactor: harden websocket bridge lifecycle
---
.../websocket-bridge/connection-manager.ts | 90 +++++++++++++++++++
.../bridges/websocket-bridge/connection.ts | 28 ------
.../src/bridges/websocket-bridge/index.ts | 10 ++-
.../websocket-bridge/message-handler.ts | 25 ------
.../websocket-bridge/message-router.ts | 68 ++++++++++++++
.../bridges/websocket-bridge/operations.ts | 35 ++++----
.../src/bridges/websocket-bridge/rpc.ts | 25 +++---
7 files changed, 199 insertions(+), 82 deletions(-)
create mode 100644 dbal/development/src/bridges/websocket-bridge/connection-manager.ts
delete mode 100644 dbal/development/src/bridges/websocket-bridge/connection.ts
delete mode 100644 dbal/development/src/bridges/websocket-bridge/message-handler.ts
create mode 100644 dbal/development/src/bridges/websocket-bridge/message-router.ts
diff --git a/dbal/development/src/bridges/websocket-bridge/connection-manager.ts b/dbal/development/src/bridges/websocket-bridge/connection-manager.ts
new file mode 100644
index 000000000..2e39d36a4
--- /dev/null
+++ b/dbal/development/src/bridges/websocket-bridge/connection-manager.ts
@@ -0,0 +1,90 @@
+import { DBALError } from '../../core/foundation/errors'
+import type { RPCMessage } from '../utils/rpc-types'
+import type { BridgeState } from './state'
+import type { MessageRouter } from './message-router'
+
+export interface ConnectionManager {
+ ensureConnection: () => Promise
+ send: (message: RPCMessage) => Promise
+ close: () => Promise
+}
+
+export const createConnectionManager = (
+ state: BridgeState,
+ messageRouter: MessageRouter,
+): ConnectionManager => {
+ let connectionPromise: Promise | null = null
+
+ const resetConnection = () => {
+ connectionPromise = null
+ state.ws = null
+ }
+
+ const rejectPendingRequests = (error: DBALError) => {
+ state.pendingRequests.forEach(({ reject }) => reject(error))
+ state.pendingRequests.clear()
+ }
+
+ const ensureConnection = async (): Promise => {
+ if (state.ws?.readyState === WebSocket.OPEN) {
+ return
+ }
+
+ if (connectionPromise) {
+ return connectionPromise
+ }
+
+ connectionPromise = new Promise((resolve, reject) => {
+ try {
+ const ws = new WebSocket(state.endpoint)
+ state.ws = ws
+
+ ws.onopen = () => resolve()
+ ws.onerror = error => {
+ const connectionError = DBALError.internal(`WebSocket connection failed: ${error}`)
+ rejectPendingRequests(connectionError)
+ resetConnection()
+ reject(connectionError)
+ }
+ ws.onclose = () => {
+ rejectPendingRequests(DBALError.internal('WebSocket connection closed'))
+ resetConnection()
+ }
+ ws.onmessage = event => messageRouter.handle(event.data)
+ } catch (error) {
+ resetConnection()
+ const connectionError =
+ error instanceof DBALError ? error : DBALError.internal('Failed to establish WebSocket connection')
+ reject(connectionError)
+ }
+ })
+
+ return connectionPromise
+ }
+
+ const send = async (message: RPCMessage): Promise => {
+ await ensureConnection()
+
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
+ throw DBALError.internal('WebSocket connection not open')
+ }
+
+ state.ws.send(JSON.stringify(message))
+ }
+
+ const close = async (): Promise => {
+ rejectPendingRequests(DBALError.internal('WebSocket connection closed'))
+
+ if (state.ws) {
+ state.ws.close()
+ }
+
+ resetConnection()
+ }
+
+ return {
+ ensureConnection,
+ send,
+ close,
+ }
+}
diff --git a/dbal/development/src/bridges/websocket-bridge/connection.ts b/dbal/development/src/bridges/websocket-bridge/connection.ts
deleted file mode 100644
index 9f348f18a..000000000
--- a/dbal/development/src/bridges/websocket-bridge/connection.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { DBALError } from '../../core/foundation/errors'
-import { handleMessage } from './message-handler'
-import type { BridgeState } from './state'
-
-export const connect = async (state: BridgeState): Promise => {
- if (state.ws?.readyState === WebSocket.OPEN) {
- return
- }
-
- return new Promise((resolve, reject) => {
- state.ws = new WebSocket(state.endpoint)
-
- state.ws.onopen = () => resolve()
- state.ws.onerror = error => reject(DBALError.internal(`WebSocket connection failed: ${error}`))
- state.ws.onmessage = event => handleMessage(state, event.data)
- state.ws.onclose = () => {
- state.ws = null
- }
- })
-}
-
-export const closeConnection = async (state: BridgeState): Promise => {
- if (state.ws) {
- state.ws.close()
- state.ws = null
- }
- state.pendingRequests.clear()
-}
diff --git a/dbal/development/src/bridges/websocket-bridge/index.ts b/dbal/development/src/bridges/websocket-bridge/index.ts
index f4ecedcdd..b6f27cbad 100644
--- a/dbal/development/src/bridges/websocket-bridge/index.ts
+++ b/dbal/development/src/bridges/websocket-bridge/index.ts
@@ -1,16 +1,20 @@
import type { DBALAdapter, AdapterCapabilities } from '../../adapters/adapter'
import type { ListOptions, ListResult } from '../../core/types'
-import { closeConnection } from './connection'
+import { createConnectionManager } from './connection-manager'
+import { createMessageRouter } from './message-router'
import { createOperations } from './operations'
import { createBridgeState } from './state'
export class WebSocketBridge implements DBALAdapter {
private readonly state: ReturnType
+ private readonly connectionManager: ReturnType
private readonly operations: ReturnType
constructor(endpoint: string, auth?: { user: unknown; session: unknown }) {
this.state = createBridgeState(endpoint, auth)
- this.operations = createOperations(this.state)
+ const messageRouter = createMessageRouter(this.state)
+ this.connectionManager = createConnectionManager(this.state, messageRouter)
+ this.operations = createOperations(this.state, this.connectionManager)
}
create(entity: string, data: Record): Promise {
@@ -75,6 +79,6 @@ export class WebSocketBridge implements DBALAdapter {
}
async close(): Promise {
- await closeConnection(this.state)
+ await this.connectionManager.close()
}
}
diff --git a/dbal/development/src/bridges/websocket-bridge/message-handler.ts b/dbal/development/src/bridges/websocket-bridge/message-handler.ts
deleted file mode 100644
index 78db23362..000000000
--- a/dbal/development/src/bridges/websocket-bridge/message-handler.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { RPCResponse } from '../utils/rpc-types'
-import type { BridgeState } from './state'
-import { DBALError } from '../../core/foundation/errors'
-
-export const handleMessage = (state: BridgeState, data: string): void => {
- try {
- const response: RPCResponse = JSON.parse(data)
- const pending = state.pendingRequests.get(response.id)
-
- if (!pending) {
- return
- }
-
- state.pendingRequests.delete(response.id)
-
- if (response.error) {
- const error = new DBALError(response.error.message, response.error.code, response.error.details)
- pending.reject(error)
- } else {
- pending.resolve(response.result)
- }
- } catch (error) {
- console.error('Failed to parse WebSocket message:', error)
- }
-}
diff --git a/dbal/development/src/bridges/websocket-bridge/message-router.ts b/dbal/development/src/bridges/websocket-bridge/message-router.ts
new file mode 100644
index 000000000..0603f2a2a
--- /dev/null
+++ b/dbal/development/src/bridges/websocket-bridge/message-router.ts
@@ -0,0 +1,68 @@
+import { DBALError } from '../../core/foundation/errors'
+import type { RPCResponse } from '../utils/rpc-types'
+import type { BridgeState } from './state'
+
+export interface MessageRouter {
+ handle: (rawMessage: unknown) => void
+}
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+
+const isRPCError = (value: unknown): value is NonNullable =>
+ isRecord(value) &&
+ typeof value.code === 'number' &&
+ typeof value.message === 'string' &&
+ (value.details === undefined || isRecord(value.details))
+
+const isRPCResponse = (value: unknown): value is RPCResponse => {
+ if (!isRecord(value)) {
+ return false
+ }
+
+ const hasId = typeof value.id === 'string'
+ const hasResult = Object.prototype.hasOwnProperty.call(value, 'result')
+ const hasError = isRPCError(value.error) || value.error === undefined
+
+ return hasId && (hasResult || isRPCError(value.error)) && hasError
+}
+
+const parseResponse = (rawMessage: string): RPCResponse => {
+ const parsed = JSON.parse(rawMessage) as unknown
+
+ if (!isRPCResponse(parsed)) {
+ throw new Error('Invalid RPC response shape')
+ }
+
+ return parsed
+}
+
+export const createMessageRouter = (state: BridgeState): MessageRouter => ({
+ handle: (rawMessage: unknown) => {
+ if (typeof rawMessage !== 'string') {
+ console.warn('Ignoring non-string WebSocket message')
+ return
+ }
+
+ try {
+ const response = parseResponse(rawMessage)
+ const pending = state.pendingRequests.get(response.id)
+
+ if (!pending) {
+ console.warn(`No pending request for response ${response.id}`)
+ return
+ }
+
+ state.pendingRequests.delete(response.id)
+
+ if (response.error) {
+ const error = new DBALError(response.error.message, response.error.code, response.error.details)
+ pending.reject(error)
+ } else {
+ pending.resolve(response.result)
+ }
+ } catch (error) {
+ console.error('Failed to process WebSocket message', error)
+ }
+ },
+})
diff --git a/dbal/development/src/bridges/websocket-bridge/operations.ts b/dbal/development/src/bridges/websocket-bridge/operations.ts
index 05c9a866b..8519082fe 100644
--- a/dbal/development/src/bridges/websocket-bridge/operations.ts
+++ b/dbal/development/src/bridges/websocket-bridge/operations.ts
@@ -1,31 +1,36 @@
import type { AdapterCapabilities } from '../../adapters/adapter'
import type { ListOptions, ListResult } from '../../core/types'
+import type { ConnectionManager } from './connection-manager'
import type { BridgeState } from './state'
import { rpcCall } from './rpc'
-export const createOperations = (state: BridgeState) => ({
- create: (entity: string, data: Record) => rpcCall(state, 'create', entity, data),
- read: (entity: string, id: string) => rpcCall(state, 'read', entity, id),
- update: (entity: string, id: string, data: Record) => rpcCall(state, 'update', entity, id, data),
- delete: (entity: string, id: string) => rpcCall(state, 'delete', entity, id) as Promise,
- list: (entity: string, options?: ListOptions) => rpcCall(state, 'list', entity, options) as Promise>,
- findFirst: (entity: string, filter?: Record) => rpcCall(state, 'findFirst', entity, filter),
- findByField: (entity: string, field: string, value: unknown) => rpcCall(state, 'findByField', entity, field, value),
+export const createOperations = (state: BridgeState, connectionManager: ConnectionManager) => ({
+ create: (entity: string, data: Record) => rpcCall(state, connectionManager, 'create', entity, data),
+ read: (entity: string, id: string) => rpcCall(state, connectionManager, 'read', entity, id),
+ update: (entity: string, id: string, data: Record) =>
+ rpcCall(state, connectionManager, 'update', entity, id, data),
+ delete: (entity: string, id: string) => rpcCall(state, connectionManager, 'delete', entity, id) as Promise,
+ list: (entity: string, options?: ListOptions) =>
+ rpcCall(state, connectionManager, 'list', entity, options) as Promise>,
+ findFirst: (entity: string, filter?: Record) =>
+ rpcCall(state, connectionManager, 'findFirst', entity, filter),
+ findByField: (entity: string, field: string, value: unknown) =>
+ rpcCall(state, connectionManager, 'findByField', entity, field, value),
upsert: (
entity: string,
filter: Record,
createData: Record,
updateData: Record,
- ) => rpcCall(state, 'upsert', entity, filter, createData, updateData),
+ ) => rpcCall(state, connectionManager, 'upsert', entity, filter, createData, updateData),
updateByField: (entity: string, field: string, value: unknown, data: Record) =>
- rpcCall(state, 'updateByField', entity, field, value, data),
+ rpcCall(state, connectionManager, 'updateByField', entity, field, value, data),
deleteByField: (entity: string, field: string, value: unknown) =>
- rpcCall(state, 'deleteByField', entity, field, value) as Promise,
+ rpcCall(state, connectionManager, 'deleteByField', entity, field, value) as Promise,
deleteMany: (entity: string, filter?: Record) =>
- rpcCall(state, 'deleteMany', entity, filter) as Promise,
+ rpcCall(state, connectionManager, 'deleteMany', entity, filter) as Promise,
createMany: (entity: string, data: Record[]) =>
- rpcCall(state, 'createMany', entity, data) as Promise,
+ rpcCall(state, connectionManager, 'createMany', entity, data) as Promise,
updateMany: (entity: string, filter: Record, data: Record) =>
- rpcCall(state, 'updateMany', entity, filter, data) as Promise,
- getCapabilities: () => rpcCall(state, 'getCapabilities') as Promise,
+ rpcCall(state, connectionManager, 'updateMany', entity, filter, data) as Promise,
+ getCapabilities: () => rpcCall(state, connectionManager, 'getCapabilities') as Promise,
})
diff --git a/dbal/development/src/bridges/websocket-bridge/rpc.ts b/dbal/development/src/bridges/websocket-bridge/rpc.ts
index 2462558b4..3de06a550 100644
--- a/dbal/development/src/bridges/websocket-bridge/rpc.ts
+++ b/dbal/development/src/bridges/websocket-bridge/rpc.ts
@@ -1,25 +1,28 @@
import { DBALError } from '../../core/foundation/errors'
import { generateRequestId } from '../utils/generate-request-id'
import type { RPCMessage } from '../utils/rpc-types'
-import { connect } from './connection'
+import type { ConnectionManager } from './connection-manager'
import type { BridgeState } from './state'
-export const rpcCall = async (state: BridgeState, method: string, ...params: unknown[]): Promise => {
- await connect(state)
-
+export const rpcCall = async (
+ state: BridgeState,
+ connectionManager: ConnectionManager,
+ method: string,
+ ...params: unknown[]
+): Promise => {
const id = generateRequestId()
const message: RPCMessage = { id, method, params }
return new Promise((resolve, reject) => {
state.pendingRequests.set(id, { resolve, reject })
- if (state.ws?.readyState === WebSocket.OPEN) {
- state.ws.send(JSON.stringify(message))
- } else {
- state.pendingRequests.delete(id)
- reject(DBALError.internal('WebSocket connection not open'))
- return
- }
+ connectionManager
+ .send(message)
+ .catch(error => {
+ state.pendingRequests.delete(id)
+ reject(error)
+ return
+ })
setTimeout(() => {
if (state.pendingRequests.has(id)) {
From 66f9d2cfe6fc74e98dca22003e6f0507113bf369 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:38:50 +0000
Subject: [PATCH 12/80] refactor: split user operations into separate modules
---
.../operations/core/user-operations.ts | 9 +++
.../entities/operations/core/user/create.ts | 20 +++++++
.../entities/operations/core/user/delete.ts | 13 +++++
.../entities/operations/core/user/index.ts | 4 +-
.../operations/core/user/mutations.ts | 57 -------------------
.../entities/operations/core/user/update.ts | 22 +++++++
.../operations/core/user/validation.ts | 24 ++++++++
7 files changed, 91 insertions(+), 58 deletions(-)
create mode 100644 dbal/development/src/core/entities/operations/core/user/create.ts
create mode 100644 dbal/development/src/core/entities/operations/core/user/delete.ts
delete mode 100644 dbal/development/src/core/entities/operations/core/user/mutations.ts
create mode 100644 dbal/development/src/core/entities/operations/core/user/update.ts
create mode 100644 dbal/development/src/core/entities/operations/core/user/validation.ts
diff --git a/dbal/development/src/core/entities/operations/core/user-operations.ts b/dbal/development/src/core/entities/operations/core/user-operations.ts
index d5e29f59c..5d1da503e 100644
--- a/dbal/development/src/core/entities/operations/core/user-operations.ts
+++ b/dbal/development/src/core/entities/operations/core/user-operations.ts
@@ -1,2 +1,11 @@
export { createUserOperations } from './user'
export type { UserOperations } from './user'
+
+export { createUser } from './user/create'
+export { deleteUser } from './user/delete'
+export { updateUser } from './user/update'
+export {
+ assertValidUserCreate,
+ assertValidUserId,
+ assertValidUserUpdate,
+} from './user/validation'
diff --git a/dbal/development/src/core/entities/operations/core/user/create.ts b/dbal/development/src/core/entities/operations/core/user/create.ts
new file mode 100644
index 000000000..4543fe4e2
--- /dev/null
+++ b/dbal/development/src/core/entities/operations/core/user/create.ts
@@ -0,0 +1,20 @@
+import type { DBALAdapter } from '../../../../adapters/adapter'
+import { DBALError } from '../../../../foundation/errors'
+import type { User } from '../../../../foundation/types'
+import { assertValidUserCreate } from './validation'
+
+export const createUser = async (
+ adapter: DBALAdapter,
+ data: Omit,
+): Promise => {
+ assertValidUserCreate(data)
+
+ try {
+ return adapter.create('User', data) as Promise
+ } catch (error) {
+ if (error instanceof DBALError && error.code === 409) {
+ throw DBALError.conflict('User with username or email already exists')
+ }
+ throw error
+ }
+}
diff --git a/dbal/development/src/core/entities/operations/core/user/delete.ts b/dbal/development/src/core/entities/operations/core/user/delete.ts
new file mode 100644
index 000000000..07484d1a6
--- /dev/null
+++ b/dbal/development/src/core/entities/operations/core/user/delete.ts
@@ -0,0 +1,13 @@
+import type { DBALAdapter } from '../../../../adapters/adapter'
+import { DBALError } from '../../../../foundation/errors'
+import { assertValidUserId } from './validation'
+
+export const deleteUser = async (adapter: DBALAdapter, id: string): Promise => {
+ assertValidUserId(id)
+
+ const result = await adapter.delete('User', id)
+ if (!result) {
+ throw DBALError.notFound(`User not found: ${id}`)
+ }
+ return result
+}
diff --git a/dbal/development/src/core/entities/operations/core/user/index.ts b/dbal/development/src/core/entities/operations/core/user/index.ts
index 200efa017..a5f410c72 100644
--- a/dbal/development/src/core/entities/operations/core/user/index.ts
+++ b/dbal/development/src/core/entities/operations/core/user/index.ts
@@ -1,6 +1,8 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { User, ListOptions, ListResult } from '../../../../foundation/types'
-import { createUser, deleteUser, updateUser } from './mutations'
+import { createUser } from './create'
+import { deleteUser } from './delete'
+import { updateUser } from './update'
import { createManyUsers, deleteManyUsers, updateManyUsers } from './batch'
import { listUsers, readUser } from './reads'
diff --git a/dbal/development/src/core/entities/operations/core/user/mutations.ts b/dbal/development/src/core/entities/operations/core/user/mutations.ts
deleted file mode 100644
index 8e80c7be8..000000000
--- a/dbal/development/src/core/entities/operations/core/user/mutations.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { DBALAdapter } from '../../../../adapters/adapter'
-import type { User } from '../../../../foundation/types'
-import { DBALError } from '../../../../foundation/errors'
-import { validateUserCreate, validateUserUpdate, validateId } from '../../../../foundation/validation'
-
-export const createUser = async (
- adapter: DBALAdapter,
- data: Omit,
-): Promise => {
- const validationErrors = validateUserCreate(data)
- if (validationErrors.length > 0) {
- throw DBALError.validationError('Invalid user data', validationErrors.map(error => ({ field: 'user', error })))
- }
-
- try {
- return adapter.create('User', data) as Promise
- } catch (error) {
- if (error instanceof DBALError && error.code === 409) {
- throw DBALError.conflict('User with username or email already exists')
- }
- throw error
- }
-}
-
-export const updateUser = async (adapter: DBALAdapter, id: string, data: Partial): Promise => {
- const idErrors = validateId(id)
- if (idErrors.length > 0) {
- throw DBALError.validationError('Invalid user ID', idErrors.map(error => ({ field: 'id', error })))
- }
-
- const validationErrors = validateUserUpdate(data)
- if (validationErrors.length > 0) {
- throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error })))
- }
-
- try {
- return adapter.update('User', id, data) as Promise
- } catch (error) {
- if (error instanceof DBALError && error.code === 409) {
- throw DBALError.conflict('Username or email already exists')
- }
- throw error
- }
-}
-
-export const deleteUser = async (adapter: DBALAdapter, id: string): Promise => {
- const validationErrors = validateId(id)
- if (validationErrors.length > 0) {
- throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error })))
- }
-
- const result = await adapter.delete('User', id)
- if (!result) {
- throw DBALError.notFound(`User not found: ${id}`)
- }
- return result
-}
diff --git a/dbal/development/src/core/entities/operations/core/user/update.ts b/dbal/development/src/core/entities/operations/core/user/update.ts
new file mode 100644
index 000000000..ca0ae185d
--- /dev/null
+++ b/dbal/development/src/core/entities/operations/core/user/update.ts
@@ -0,0 +1,22 @@
+import type { DBALAdapter } from '../../../../adapters/adapter'
+import { DBALError } from '../../../../foundation/errors'
+import type { User } from '../../../../foundation/types'
+import { assertValidUserId, assertValidUserUpdate } from './validation'
+
+export const updateUser = async (
+ adapter: DBALAdapter,
+ id: string,
+ data: Partial,
+): Promise => {
+ assertValidUserId(id)
+ assertValidUserUpdate(data)
+
+ try {
+ return adapter.update('User', id, data) as Promise
+ } catch (error) {
+ if (error instanceof DBALError && error.code === 409) {
+ throw DBALError.conflict('Username or email already exists')
+ }
+ throw error
+ }
+}
diff --git a/dbal/development/src/core/entities/operations/core/user/validation.ts b/dbal/development/src/core/entities/operations/core/user/validation.ts
new file mode 100644
index 000000000..0b57322d5
--- /dev/null
+++ b/dbal/development/src/core/entities/operations/core/user/validation.ts
@@ -0,0 +1,24 @@
+import { DBALError } from '../../../../foundation/errors'
+import type { User } from '../../../../foundation/types'
+import { validateId, validateUserCreate, validateUserUpdate } from '../../../../foundation/validation'
+
+export const assertValidUserId = (id: string): void => {
+ const validationErrors = validateId(id)
+ if (validationErrors.length > 0) {
+ throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error })))
+ }
+}
+
+export const assertValidUserCreate = (data: Omit): void => {
+ const validationErrors = validateUserCreate(data)
+ if (validationErrors.length > 0) {
+ throw DBALError.validationError('Invalid user data', validationErrors.map(error => ({ field: 'user', error })))
+ }
+}
+
+export const assertValidUserUpdate = (data: Partial): void => {
+ const validationErrors = validateUserUpdate(data)
+ if (validationErrors.length > 0) {
+ throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error })))
+ }
+}
From c0f1b5af14f248008debaa57ae585b033b3e85ed Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:39:27 +0000
Subject: [PATCH 13/80] feat: add package lifecycle operations
---
.../operations/system/package-operations.ts | 3 +--
.../entities/operations/system/package/index.ts | 13 +++++++++++++
.../entities/operations/system/package/publish.ts | 10 ++++++++++
.../entities/operations/system/package/unpublish.ts | 6 ++++++
.../entities/operations/system/package/validate.ts | 6 ++++++
5 files changed, 36 insertions(+), 2 deletions(-)
create mode 100644 dbal/development/src/core/entities/operations/system/package/publish.ts
create mode 100644 dbal/development/src/core/entities/operations/system/package/unpublish.ts
create mode 100644 dbal/development/src/core/entities/operations/system/package/validate.ts
diff --git a/dbal/development/src/core/entities/operations/system/package-operations.ts b/dbal/development/src/core/entities/operations/system/package-operations.ts
index 1f5d45352..886ac9b16 100644
--- a/dbal/development/src/core/entities/operations/system/package-operations.ts
+++ b/dbal/development/src/core/entities/operations/system/package-operations.ts
@@ -1,2 +1 @@
-export { createPackageOperations } from './package'
-export type { PackageOperations } from './package'
+export * from './package'
diff --git a/dbal/development/src/core/entities/operations/system/package/index.ts b/dbal/development/src/core/entities/operations/system/package/index.ts
index 7dce526f8..b70a4a145 100644
--- a/dbal/development/src/core/entities/operations/system/package/index.ts
+++ b/dbal/development/src/core/entities/operations/system/package/index.ts
@@ -2,9 +2,15 @@ import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package, ListOptions, ListResult } from '../../../../foundation/types'
import { createManyPackages, deleteManyPackages, updateManyPackages } from './batch'
import { createPackage, deletePackage, updatePackage } from './mutations'
+import { publishPackage } from './publish'
import { listPackages, readPackage } from './reads'
+import { unpublishPackage } from './unpublish'
+import { validatePackage } from './validate'
export interface PackageOperations {
+ validate: (data: Partial) => string[]
+ publish: (data: Omit) => Promise
+ unpublish: (id: string) => Promise
create: (data: Omit) => Promise
read: (id: string) => Promise
update: (id: string, data: Partial) => Promise
@@ -16,6 +22,9 @@ export interface PackageOperations {
}
export const createPackageOperations = (adapter: DBALAdapter): PackageOperations => ({
+ validate: data => validatePackage(data),
+ publish: data => publishPackage(adapter, data),
+ unpublish: id => unpublishPackage(adapter, id),
create: data => createPackage(adapter, data),
read: id => readPackage(adapter, id),
update: (id, data) => updatePackage(adapter, id, data),
@@ -25,3 +34,7 @@ export const createPackageOperations = (adapter: DBALAdapter): PackageOperations
updateMany: (filter, data) => updateManyPackages(adapter, filter, data),
deleteMany: filter => deleteManyPackages(adapter, filter),
})
+
+export { publishPackage } from './publish'
+export { unpublishPackage } from './unpublish'
+export { validatePackage } from './validate'
diff --git a/dbal/development/src/core/entities/operations/system/package/publish.ts b/dbal/development/src/core/entities/operations/system/package/publish.ts
new file mode 100644
index 000000000..f59f721ae
--- /dev/null
+++ b/dbal/development/src/core/entities/operations/system/package/publish.ts
@@ -0,0 +1,10 @@
+import type { DBALAdapter } from '../../../../adapters/adapter'
+import type { Package } from '../../../../foundation/types'
+import { createPackage } from './mutations'
+
+export const publishPackage = (
+ adapter: DBALAdapter,
+ data: Omit,
+): Promise => {
+ return createPackage(adapter, data)
+}
diff --git a/dbal/development/src/core/entities/operations/system/package/unpublish.ts b/dbal/development/src/core/entities/operations/system/package/unpublish.ts
new file mode 100644
index 000000000..27a5da97f
--- /dev/null
+++ b/dbal/development/src/core/entities/operations/system/package/unpublish.ts
@@ -0,0 +1,6 @@
+import type { DBALAdapter } from '../../../../adapters/adapter'
+import { deletePackage } from './mutations'
+
+export const unpublishPackage = (adapter: DBALAdapter, id: string): Promise => {
+ return deletePackage(adapter, id)
+}
diff --git a/dbal/development/src/core/entities/operations/system/package/validate.ts b/dbal/development/src/core/entities/operations/system/package/validate.ts
new file mode 100644
index 000000000..868033e9e
--- /dev/null
+++ b/dbal/development/src/core/entities/operations/system/package/validate.ts
@@ -0,0 +1,6 @@
+import type { Package } from '../../../../foundation/types'
+import { validatePackageCreate } from '../../../../foundation/validation'
+
+export const validatePackage = (data: Partial): string[] => {
+ return validatePackageCreate(data)
+}
From 97d461b66702b82552bda45942fc1c091d18558c Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:40:06 +0000
Subject: [PATCH 14/80] refactor: modularize codegen studio layout
---
.../src/app/codegen/CodegenStudioClient.tsx | 218 +++++-------------
.../src/app/codegen/components/Header.tsx | 21 ++
.../src/app/codegen/components/Sidebar.tsx | 51 ++++
.../src/app/codegen/hooks/useCodegenData.ts | 74 ++++++
4 files changed, 205 insertions(+), 159 deletions(-)
create mode 100644 frontends/nextjs/src/app/codegen/components/Header.tsx
create mode 100644 frontends/nextjs/src/app/codegen/components/Sidebar.tsx
create mode 100644 frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts
diff --git a/frontends/nextjs/src/app/codegen/CodegenStudioClient.tsx b/frontends/nextjs/src/app/codegen/CodegenStudioClient.tsx
index ee56df809..f67d800a4 100644
--- a/frontends/nextjs/src/app/codegen/CodegenStudioClient.tsx
+++ b/frontends/nextjs/src/app/codegen/CodegenStudioClient.tsx
@@ -1,6 +1,5 @@
'use client'
-import type { CodegenManifest } from '@/lib/codegen/codegen-types'
import { useMemo, useState, type ChangeEvent } from 'react'
import {
@@ -16,6 +15,10 @@ import {
Typography,
} from '@mui/material'
+import Header from './components/Header'
+import Sidebar from './components/Sidebar'
+import { useCodegenData, type CodegenRequest } from './hooks/useCodegenData'
+
const runtimeOptions = [
{ value: 'web', label: 'Next.js web' },
{ value: 'cli', label: 'Command line' },
@@ -24,7 +27,7 @@ const runtimeOptions = [
{ value: 'server', label: 'Server service' },
]
-const initialFormState = {
+const initialFormState: CodegenRequest = {
projectName: 'nebula-launch',
packageId: 'codegen_studio',
runtime: 'web',
@@ -32,51 +35,11 @@ const initialFormState = {
brief: 'Modern web interface with CLI companions',
}
-type FormState = (typeof initialFormState)
-
-type FetchStatus = 'idle' | 'loading' | 'success'
-
-const createFilename = (header: string | null, fallback: string) => {
- const match = header?.match(/filename="?([^"]+)"?/) ?? null
- return match ? match[1] : fallback
-}
-
-const downloadBlob = (blob: Blob, filename: string) => {
- const url = URL.createObjectURL(blob)
- const anchor = document.createElement('a')
- anchor.href = url
- anchor.download = filename
- document.body.appendChild(anchor)
- anchor.click()
- anchor.remove()
- URL.revokeObjectURL(url)
-}
-
-const fetchZip = async (values: FormState) => {
- const response = await fetch('/api/codegen/studio', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(values),
- })
- if (!response.ok) {
- throw new Error('Codegen Studio service returned an error')
- }
- const blob = await response.blob()
- const filename = createFilename(response.headers.get('content-disposition'), `${values.projectName}.zip`)
- downloadBlob(blob, filename)
- const manifestHeader = response.headers.get('x-codegen-manifest')
- const manifest = manifestHeader
- ? (JSON.parse(decodeURIComponent(manifestHeader)) as CodegenManifest)
- : null
- return { filename, manifest }
-}
+type FormState = typeof initialFormState
export default function CodegenStudioClient() {
const [form, setForm] = useState(initialFormState)
- const [status, setStatus] = useState('idle')
- const [message, setMessage] = useState(null)
- const [error, setError] = useState(null)
- const [manifest, setManifest] = useState(null)
+ const { status, message, error, manifest, generate } = useCodegenData()
const runtimeDescription = useMemo(() => {
switch (form.runtime) {
@@ -112,125 +75,62 @@ export default function CodegenStudioClient() {
setForm((prev) => ({ ...prev, [key]: event.target.value }))
}
- const handleSubmit = async () => {
- setStatus('loading')
- setError(null)
- setMessage(null)
- try {
- const { filename, manifest } = await fetchZip(form)
- setMessage(`Zip ${filename} created successfully.`)
- setManifest(manifest)
- setStatus('success')
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Unable to generate the zip')
- setManifest(null)
- setStatus('idle')
- }
- }
+ const handleSubmit = () => generate(form)
return (
-
-
-
-
- Codegen Studio Export
-
-
- Configure a starter bundle for MetaBuilder packages and download it instantly.
-
-
-
-
-
+
+
+
-
-
- {runtimeOptions.map((option) => (
-
- {option.label}
-
- ))}
-
-
- {runtimeDescription}
-
-
-
-
- : null}
- >
- {status === 'loading' ? 'Generating...' : 'Generate ZIP'}
-
-
-
- {message && {message} }
- {error && {error} }
- {manifest && (
-
-
- Manifest preview
+
+
+
+
+
+ {runtimeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ {runtimeDescription}
-
-
- Project: {manifest.projectName}
-
-
- Package: {manifest.packageId}
-
-
- Runtime: {manifest.runtime}
-
-
- Tone: {manifest.tone ?? 'adaptive'}
-
-
- Generated at: {new Date(manifest.generatedAt).toLocaleString()}
-
-
-
- )}
-
- Bundle contents
- {previewFiles.map((entry) => (
-
- • {entry}
-
- ))}
+
+
+
+ : null}
+ >
+ {status === 'loading' ? 'Generating...' : 'Generate ZIP'}
+
+
+
+ {message && {message} }
+ {error && {error} }
+
+
+
+
diff --git a/frontends/nextjs/src/app/codegen/components/Header.tsx b/frontends/nextjs/src/app/codegen/components/Header.tsx
new file mode 100644
index 000000000..29dcd934f
--- /dev/null
+++ b/frontends/nextjs/src/app/codegen/components/Header.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { Stack, Typography } from '@mui/material'
+
+interface HeaderProps {
+ title: string
+ subtitle: string
+}
+
+export default function Header({ title, subtitle }: HeaderProps) {
+ return (
+
+
+ {title}
+
+
+ {subtitle}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/app/codegen/components/Sidebar.tsx b/frontends/nextjs/src/app/codegen/components/Sidebar.tsx
new file mode 100644
index 000000000..27172ca38
--- /dev/null
+++ b/frontends/nextjs/src/app/codegen/components/Sidebar.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import type { CodegenManifest } from '@/lib/codegen/codegen-types'
+import { Paper, Stack, Typography } from '@mui/material'
+
+interface SidebarProps {
+ manifest: CodegenManifest | null
+ previewFiles: string[]
+}
+
+export default function Sidebar({ manifest, previewFiles }: SidebarProps) {
+ return (
+
+ {manifest && (
+
+
+ Manifest preview
+
+
+
+ Project: {manifest.projectName}
+
+
+ Package: {manifest.packageId}
+
+
+ Runtime: {manifest.runtime}
+
+
+ Tone: {manifest.tone ?? 'adaptive'}
+
+
+ Generated at: {new Date(manifest.generatedAt).toLocaleString()}
+
+
+
+ )}
+
+ Bundle contents
+ {previewFiles.map((entry) => (
+
+ • {entry}
+
+ ))}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts b/frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts
new file mode 100644
index 000000000..61d831c7c
--- /dev/null
+++ b/frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts
@@ -0,0 +1,74 @@
+'use client'
+
+import type { CodegenManifest } from '@/lib/codegen/codegen-types'
+import { useCallback, useState } from 'react'
+
+export type CodegenRequest = {
+ projectName: string
+ packageId: string
+ runtime: string
+ tone: string
+ brief: string
+}
+
+export type FetchStatus = 'idle' | 'loading' | 'success'
+
+const createFilename = (header: string | null, fallback: string) => {
+ const match = header?.match(/filename="?([^"]+)"?/) ?? null
+ return match ? match[1] : fallback
+}
+
+const downloadBlob = (blob: Blob, filename: string) => {
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = filename
+ document.body.appendChild(anchor)
+ anchor.click()
+ anchor.remove()
+ URL.revokeObjectURL(url)
+}
+
+const fetchZip = async (values: CodegenRequest) => {
+ const response = await fetch('/api/codegen/studio', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(values),
+ })
+ if (!response.ok) {
+ throw new Error('Codegen Studio service returned an error')
+ }
+ const blob = await response.blob()
+ const filename = createFilename(response.headers.get('content-disposition'), `${values.projectName}.zip`)
+ downloadBlob(blob, filename)
+ const manifestHeader = response.headers.get('x-codegen-manifest')
+ const manifest = manifestHeader
+ ? (JSON.parse(decodeURIComponent(manifestHeader)) as CodegenManifest)
+ : null
+ return { filename, manifest }
+}
+
+export function useCodegenData() {
+ const [status, setStatus] = useState('idle')
+ const [message, setMessage] = useState(null)
+ const [error, setError] = useState(null)
+ const [manifest, setManifest] = useState(null)
+
+ const generate = useCallback(async (values: CodegenRequest) => {
+ setStatus('loading')
+ setError(null)
+ setMessage(null)
+ try {
+ const { filename, manifest: manifestResult } = await fetchZip(values)
+ setMessage(`Zip ${filename} created successfully.`)
+ setManifest(manifestResult)
+ setStatus('success')
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unable to generate the zip')
+ setManifest(null)
+ setStatus('idle')
+ }
+ }, [])
+
+ return { status, message, error, manifest, generate }
+}
From 7c061b43ca37ca5edc39b3ee24719f79b358fe84 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:40:43 +0000
Subject: [PATCH 15/80] refactor: modularize lua blocks editor
---
.../components/editors/lua/BlockListView.tsx | 95 +++++
.../components/editors/lua/CodePreview.tsx | 73 ++++
.../editors/lua/LuaBlocksEditor.tsx | 353 ++++++------------
.../lua/hooks/useLuaBlockEditorState.ts | 26 ++
4 files changed, 314 insertions(+), 233 deletions(-)
create mode 100644 frontends/nextjs/src/components/editors/lua/BlockListView.tsx
create mode 100644 frontends/nextjs/src/components/editors/lua/CodePreview.tsx
create mode 100644 frontends/nextjs/src/components/editors/lua/hooks/useLuaBlockEditorState.ts
diff --git a/frontends/nextjs/src/components/editors/lua/BlockListView.tsx b/frontends/nextjs/src/components/editors/lua/BlockListView.tsx
new file mode 100644
index 000000000..9176fba4e
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/BlockListView.tsx
@@ -0,0 +1,95 @@
+import type { MouseEvent } from 'react'
+import { Box, Button, Card, CardContent, CardHeader, Stack, TextField, Typography } from '@mui/material'
+import { Add as AddIcon } from '@mui/icons-material'
+import type { LuaScript } from '@/lib/level-types'
+import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from './types'
+import { BlockList } from './blocks/BlockList'
+import styles from './LuaBlocksEditor.module.scss'
+
+interface BlockListViewProps {
+ activeBlocks: LuaBlock[]
+ blockDefinitionMap: Map
+ onRequestAddBlock: (
+ event: MouseEvent,
+ target: { parentId: string | null; slot: BlockSlot }
+ ) => void
+ onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
+ onDuplicateBlock: (blockId: string) => void
+ onRemoveBlock: (blockId: string) => void
+ onUpdateField: (blockId: string, fieldName: string, value: string) => void
+ onUpdateScript: (updates: Partial) => void
+ selectedScript: LuaScript | null
+}
+
+export function BlockListView({
+ activeBlocks,
+ blockDefinitionMap,
+ onRequestAddBlock,
+ onMoveBlock,
+ onDuplicateBlock,
+ onRemoveBlock,
+ onUpdateField,
+ onUpdateScript,
+ selectedScript,
+}: BlockListViewProps) {
+ return (
+
+ }
+ onClick={(event) => onRequestAddBlock(event, { parentId: null, slot: 'root' })}
+ disabled={!selectedScript}
+ >
+ Add block
+
+ }
+ />
+
+ {!selectedScript ? (
+
+ Select a script to start building blocks.
+
+ ) : (
+
+
+ onUpdateScript({ name: event.target.value })}
+ fullWidth
+ />
+ onUpdateScript({ description: event.target.value })}
+ fullWidth
+ />
+
+
+ {activeBlocks.length > 0 ? (
+
+ ) : (
+ Add a block to start building Lua logic.
+ )}
+
+
+ Blocks are saved in the script as metadata, so you can reload them later.
+
+
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/editors/lua/CodePreview.tsx b/frontends/nextjs/src/components/editors/lua/CodePreview.tsx
new file mode 100644
index 000000000..c83746fdb
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/CodePreview.tsx
@@ -0,0 +1,73 @@
+import { Box, Button, Card, CardContent, CardHeader, Stack, Tooltip } from '@mui/material'
+import { ContentCopy, Refresh as RefreshIcon, Save as SaveIcon } from '@mui/icons-material'
+import type { LuaScript } from '@/lib/level-types'
+import styles from './LuaBlocksEditor.module.scss'
+
+interface CodePreviewProps {
+ generatedCode: string
+ onApplyCode: () => void
+ onCopyCode: () => void
+ onReloadFromCode: () => void
+ selectedScript: LuaScript | null
+}
+
+export function CodePreview({
+ generatedCode,
+ onApplyCode,
+ onCopyCode,
+ onReloadFromCode,
+ selectedScript,
+}: CodePreviewProps) {
+ return (
+
+
+
+
+ }
+ onClick={onReloadFromCode}
+ disabled={!selectedScript}
+ >
+ Reload
+
+
+
+
+
+ }
+ onClick={onCopyCode}
+ disabled={!selectedScript}
+ >
+ Copy
+
+
+
+ }
+ onClick={onApplyCode}
+ disabled={!selectedScript}
+ >
+ Apply to script
+
+
+ }
+ />
+
+
+ {generatedCode}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/editors/lua/LuaBlocksEditor.tsx b/frontends/nextjs/src/components/editors/lua/LuaBlocksEditor.tsx
index 212f840ba..58b2534f7 100644
--- a/frontends/nextjs/src/components/editors/lua/LuaBlocksEditor.tsx
+++ b/frontends/nextjs/src/components/editors/lua/LuaBlocksEditor.tsx
@@ -5,27 +5,21 @@ import {
CardContent,
CardHeader,
Divider,
+ IconButton,
List,
ListItemButton,
ListItemText,
Paper,
Stack,
- TextField,
Tooltip,
Typography,
} from '@mui/material'
-import {
- Add as AddIcon,
- ContentCopy,
- Delete as DeleteIcon,
- Refresh as RefreshIcon,
- Save as SaveIcon,
-} from '@mui/icons-material'
+import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'
import type { LuaScript } from '@/lib/level-types'
-import { BlockList } from './blocks/BlockList'
import { BlockMenu } from './blocks/BlockMenu'
-import { useBlockDefinitions } from './hooks/useBlockDefinitions'
-import { useLuaBlocksState } from './hooks/useLuaBlocksState'
+import { BlockListView } from './BlockListView'
+import { CodePreview } from './CodePreview'
+import { useLuaBlockEditorState } from './hooks/useLuaBlockEditorState'
import styles from './LuaBlocksEditor.module.scss'
interface LuaBlocksEditorProps {
@@ -34,18 +28,11 @@ interface LuaBlocksEditorProps {
}
export function LuaBlocksEditor({ scripts, onScriptsChange }: LuaBlocksEditorProps) {
- const {
- blockDefinitions,
- blockDefinitionMap,
- blocksByCategory,
- createBlock,
- cloneBlock,
- buildLuaFromBlocks,
- decodeBlocksMetadata,
- } = useBlockDefinitions()
-
const {
activeBlocks,
+ blockDefinitionMap,
+ blockDefinitions,
+ blocksByCategory,
generatedCode,
handleAddBlock,
handleAddScript,
@@ -64,173 +51,7 @@ export function LuaBlocksEditor({ scripts, onScriptsChange }: LuaBlocksEditorPro
selectedScript,
selectedScriptId,
setSelectedScriptId,
- } = useLuaBlocksState({
- scripts,
- onScriptsChange,
- buildLuaFromBlocks,
- createBlock,
- cloneBlock,
- decodeBlocksMetadata,
- })
-
- const renderBlockLibrary = () => (
-
-
-
-
- {Object.entries(blocksByCategory).map(([category, blocks]) => (
-
-
- {category}
-
-
- {blocks.map((block) => (
- handleAddBlock(block.type, { parentId: null, slot: 'root' })}
- >
-
-
- {block.label}
- {block.description}
-
- {
- event.stopPropagation()
- handleAddBlock(block.type, { parentId: null, slot: 'root' })
- }}
- >
- Add
-
-
-
- ))}
-
-
- ))}
-
-
-
- )
-
- const renderWorkspace = () => (
-
- }
- onClick={(event) => handleRequestAddBlock(event, { parentId: null, slot: 'root' })}
- disabled={!selectedScript}
- >
- Add block
-
- }
- />
-
- {!selectedScript ? (
-
- Select a script to start building blocks.
-
- ) : (
-
-
- handleUpdateScript({ name: event.target.value })}
- fullWidth
- />
- handleUpdateScript({ description: event.target.value })}
- fullWidth
- />
-
-
- {activeBlocks.length > 0 ? (
-
- ) : (
- Add a block to start building Lua logic.
- )}
-
-
- Blocks are saved in the script as metadata, so you can reload them later.
-
-
- )}
-
-
- )
-
- const renderScriptList = () => (
-
-
-
-
- } onClick={handleAddScript}>
- New block script
-
-
-
- {scripts.length === 0 && (
-
- No scripts yet. Create a block script to begin.
-
- )}
- {scripts.map((script) => (
- setSelectedScriptId(script.id)}
- sx={{
- borderRadius: 2,
- mb: 1,
- alignItems: 'flex-start',
- }}
- >
-
-
- {
- event.stopPropagation()
- handleDeleteScript(script.id)
- }}
- >
-
-
-
-
- ))}
-
-
-
-
- )
+ } = useLuaBlockEditorState({ scripts, onScriptsChange })
return (
@@ -242,55 +63,121 @@ export function LuaBlocksEditor({ scripts, onScriptsChange }: LuaBlocksEditorPro
}}
>
- {renderScriptList()}
- {renderBlockLibrary()}
+
+
+
+
+ } onClick={handleAddScript}>
+ New block script
+
+
+
+ {scripts.length === 0 && (
+
+ No scripts yet. Create a block script to begin.
+
+ )}
+ {scripts.map((script) => (
+ setSelectedScriptId(script.id)}
+ sx={{
+ borderRadius: 2,
+ mb: 1,
+ alignItems: 'flex-start',
+ }}
+ >
+
+
+ {
+ event.stopPropagation()
+ handleDeleteScript(script.id)
+ }}
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {Object.entries(blocksByCategory).map(([category, blocks]) => (
+
+
+ {category}
+
+
+ {blocks.map((block) => (
+ handleAddBlock(block.type, { parentId: null, slot: 'root' })}
+ >
+
+
+ {block.label}
+ {block.description}
+
+ {
+ event.stopPropagation()
+ handleAddBlock(block.type, { parentId: null, slot: 'root' })
+ }}
+ >
+ Add
+
+
+
+ ))}
+
+
+ ))}
+
+
+
- {renderWorkspace()}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- onClick={handleApplyCode}
- disabled={!selectedScript}
- >
- Apply to script
-
-
- }
- />
-
-
- {generatedCode}
-
-
-
+
diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlockEditorState.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlockEditorState.ts
new file mode 100644
index 000000000..618b5f491
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlockEditorState.ts
@@ -0,0 +1,26 @@
+import type { LuaScript } from '@/lib/level-types'
+import { useBlockDefinitions } from './useBlockDefinitions'
+import { useLuaBlocksState } from './useLuaBlocksState'
+
+interface UseLuaBlockEditorStateProps {
+ scripts: LuaScript[]
+ onScriptsChange: (scripts: LuaScript[]) => void
+}
+
+export function useLuaBlockEditorState({ scripts, onScriptsChange }: UseLuaBlockEditorStateProps) {
+ const blockDefinitionState = useBlockDefinitions()
+
+ const luaBlockState = useLuaBlocksState({
+ scripts,
+ onScriptsChange,
+ buildLuaFromBlocks: blockDefinitionState.buildLuaFromBlocks,
+ createBlock: blockDefinitionState.createBlock,
+ cloneBlock: blockDefinitionState.cloneBlock,
+ decodeBlocksMetadata: blockDefinitionState.decodeBlocksMetadata,
+ })
+
+ return {
+ ...blockDefinitionState,
+ ...luaBlockState,
+ }
+}
From e0c556c279128117ba7434d9a8033a45a53d8701 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:41:21 +0000
Subject: [PATCH 16/80] refactor: modularize lua snippet library
---
.../editors/lua/LuaSnippetLibrary.tsx | 278 +++---------------
.../lua/LuaSnippetLibrary/SearchBar.tsx | 44 +++
.../lua/LuaSnippetLibrary/SnippetDialog.tsx | 116 ++++++++
.../lua/LuaSnippetLibrary/SnippetList.tsx | 125 ++++++++
4 files changed, 328 insertions(+), 235 deletions(-)
create mode 100644 frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SearchBar.tsx
create mode 100644 frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetDialog.tsx
create mode 100644 frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetList.tsx
diff --git a/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary.tsx b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary.tsx
index 52290dcec..37b32710b 100644
--- a/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary.tsx
+++ b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary.tsx
@@ -1,34 +1,15 @@
-import { useState } from 'react'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Button } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
-import { Separator } from '@/components/ui'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui'
-import {
- MagnifyingGlass,
- Copy,
- Check,
- BookOpen,
- Tag,
- ArrowRight,
- Code
-} from '@phosphor-icons/react'
+import { useMemo, useState } from 'react'
+import { Tabs } from '@/components/ui'
+import { BookOpen } from '@phosphor-icons/react'
import { toast } from 'sonner'
-import {
- LUA_SNIPPET_CATEGORIES,
- getSnippetsByCategory,
+import {
+ getSnippetsByCategory,
searchSnippets,
- type LuaSnippet
+ type LuaSnippet,
} from '@/lib/lua-snippets'
+import { SearchBar } from './LuaSnippetLibrary/SearchBar'
+import { SnippetDialog } from './LuaSnippetLibrary/SnippetDialog'
+import { SnippetList } from './LuaSnippetLibrary/SnippetList'
interface LuaSnippetLibraryProps {
onInsertSnippet?: (code: string) => void
@@ -40,9 +21,11 @@ export function LuaSnippetLibrary({ onInsertSnippet }: LuaSnippetLibraryProps) {
const [selectedSnippet, setSelectedSnippet] = useState(null)
const [copiedId, setCopiedId] = useState(null)
- const displayedSnippets = searchQuery
- ? searchSnippets(searchQuery)
- : getSnippetsByCategory(selectedCategory)
+ const displayedSnippets = useMemo(
+ () =>
+ searchQuery ? searchSnippets(searchQuery) : getSnippetsByCategory(selectedCategory),
+ [searchQuery, selectedCategory]
+ )
const handleCopySnippet = (snippet: LuaSnippet) => {
navigator.clipboard.writeText(snippet.code)
@@ -72,214 +55,39 @@ export function LuaSnippetLibrary({ onInsertSnippet }: LuaSnippetLibraryProps) {
-
-
- setSearchQuery(e.target.value)}
- className="pl-10"
- />
-
-
-
-
- {LUA_SNIPPET_CATEGORIES.map((category) => (
-
- {category}
-
- ))}
-
-
+
- {LUA_SNIPPET_CATEGORIES.map((category) => (
-
-
- {displayedSnippets.length === 0 ? (
-
-
-
No snippets found
- {searchQuery && (
-
Try a different search term
- )}
-
- ) : (
- displayedSnippets.map((snippet) => (
-
setSelectedSnippet(snippet)}
- >
-
-
-
-
- {snippet.name}
-
-
- {snippet.description}
-
-
-
- {snippet.category}
-
-
-
-
-
- {snippet.tags.slice(0, 3).map((tag) => (
-
-
- {tag}
-
- ))}
- {snippet.tags.length > 3 && (
-
- +{snippet.tags.length - 3}
-
- )}
-
-
-
{
- e.stopPropagation()
- handleCopySnippet(snippet)
- }}
- >
- {copiedId === snippet.id ? (
- <>
-
- Copied
- >
- ) : (
- <>
-
- Copy
- >
- )}
-
- {onInsertSnippet && (
-
{
- e.stopPropagation()
- handleInsertSnippet(snippet)
- }}
- >
-
- Insert
-
- )}
-
-
-
- ))
- )}
-
-
- ))}
+
- setSelectedSnippet(null)}>
-
-
-
-
- {selectedSnippet?.name}
- {selectedSnippet?.description}
-
-
{selectedSnippet?.category}
-
-
-
-
- {selectedSnippet?.tags && selectedSnippet.tags.length > 0 && (
-
- {selectedSnippet.tags.map((tag) => (
-
-
- {tag}
-
- ))}
-
- )}
-
- {selectedSnippet?.parameters && selectedSnippet.parameters.length > 0 && (
-
-
-
- Parameters
-
-
- {selectedSnippet.parameters.map((param) => (
-
-
-
- {param.name}
-
-
- {param.type}
-
-
-
{param.description}
-
- ))}
-
-
- )}
-
-
-
-
-
Code
-
-
- {selectedSnippet?.code}
-
-
-
-
-
-
selectedSnippet && handleCopySnippet(selectedSnippet)}
- >
- {copiedId === selectedSnippet?.id ? (
- <>
-
- Copied to Clipboard
- >
- ) : (
- <>
-
- Copy to Clipboard
- >
- )}
-
- {onInsertSnippet && (
-
{
- if (selectedSnippet) {
- handleInsertSnippet(selectedSnippet)
- setSelectedSnippet(null)
- }
- }}
- >
-
- Insert into Editor
-
- )}
-
-
-
-
+ {
+ handleInsertSnippet(snippet)
+ setSelectedSnippet(null)
+ }
+ : undefined
+ }
+ onClose={() => setSelectedSnippet(null)}
+ />
)
}
diff --git a/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SearchBar.tsx b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SearchBar.tsx
new file mode 100644
index 000000000..5be89b841
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SearchBar.tsx
@@ -0,0 +1,44 @@
+import { MagnifyingGlass } from '@phosphor-icons/react'
+import { Input, ScrollArea, TabsList, TabsTrigger } from '@/components/ui'
+import { LUA_SNIPPET_CATEGORIES } from '@/lib/lua-snippets'
+
+interface SearchBarProps {
+ searchQuery: string
+ onSearchChange: (value: string) => void
+ selectedCategory: string
+ onCategoryChange: (category: string) => void
+}
+
+export function SearchBar({
+ searchQuery,
+ onSearchChange,
+ selectedCategory,
+ onCategoryChange,
+}: SearchBarProps) {
+ return (
+
+
+
+ onSearchChange(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+
+ {LUA_SNIPPET_CATEGORIES.map((category) => (
+
+ {category}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetDialog.tsx b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetDialog.tsx
new file mode 100644
index 000000000..0111ba403
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetDialog.tsx
@@ -0,0 +1,116 @@
+import {
+ Badge,
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ Separator,
+} from '@/components/ui'
+import { ArrowRight, Check, Code, Copy, Tag } from '@phosphor-icons/react'
+import { type LuaSnippet } from '@/lib/lua-snippets'
+
+interface SnippetDialogProps {
+ snippet: LuaSnippet | null
+ copiedId: string | null
+ onCopy: (snippet: LuaSnippet) => void
+ onInsert?: (snippet: LuaSnippet) => void
+ onClose: () => void
+}
+
+export function SnippetDialog({
+ snippet,
+ copiedId,
+ onCopy,
+ onInsert,
+ onClose,
+}: SnippetDialogProps) {
+ return (
+ !isOpen && onClose()}>
+
+
+
+
+ {snippet?.name}
+ {snippet?.description}
+
+
{snippet?.category}
+
+
+
+
+ {snippet?.tags && snippet.tags.length > 0 && (
+
+ {snippet.tags.map((tag) => (
+
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {snippet?.parameters && snippet.parameters.length > 0 && (
+
+
+
+ Parameters
+
+
+ {snippet.parameters.map((param) => (
+
+
+ {param.name}
+
+ {param.type}
+
+
+
{param.description}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
snippet && onCopy(snippet)}>
+ {copiedId === snippet?.id ? (
+ <>
+
+ Copied to Clipboard
+ >
+ ) : (
+ <>
+
+ Copy to Clipboard
+ >
+ )}
+
+ {onInsert && (
+
snippet && onInsert(snippet)}
+ >
+
+ Insert into Editor
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetList.tsx b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetList.tsx
new file mode 100644
index 000000000..8ddcf047d
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetList.tsx
@@ -0,0 +1,125 @@
+import {
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ TabsContent,
+} from '@/components/ui'
+import { ArrowRight, Check, Code, Copy, Tag } from '@phosphor-icons/react'
+import { LUA_SNIPPET_CATEGORIES, type LuaSnippet } from '@/lib/lua-snippets'
+
+interface SnippetListProps {
+ snippets: LuaSnippet[]
+ searchQuery: string
+ selectedCategory: string
+ onSelectSnippet: (snippet: LuaSnippet) => void
+ onCopySnippet: (snippet: LuaSnippet) => void
+ onInsertSnippet?: (snippet: LuaSnippet) => void
+ copiedId: string | null
+}
+
+export function SnippetList({
+ snippets,
+ searchQuery,
+ selectedCategory,
+ onSelectSnippet,
+ onCopySnippet,
+ onInsertSnippet,
+ copiedId,
+}: SnippetListProps) {
+ return (
+ <>
+ {LUA_SNIPPET_CATEGORIES.map((category) => (
+
+
+ {snippets.length === 0 ? (
+
+
+
No snippets found
+ {searchQuery &&
Try a different search term
}
+
+ ) : (
+ snippets.map((snippet) => (
+
onSelectSnippet(snippet)}
+ >
+
+
+
+
+ {snippet.name}
+
+
+ {snippet.description}
+
+
+
+ {snippet.category}
+
+
+
+
+
+ {snippet.tags.slice(0, 3).map((tag) => (
+
+
+ {tag}
+
+ ))}
+ {snippet.tags.length > 3 && (
+
+ +{snippet.tags.length - 3}
+
+ )}
+
+
+
{
+ e.stopPropagation()
+ onCopySnippet(snippet)
+ }}
+ >
+ {copiedId === snippet.id ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+ {onInsertSnippet && (
+
{
+ e.stopPropagation()
+ onInsertSnippet(snippet)
+ }}
+ >
+
+ Insert
+
+ )}
+
+
+
+ ))
+ )}
+
+
+ ))}
+ >
+ )
+}
From 4d8394acc0864d3e30e7d8e55a8ab95ee0f44708 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:42:14 +0000
Subject: [PATCH 17/80] refactor: extract lua block item and grouping helpers
---
.../editors/lua/blocks/BlockItem.tsx | 218 ++++++++++++++++++
.../editors/lua/blocks/BlockList.tsx | 201 +++-------------
.../components/editors/lua/blocks/grouping.ts | 20 ++
.../components/editors/lua/blocks/index.ts | 21 +-
4 files changed, 273 insertions(+), 187 deletions(-)
create mode 100644 frontends/nextjs/src/components/editors/lua/blocks/BlockItem.tsx
create mode 100644 frontends/nextjs/src/components/editors/lua/blocks/grouping.ts
diff --git a/frontends/nextjs/src/components/editors/lua/blocks/BlockItem.tsx b/frontends/nextjs/src/components/editors/lua/blocks/BlockItem.tsx
new file mode 100644
index 000000000..938a26451
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/blocks/BlockItem.tsx
@@ -0,0 +1,218 @@
+import type { MouseEvent } from 'react'
+import {
+ Box,
+ Button,
+ IconButton,
+ MenuItem,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import {
+ Add as AddIcon,
+ ArrowDownward,
+ ArrowUpward,
+ ContentCopy,
+ Delete as DeleteIcon,
+} from '@mui/icons-material'
+import type { BlockDefinition, BlockSlot, LuaBlock } from '../types'
+import styles from '../LuaBlocksEditor.module.scss'
+
+interface BlockItemProps {
+ block: LuaBlock
+ definition: BlockDefinition
+ index: number
+ total: number
+ onRequestAddBlock: (
+ event: MouseEvent,
+ target: { parentId: string | null; slot: BlockSlot }
+ ) => void
+ onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
+ onDuplicateBlock: (blockId: string) => void
+ onRemoveBlock: (blockId: string) => void
+ onUpdateField: (blockId: string, fieldName: string, value: string) => void
+ renderNestedList: (blocks?: LuaBlock[]) => JSX.Element
+}
+
+interface BlockSectionProps {
+ title: string
+ blocks: LuaBlock[] | undefined
+ parentId: string
+ slot: BlockSlot
+ onRequestAddBlock: (
+ event: MouseEvent,
+ target: { parentId: string | null; slot: BlockSlot }
+ ) => void
+ renderNestedList: (blocks?: LuaBlock[]) => JSX.Element
+}
+
+const BlockSection = ({
+ title,
+ blocks,
+ parentId,
+ slot,
+ onRequestAddBlock,
+ renderNestedList,
+}: BlockSectionProps) => (
+
+
+ {title}
+ onRequestAddBlock(event, { parentId, slot })}
+ startIcon={ }
+ >
+ Add block
+
+
+
+ {blocks && blocks.length > 0 ? (
+ renderNestedList(blocks)
+ ) : (
+ Drop blocks here to build this section.
+ )}
+
+
+)
+
+const BlockFields = ({
+ block,
+ definition,
+ onUpdateField,
+}: {
+ block: LuaBlock
+ definition: BlockDefinition
+ onUpdateField: (blockId: string, fieldName: string, value: string) => void
+}) => {
+ if (definition.fields.length === 0) return null
+
+ return (
+
+ {definition.fields.map((field) => (
+
+ {field.label}
+ {field.type === 'select' ? (
+ onUpdateField(block.id, field.name, event.target.value)}
+ fullWidth
+ variant="outlined"
+ InputProps={{
+ sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
+ }}
+ >
+ {field.options?.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ ) : (
+ onUpdateField(block.id, field.name, event.target.value)}
+ placeholder={field.placeholder}
+ fullWidth
+ variant="outlined"
+ type={field.type === 'number' ? 'number' : 'text'}
+ InputProps={{
+ sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
+ }}
+ />
+ )}
+
+ ))}
+
+ )
+}
+
+export const BlockItem = ({
+ block,
+ definition,
+ index,
+ total,
+ onRequestAddBlock,
+ onMoveBlock,
+ onDuplicateBlock,
+ onRemoveBlock,
+ onUpdateField,
+ renderNestedList,
+}: BlockItemProps) => (
+
+
+ {definition.label}
+
+
+
+ onMoveBlock(block.id, 'up')}
+ disabled={index === 0}
+ sx={{ color: 'rgba(255,255,255,0.85)' }}
+ >
+
+
+
+
+
+
+ onMoveBlock(block.id, 'down')}
+ disabled={index === total - 1}
+ sx={{ color: 'rgba(255,255,255,0.85)' }}
+ >
+
+
+
+
+
+ onDuplicateBlock(block.id)}
+ sx={{ color: 'rgba(255,255,255,0.85)' }}
+ >
+
+
+
+
+ onRemoveBlock(block.id)}
+ sx={{ color: 'rgba(255,255,255,0.85)' }}
+ >
+
+
+
+
+
+
+
+
+ {definition.hasChildren && (
+
+ )}
+
+ {definition.hasElseChildren && (
+
+ )}
+
+)
diff --git a/frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx b/frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
index afe08e023..1c4c3052b 100644
--- a/frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
+++ b/frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
@@ -1,22 +1,8 @@
import type { MouseEvent } from 'react'
-import {
- Box,
- Button,
- IconButton,
- MenuItem,
- TextField,
- Tooltip,
- Typography,
-} from '@mui/material'
-import {
- Add as AddIcon,
- ArrowDownward,
- ArrowUpward,
- ContentCopy,
- Delete as DeleteIcon,
-} from '@mui/icons-material'
+import { Box } from '@mui/material'
import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from '../types'
import styles from '../LuaBlocksEditor.module.scss'
+import { BlockItem } from './BlockItem'
interface BlockListProps {
blocks: LuaBlock[]
@@ -31,89 +17,6 @@ interface BlockListProps {
onUpdateField: (blockId: string, fieldName: string, value: string) => void
}
-const renderBlockFields = (
- block: LuaBlock,
- definition: BlockDefinition,
- onUpdateField: (blockId: string, fieldName: string, value: string) => void
-) => {
- if (definition.fields.length === 0) return null
-
- return (
-
- {definition.fields.map((field) => (
-
- {field.label}
- {field.type === 'select' ? (
- onUpdateField(block.id, field.name, event.target.value)}
- fullWidth
- variant="outlined"
- InputProps={{
- sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
- }}
- >
- {field.options?.map((option) => (
-
- {option.label}
-
- ))}
-
- ) : (
- onUpdateField(block.id, field.name, event.target.value)}
- placeholder={field.placeholder}
- fullWidth
- variant="outlined"
- type={field.type === 'number' ? 'number' : 'text'}
- InputProps={{
- sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
- }}
- />
- )}
-
- ))}
-
- )
-}
-
-const renderBlockSection = (
- title: string,
- blocks: LuaBlock[] | undefined,
- parentId: string | null,
- slot: BlockSlot,
- onRequestAddBlock: (
- event: MouseEvent,
- target: { parentId: string | null; slot: BlockSlot }
- ) => void,
- renderBlockCard: (block: LuaBlock, index: number, total: number) => JSX.Element | null
-) => (
-
-
- {title}
- onRequestAddBlock(event, { parentId, slot })}
- startIcon={ }
- >
- Add block
-
-
-
- {blocks && blocks.length > 0 ? (
- blocks.map((child, index) => renderBlockCard(child, index, blocks.length))
- ) : (
- Drop blocks here to build this section.
- )}
-
-
-)
-
export const BlockList = ({
blocks,
blockDefinitionMap,
@@ -123,78 +26,40 @@ export const BlockList = ({
onRemoveBlock,
onUpdateField,
}: BlockListProps) => {
- const renderBlockCard = (block: LuaBlock, index: number, total: number) => {
- const definition = blockDefinitionMap.get(block.type)
- if (!definition) return null
-
- return (
-
-
- {definition.label}
-
-
-
- onMoveBlock(block.id, 'up')}
- disabled={index === 0}
- sx={{ color: 'rgba(255,255,255,0.85)' }}
- >
-
-
-
-
-
-
- onMoveBlock(block.id, 'down')}
- disabled={index === total - 1}
- sx={{ color: 'rgba(255,255,255,0.85)' }}
- >
-
-
-
-
-
- onDuplicateBlock(block.id)}
- sx={{ color: 'rgba(255,255,255,0.85)' }}
- >
-
-
-
-
- onRemoveBlock(block.id)}
- sx={{ color: 'rgba(255,255,255,0.85)' }}
- >
-
-
-
-
-
- {renderBlockFields(block, definition, onUpdateField)}
- {definition.hasChildren &&
- renderBlockSection('Then', block.children, block.id, 'children', onRequestAddBlock, renderBlockCard)}
- {definition.hasElseChildren &&
- renderBlockSection(
- 'Else',
- block.elseChildren,
- block.id,
- 'elseChildren',
- onRequestAddBlock,
- renderBlockCard
- )}
-
- )
- }
+ const renderNestedList = (childBlocks?: LuaBlock[]) => (
+
+ )
return (
- {blocks.map((block, index) => renderBlockCard(block, index, blocks.length))}
+ {blocks.map((block, index) => {
+ const definition = blockDefinitionMap.get(block.type)
+ if (!definition) return null
+
+ return (
+
+ )
+ })}
)
}
diff --git a/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts b/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts
new file mode 100644
index 000000000..786b6f586
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts
@@ -0,0 +1,20 @@
+import type { BlockCategory, BlockDefinition } from '../types'
+
+const createCategoryIndex = (): Record => ({
+ Basics: [],
+ Logic: [],
+ Loops: [],
+ Data: [],
+ Functions: [],
+})
+
+export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
+ const categories = createCategoryIndex()
+ definitions.forEach((definition) => {
+ categories[definition.category].push(definition)
+ })
+ return categories
+}
+
+export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) =>
+ new Map(definitions.map((definition) => [definition.type, definition]))
diff --git a/frontends/nextjs/src/components/editors/lua/blocks/index.ts b/frontends/nextjs/src/components/editors/lua/blocks/index.ts
index 33cf9167d..b9706fcc0 100644
--- a/frontends/nextjs/src/components/editors/lua/blocks/index.ts
+++ b/frontends/nextjs/src/components/editors/lua/blocks/index.ts
@@ -1,4 +1,4 @@
-import type { BlockCategory, BlockDefinition } from '../types'
+import type { BlockDefinition } from '../types'
import { basicBlocks } from './basics'
import { dataBlocks } from './data'
import { functionBlocks } from './functions'
@@ -13,21 +13,4 @@ export const BLOCK_DEFINITIONS: BlockDefinition[] = [
...functionBlocks,
]
-const createCategoryIndex = (): Record => ({
- Basics: [],
- Logic: [],
- Loops: [],
- Data: [],
- Functions: [],
-})
-
-export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
- const categories = createCategoryIndex()
- definitions.forEach((definition) => {
- categories[definition.category].push(definition)
- })
- return categories
-}
-
-export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) =>
- new Map(definitions.map((definition) => [definition.type, definition]))
+export { buildBlockDefinitionMap, groupBlockDefinitionsByCategory } from './grouping'
From a72299176c4906b0de3697dd5d2154711d90c098 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:43:12 +0000
Subject: [PATCH 18/80] refactor: modularize lua blocks state hook
---
.../editors/lua/hooks/useLuaBlocksState.ts | 290 ++----------------
.../lua/hooks/useLuaBlocksState/actions.ts | 208 +++++++++++++
.../lua/hooks/useLuaBlocksState/selectors.ts | 12 +
.../lua/hooks/useLuaBlocksState/storage.ts | 98 ++++++
4 files changed, 341 insertions(+), 267 deletions(-)
create mode 100644 frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/actions.ts
create mode 100644 frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/selectors.ts
create mode 100644 frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/storage.ts
diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState.ts
index 4c671447b..4f844dd02 100644
--- a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState.ts
+++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState.ts
@@ -1,7 +1,8 @@
-import { useEffect, useMemo, useState, type MouseEvent } from 'react'
-import { toast } from 'sonner'
+import { useEffect, useMemo, useState } from 'react'
import type { LuaScript } from '@/lib/level-types'
-import type { BlockSlot, LuaBlock, LuaBlockType } from '../types'
+import type { LuaBlock, LuaBlockType } from '../types'
+import { createLuaBlocksActions, type MenuTarget } from './useLuaBlocksState/actions'
+import { selectActiveBlocks, selectSelectedScript } from './useLuaBlocksState/selectors'
interface UseLuaBlocksStateProps {
scripts: LuaScript[]
@@ -12,108 +13,6 @@ interface UseLuaBlocksStateProps {
decodeBlocksMetadata: (code: string) => LuaBlock[] | null
}
-interface MenuTarget {
- parentId: string | null
- slot: BlockSlot
-}
-
-const addBlockToTree = (
- blocks: LuaBlock[],
- parentId: string | null,
- slot: BlockSlot,
- newBlock: LuaBlock
-): LuaBlock[] => {
- if (slot === 'root' || !parentId) {
- return [...blocks, newBlock]
- }
-
- return blocks.map((block) => {
- if (block.id === parentId) {
- const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? []
- const updated = [...current, newBlock]
- if (slot === 'children') {
- return { ...block, children: updated }
- }
- return { ...block, elseChildren: updated }
- }
-
- const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children
- const elseChildren = block.elseChildren
- ? addBlockToTree(block.elseChildren, parentId, slot, newBlock)
- : block.elseChildren
-
- if (children !== block.children || elseChildren !== block.elseChildren) {
- return { ...block, children, elseChildren }
- }
-
- return block
- })
-}
-
-const updateBlockInTree = (
- blocks: LuaBlock[],
- blockId: string,
- updater: (block: LuaBlock) => LuaBlock
-): LuaBlock[] =>
- blocks.map((block) => {
- if (block.id === blockId) {
- return updater(block)
- }
-
- const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children
- const elseChildren = block.elseChildren
- ? updateBlockInTree(block.elseChildren, blockId, updater)
- : block.elseChildren
-
- if (children !== block.children || elseChildren !== block.elseChildren) {
- return { ...block, children, elseChildren }
- }
-
- return block
- })
-
-const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] =>
- blocks
- .filter((block) => block.id !== blockId)
- .map((block) => {
- const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children
- const elseChildren = block.elseChildren
- ? removeBlockFromTree(block.elseChildren, blockId)
- : block.elseChildren
-
- if (children !== block.children || elseChildren !== block.elseChildren) {
- return { ...block, children, elseChildren }
- }
-
- return block
- })
-
-const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => {
- const index = blocks.findIndex((block) => block.id === blockId)
- if (index !== -1) {
- const targetIndex = direction === 'up' ? index - 1 : index + 1
- if (targetIndex < 0 || targetIndex >= blocks.length) return blocks
-
- const updated = [...blocks]
- const [moved] = updated.splice(index, 1)
- updated.splice(targetIndex, 0, moved)
- return updated
- }
-
- return blocks.map((block) => {
- const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children
- const elseChildren = block.elseChildren
- ? moveBlockInTree(block.elseChildren, blockId, direction)
- : block.elseChildren
-
- if (children !== block.children || elseChildren !== block.elseChildren) {
- return { ...block, children, elseChildren }
- }
-
- return block
- })
-}
-
export function useLuaBlocksState({
scripts,
onScriptsChange,
@@ -156,178 +55,35 @@ export function useLuaBlocksState({
}))
}, [blocksByScript, decodeBlocksMetadata, scripts, selectedScriptId])
- const selectedScript = scripts.find((script) => script.id === selectedScriptId) || null
- const activeBlocks = selectedScriptId ? blocksByScript[selectedScriptId] || [] : []
+ const selectedScript = selectSelectedScript(scripts, selectedScriptId)
+ const activeBlocks = selectActiveBlocks(blocksByScript, selectedScriptId)
const generatedCode = useMemo(() => buildLuaFromBlocks(activeBlocks), [activeBlocks, buildLuaFromBlocks])
- const handleAddScript = () => {
- const starterBlocks = [createBlock('log')]
- const newScript: LuaScript = {
- id: `lua_${Date.now()}`,
- name: 'Block Script',
- description: 'Built with Lua blocks',
- code: buildLuaFromBlocks(starterBlocks),
- parameters: [],
- }
-
- onScriptsChange([...scripts, newScript])
- setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks }))
- setSelectedScriptId(newScript.id)
- toast.success('Block script created')
- }
-
- const handleDeleteScript = (scriptId: string) => {
- const remaining = scripts.filter((script) => script.id !== scriptId)
- onScriptsChange(remaining)
-
- setBlocksByScript((prev) => {
- const { [scriptId]: _, ...rest } = prev
- return rest
- })
-
- if (selectedScriptId === scriptId) {
- setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null)
- }
-
- toast.success('Script deleted')
- }
-
- const handleUpdateScript = (updates: Partial) => {
- if (!selectedScript) return
- onScriptsChange(
- scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script))
- )
- }
-
- const handleApplyCode = () => {
- if (!selectedScript) return
- handleUpdateScript({ code: generatedCode })
- toast.success('Lua code updated from blocks')
- }
-
- const handleCopyCode = async () => {
- try {
- await navigator.clipboard.writeText(generatedCode)
- toast.success('Lua code copied to clipboard')
- } catch (error) {
- toast.error('Unable to copy code')
- }
- }
-
- const handleReloadFromCode = () => {
- if (!selectedScript) return
- const parsed = decodeBlocksMetadata(selectedScript.code)
- if (!parsed) {
- toast.warning('No block metadata found in this script')
- return
- }
- setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed }))
- toast.success('Blocks loaded from script')
- }
-
- const handleRequestAddBlock = (
- event: MouseEvent,
- target: { parentId: string | null; slot: BlockSlot }
- ) => {
- setMenuAnchor(event.currentTarget)
- setMenuTarget(target)
- }
-
- const handleAddBlock = (type: LuaBlockType, target?: { parentId: string | null; slot: BlockSlot }) => {
- const resolvedTarget = target ?? menuTarget
- if (!selectedScriptId || !resolvedTarget) return
-
- const newBlock = createBlock(type)
- setBlocksByScript((prev) => ({
- ...prev,
- [selectedScriptId]: addBlockToTree(
- prev[selectedScriptId] || [],
- resolvedTarget.parentId,
- resolvedTarget.slot,
- newBlock
- ),
- }))
-
- setMenuAnchor(null)
- setMenuTarget(null)
- }
-
- const handleCloseMenu = () => {
- setMenuAnchor(null)
- setMenuTarget(null)
- }
-
- const handleUpdateField = (blockId: string, fieldName: string, value: string) => {
- if (!selectedScriptId) return
- setBlocksByScript((prev) => ({
- ...prev,
- [selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({
- ...block,
- fields: {
- ...block.fields,
- [fieldName]: value,
- },
- })),
- }))
- }
-
- const handleRemoveBlock = (blockId: string) => {
- if (!selectedScriptId) return
- setBlocksByScript((prev) => ({
- ...prev,
- [selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId),
- }))
- }
-
- const handleDuplicateBlock = (blockId: string) => {
- if (!selectedScriptId) return
-
- setBlocksByScript((prev) => {
- const blocks = prev[selectedScriptId] || []
- let duplicated: LuaBlock | null = null
-
- const updated = updateBlockInTree(blocks, blockId, (block) => {
- duplicated = cloneBlock(block)
- return block
- })
-
- if (!duplicated) return prev
-
- return {
- ...prev,
- [selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated),
- }
- })
- }
-
- const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => {
- if (!selectedScriptId) return
- setBlocksByScript((prev) => ({
- ...prev,
- [selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction),
- }))
- }
+ const actions = createLuaBlocksActions({
+ scripts,
+ selectedScript,
+ selectedScriptId,
+ generatedCode,
+ menuTarget,
+ buildLuaFromBlocks,
+ createBlock,
+ cloneBlock,
+ decodeBlocksMetadata,
+ onScriptsChange,
+ setBlocksByScript,
+ setMenuAnchor,
+ setMenuTarget,
+ setSelectedScriptId,
+ })
return {
activeBlocks,
generatedCode,
- handleAddBlock,
- handleAddScript,
- handleApplyCode,
- handleCloseMenu,
- handleCopyCode,
- handleDeleteScript,
- handleDuplicateBlock,
- handleMoveBlock,
- handleReloadFromCode,
- handleRemoveBlock,
- handleRequestAddBlock,
- handleUpdateField,
- handleUpdateScript,
menuAnchor,
menuTarget,
selectedScript,
selectedScriptId,
setSelectedScriptId,
+ ...actions,
}
}
diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/actions.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/actions.ts
new file mode 100644
index 000000000..f03cce58a
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/actions.ts
@@ -0,0 +1,208 @@
+import type { Dispatch, MouseEvent, SetStateAction } from 'react'
+import { toast } from 'sonner'
+import type { LuaScript } from '@/lib/level-types'
+import type { BlockSlot, LuaBlock, LuaBlockType } from '../../types'
+import { addBlockToTree, moveBlockInTree, removeBlockFromTree, updateBlockInTree } from './storage'
+
+export interface MenuTarget {
+ parentId: string | null
+ slot: BlockSlot
+}
+
+interface LuaBlocksActionConfig {
+ scripts: LuaScript[]
+ selectedScript: LuaScript | null
+ selectedScriptId: string | null
+ generatedCode: string
+ menuTarget: MenuTarget | null
+ buildLuaFromBlocks: (blocks: LuaBlock[]) => string
+ createBlock: (type: LuaBlockType) => LuaBlock
+ cloneBlock: (block: LuaBlock) => LuaBlock
+ decodeBlocksMetadata: (code: string) => LuaBlock[] | null
+ onScriptsChange: (scripts: LuaScript[]) => void
+ setBlocksByScript: Dispatch>>
+ setMenuAnchor: Dispatch>
+ setMenuTarget: Dispatch>
+ setSelectedScriptId: Dispatch>
+}
+
+export const createLuaBlocksActions = ({
+ scripts,
+ selectedScript,
+ selectedScriptId,
+ generatedCode,
+ menuTarget,
+ buildLuaFromBlocks,
+ createBlock,
+ cloneBlock,
+ decodeBlocksMetadata,
+ onScriptsChange,
+ setBlocksByScript,
+ setMenuAnchor,
+ setMenuTarget,
+ setSelectedScriptId,
+}: LuaBlocksActionConfig) => {
+ const handleAddScript = () => {
+ const starterBlocks = [createBlock('log')]
+ const newScript: LuaScript = {
+ id: `lua_${Date.now()}`,
+ name: 'Block Script',
+ description: 'Built with Lua blocks',
+ code: buildLuaFromBlocks(starterBlocks),
+ parameters: [],
+ }
+
+ onScriptsChange([...scripts, newScript])
+ setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks }))
+ setSelectedScriptId(newScript.id)
+ toast.success('Block script created')
+ }
+
+ const handleDeleteScript = (scriptId: string) => {
+ const remaining = scripts.filter((script) => script.id !== scriptId)
+ onScriptsChange(remaining)
+
+ setBlocksByScript((prev) => {
+ const { [scriptId]: _, ...rest } = prev
+ return rest
+ })
+
+ if (selectedScriptId === scriptId) {
+ setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null)
+ }
+
+ toast.success('Script deleted')
+ }
+
+ const handleUpdateScript = (updates: Partial) => {
+ if (!selectedScript) return
+ onScriptsChange(
+ scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script))
+ )
+ }
+
+ const handleApplyCode = () => {
+ if (!selectedScript) return
+ handleUpdateScript({ code: generatedCode })
+ toast.success('Lua code updated from blocks')
+ }
+
+ const handleCopyCode = async () => {
+ try {
+ await navigator.clipboard.writeText(generatedCode)
+ toast.success('Lua code copied to clipboard')
+ } catch (error) {
+ toast.error('Unable to copy code')
+ }
+ }
+
+ const handleReloadFromCode = () => {
+ if (!selectedScript) return
+ const parsed = decodeBlocksMetadata(selectedScript.code)
+ if (!parsed) {
+ toast.warning('No block metadata found in this script')
+ return
+ }
+ setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed }))
+ toast.success('Blocks loaded from script')
+ }
+
+ const handleRequestAddBlock = (
+ event: MouseEvent,
+ target: { parentId: string | null; slot: BlockSlot }
+ ) => {
+ setMenuAnchor(event.currentTarget)
+ setMenuTarget(target)
+ }
+
+ const handleAddBlock = (type: LuaBlockType, target?: MenuTarget) => {
+ const resolvedTarget = target ?? menuTarget
+ if (!selectedScriptId || !resolvedTarget) return
+
+ const newBlock = createBlock(type)
+ setBlocksByScript((prev) => ({
+ ...prev,
+ [selectedScriptId]: addBlockToTree(
+ prev[selectedScriptId] || [],
+ resolvedTarget.parentId,
+ resolvedTarget.slot,
+ newBlock
+ ),
+ }))
+
+ setMenuAnchor(null)
+ setMenuTarget(null)
+ }
+
+ const handleCloseMenu = () => {
+ setMenuAnchor(null)
+ setMenuTarget(null)
+ }
+
+ const handleUpdateField = (blockId: string, fieldName: string, value: string) => {
+ if (!selectedScriptId) return
+ setBlocksByScript((prev) => ({
+ ...prev,
+ [selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({
+ ...block,
+ fields: {
+ ...block.fields,
+ [fieldName]: value,
+ },
+ })),
+ }))
+ }
+
+ const handleRemoveBlock = (blockId: string) => {
+ if (!selectedScriptId) return
+ setBlocksByScript((prev) => ({
+ ...prev,
+ [selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId),
+ }))
+ }
+
+ const handleDuplicateBlock = (blockId: string) => {
+ if (!selectedScriptId) return
+
+ setBlocksByScript((prev) => {
+ const blocks = prev[selectedScriptId] || []
+ let duplicated: LuaBlock | null = null
+
+ const updated = updateBlockInTree(blocks, blockId, (block) => {
+ duplicated = cloneBlock(block)
+ return block
+ })
+
+ if (!duplicated) return prev
+
+ return {
+ ...prev,
+ [selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated),
+ }
+ })
+ }
+
+ const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => {
+ if (!selectedScriptId) return
+ setBlocksByScript((prev) => ({
+ ...prev,
+ [selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction),
+ }))
+ }
+
+ return {
+ handleAddBlock,
+ handleAddScript,
+ handleApplyCode,
+ handleCloseMenu,
+ handleCopyCode,
+ handleDeleteScript,
+ handleDuplicateBlock,
+ handleMoveBlock,
+ handleReloadFromCode,
+ handleRemoveBlock,
+ handleRequestAddBlock,
+ handleUpdateField,
+ handleUpdateScript,
+ }
+}
diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/selectors.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/selectors.ts
new file mode 100644
index 000000000..a669f3f78
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/selectors.ts
@@ -0,0 +1,12 @@
+import type { LuaScript } from '@/lib/level-types'
+import type { LuaBlock } from '../../types'
+
+export const selectSelectedScript = (
+ scripts: LuaScript[],
+ selectedScriptId: string | null
+): LuaScript | null => scripts.find((script) => script.id === selectedScriptId) || null
+
+export const selectActiveBlocks = (
+ blocksByScript: Record,
+ selectedScriptId: string | null
+): LuaBlock[] => (selectedScriptId ? blocksByScript[selectedScriptId] || [] : [])
diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/storage.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/storage.ts
new file mode 100644
index 000000000..c26c7a31b
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/storage.ts
@@ -0,0 +1,98 @@
+import type { BlockSlot, LuaBlock } from '../../types'
+
+export const addBlockToTree = (
+ blocks: LuaBlock[],
+ parentId: string | null,
+ slot: BlockSlot,
+ newBlock: LuaBlock
+): LuaBlock[] => {
+ if (slot === 'root' || !parentId) {
+ return [...blocks, newBlock]
+ }
+
+ return blocks.map((block) => {
+ if (block.id === parentId) {
+ const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? []
+ const updated = [...current, newBlock]
+ if (slot === 'children') {
+ return { ...block, children: updated }
+ }
+ return { ...block, elseChildren: updated }
+ }
+
+ const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children
+ const elseChildren = block.elseChildren
+ ? addBlockToTree(block.elseChildren, parentId, slot, newBlock)
+ : block.elseChildren
+
+ if (children !== block.children || elseChildren !== block.elseChildren) {
+ return { ...block, children, elseChildren }
+ }
+
+ return block
+ })
+}
+
+export const updateBlockInTree = (
+ blocks: LuaBlock[],
+ blockId: string,
+ updater: (block: LuaBlock) => LuaBlock
+): LuaBlock[] =>
+ blocks.map((block) => {
+ if (block.id === blockId) {
+ return updater(block)
+ }
+
+ const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children
+ const elseChildren = block.elseChildren
+ ? updateBlockInTree(block.elseChildren, blockId, updater)
+ : block.elseChildren
+
+ if (children !== block.children || elseChildren !== block.elseChildren) {
+ return { ...block, children, elseChildren }
+ }
+
+ return block
+ })
+
+export const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] =>
+ blocks
+ .filter((block) => block.id !== blockId)
+ .map((block) => {
+ const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children
+ const elseChildren = block.elseChildren
+ ? removeBlockFromTree(block.elseChildren, blockId)
+ : block.elseChildren
+
+ if (children !== block.children || elseChildren !== block.elseChildren) {
+ return { ...block, children, elseChildren }
+ }
+
+ return block
+ })
+
+export const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => {
+ const index = blocks.findIndex((block) => block.id === blockId)
+ if (index !== -1) {
+ const targetIndex = direction === 'up' ? index - 1 : index + 1
+ if (targetIndex < 0 || targetIndex >= blocks.length) return blocks
+
+ const updated = [...blocks]
+ const [moved] = updated.splice(index, 1)
+ updated.splice(targetIndex, 0, moved)
+ return updated
+ }
+
+ return blocks.map((block) => {
+ const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children
+ const elseChildren = block.elseChildren
+ ? moveBlockInTree(block.elseChildren, blockId, direction)
+ : block.elseChildren
+
+ if (children !== block.children || elseChildren !== block.elseChildren) {
+ return { ...block, children, elseChildren }
+ }
+
+ return block
+ })
+}
From ecd04fa1a0fe00351756b290ca9864e015845b97 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:43:54 +0000
Subject: [PATCH 19/80] refactor: modularize level 4 schema editor
---
.../editors/schema/SchemaEditorLevel4.tsx | 277 ++----------------
.../src/components/level/levels/Level4.tsx | 1 +
.../src/components/schema/level4/Tabs.tsx | 186 ++++++++++++
.../schema/level4/ValidationPanel.tsx | 84 ++++++
.../schema/level4/useSchemaLevel4.ts | 127 ++++++++
5 files changed, 420 insertions(+), 255 deletions(-)
create mode 100644 frontends/nextjs/src/components/schema/level4/Tabs.tsx
create mode 100644 frontends/nextjs/src/components/schema/level4/ValidationPanel.tsx
create mode 100644 frontends/nextjs/src/components/schema/level4/useSchemaLevel4.ts
diff --git a/frontends/nextjs/src/components/editors/schema/SchemaEditorLevel4.tsx b/frontends/nextjs/src/components/editors/schema/SchemaEditorLevel4.tsx
index f6a56a682..7acbee432 100644
--- a/frontends/nextjs/src/components/editors/schema/SchemaEditorLevel4.tsx
+++ b/frontends/nextjs/src/components/editors/schema/SchemaEditorLevel4.tsx
@@ -1,19 +1,9 @@
-import { useState } from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Label } from '@/components/ui'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui'
-import { Switch } from '@/components/ui'
+import { SchemaTabs } from '@/components/schema/level4/Tabs'
+import { useSchemaLevel4 } from '@/components/schema/level4/useSchemaLevel4'
+import type { ModelSchema } from '@/lib/schema-types'
import { Plus, Trash } from '@phosphor-icons/react'
-import { toast } from 'sonner'
-import type { ModelSchema, FieldSchema, FieldType } from '@/lib/schema-types'
interface SchemaEditorLevel4Props {
schemas: ModelSchema[]
@@ -21,74 +11,17 @@ interface SchemaEditorLevel4Props {
}
export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLevel4Props) {
- const [selectedModel, setSelectedModel] = useState(
- schemas.length > 0 ? schemas[0].name : null
- )
-
- const currentModel = schemas.find(s => s.name === selectedModel)
-
- const handleAddModel = () => {
- const newModel: ModelSchema = {
- name: `Model_${Date.now()}`,
- label: 'New Model',
- fields: [],
- }
- onSchemasChange([...schemas, newModel])
- setSelectedModel(newModel.name)
- toast.success('Model created')
- }
-
- const handleDeleteModel = (modelName: string) => {
- onSchemasChange(schemas.filter(s => s.name !== modelName))
- if (selectedModel === modelName) {
- setSelectedModel(schemas.length > 1 ? schemas[0].name : null)
- }
- toast.success('Model deleted')
- }
-
- const handleUpdateModel = (updates: Partial) => {
- if (!currentModel) return
-
- onSchemasChange(
- schemas.map(s => s.name === selectedModel ? { ...s, ...updates } : s)
- )
- }
-
- const handleAddField = () => {
- if (!currentModel) return
-
- const newField: FieldSchema = {
- name: `field_${Date.now()}`,
- type: 'string',
- label: 'New Field',
- required: false,
- editable: true,
- }
-
- handleUpdateModel({
- fields: [...currentModel.fields, newField],
- })
- toast.success('Field added')
- }
-
- const handleDeleteField = (fieldName: string) => {
- if (!currentModel) return
-
- handleUpdateModel({
- fields: currentModel.fields.filter(f => f.name !== fieldName),
- })
- toast.success('Field deleted')
- }
-
- const handleUpdateField = (fieldName: string, updates: Partial) => {
- if (!currentModel) return
-
- handleUpdateModel({
- fields: currentModel.fields.map(f =>
- f.name === fieldName ? { ...f, ...updates } : f
- ),
- })
- }
+ const {
+ currentModel,
+ selectedModel,
+ selectModel,
+ handleAddField,
+ handleAddModel,
+ handleDeleteField,
+ handleDeleteModel,
+ handleUpdateField,
+ handleUpdateModel,
+ } = useSchemaLevel4({ schemas, onSchemasChange })
return (
@@ -117,7 +50,7 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev
? 'bg-accent border-accent-foreground'
: 'hover:bg-muted border-border'
}`}
- onClick={() => setSelectedModel(schema.name)}
+ onClick={() => selectModel(schema.name)}
>
{schema.label || schema.name}
@@ -150,179 +83,13 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev
) : (
- <>
-
- Edit Model: {currentModel.label}
- Configure model properties and fields
-
-
-
-
-
-
-
Fields
-
-
- Add Field
-
-
-
-
- {currentModel.fields.length === 0 ? (
-
- No fields yet. Add a field to start.
-
- ) : (
- currentModel.fields.map((field) => (
-
-
-
-
-
- Field Name
-
- handleUpdateField(field.name, { name: e.target.value })
- }
- placeholder="email"
- />
-
-
- Label
-
- handleUpdateField(field.name, { label: e.target.value })
- }
- placeholder="Email Address"
- />
-
-
- Type
-
- handleUpdateField(field.name, { type: value as FieldType })
- }
- >
-
-
-
-
- String
- Text
- Number
- Boolean
- Date
- DateTime
- Email
- URL
- Select
- Relation
- JSON
-
-
-
-
- Default Value
-
- handleUpdateField(field.name, { default: e.target.value })
- }
- placeholder="Default"
- />
-
-
-
handleDeleteField(field.name)}
- >
-
-
-
-
-
-
-
- handleUpdateField(field.name, { required: checked })
- }
- />
- Required
-
-
-
- handleUpdateField(field.name, { unique: checked })
- }
- />
- Unique
-
-
-
- handleUpdateField(field.name, { editable: checked })
- }
- />
- Editable
-
-
-
- handleUpdateField(field.name, { searchable: checked })
- }
- />
- Searchable
-
-
-
-
- ))
- )}
-
-
-
- >
+
)}
diff --git a/frontends/nextjs/src/components/level/levels/Level4.tsx b/frontends/nextjs/src/components/level/levels/Level4.tsx
index be74a350a..ee10645e9 100644
--- a/frontends/nextjs/src/components/level/levels/Level4.tsx
+++ b/frontends/nextjs/src/components/level/levels/Level4.tsx
@@ -55,6 +55,7 @@ export function Level4({ user, onLogout, onNavigate, onPreview }: Level4Props) {
) => void
+ onAddField: () => void
+ onDeleteField: (fieldName: string) => void
+ onUpdateField: (fieldName: string, updates: Partial) => void
+}
+
+export function SchemaTabs({
+ currentModel,
+ onUpdateModel,
+ onAddField,
+ onDeleteField,
+ onUpdateField,
+}: SchemaTabsProps) {
+ const handleFieldChange = (fieldName: string, updates: Partial) => {
+ onUpdateField(fieldName, updates)
+ }
+
+ return (
+ <>
+
+ Edit Model: {currentModel.label ?? currentModel.name}
+ Configure model properties and fields
+
+
+
+ onUpdateModel({ name: value })}
+ placeholder="user_model"
+ />
+ onUpdateModel({ label: value })}
+ placeholder="User"
+ />
+ onUpdateModel({ labelPlural: value })}
+ placeholder="Users"
+ />
+ onUpdateModel({ icon: value })}
+ placeholder="users"
+ />
+
+
+
+
+
Fields
+
+
+ Add Field
+
+
+
+
+ {currentModel.fields.length === 0 ? (
+
+ No fields yet. Add a field to start.
+
+ ) : (
+ currentModel.fields.map((field) => (
+
handleFieldChange(field.name, updates)}
+ onDelete={() => onDeleteField(field.name)}
+ />
+ ))
+ )}
+
+
+
+ >
+ )
+}
+
+interface FieldCardProps {
+ field: FieldSchema
+ onChange: (updates: Partial) => void
+ onDelete: () => void
+}
+
+function FieldCard({ field, onChange, onDelete }: FieldCardProps) {
+ return (
+
+
+
+
+
onChange({ name: value })}
+ placeholder="email"
+ labelClassName="text-xs"
+ />
+ onChange({ label: value })}
+ placeholder="Email Address"
+ labelClassName="text-xs"
+ />
+
+ Type
+ onChange({ type: value as FieldType })}
+ >
+
+
+
+
+ String
+ Text
+ Number
+ Boolean
+ Date
+ DateTime
+ Email
+ URL
+ Select
+ Relation
+ JSON
+
+
+
+ onChange({ default: value || undefined })}
+ placeholder="Default"
+ labelClassName="text-xs"
+ />
+
+
+
+
+
+
+
+
+
+ )
+}
+
+interface TextFieldProps {
+ label: string
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ labelClassName?: string
+}
+
+function TextField({ label, value, onChange, placeholder, labelClassName }: TextFieldProps) {
+ return (
+
+ {label}
+ onChange(event.target.value)}
+ placeholder={placeholder}
+ />
+
+ )
+}
diff --git a/frontends/nextjs/src/components/schema/level4/ValidationPanel.tsx b/frontends/nextjs/src/components/schema/level4/ValidationPanel.tsx
new file mode 100644
index 000000000..ce8bdb6f1
--- /dev/null
+++ b/frontends/nextjs/src/components/schema/level4/ValidationPanel.tsx
@@ -0,0 +1,84 @@
+import { Input, Label, Switch } from '@/components/ui'
+import type { FieldSchema } from '@/lib/schema-types'
+
+interface ValidationPanelProps {
+ field: FieldSchema
+ onChange: (updates: Partial) => void
+}
+
+const numericKeys = ['min', 'max', 'minLength', 'maxLength'] as const
+type NumericValidationKey = (typeof numericKeys)[number]
+
+const labels: Record = {
+ min: 'Minimum Value',
+ max: 'Maximum Value',
+ minLength: 'Minimum Length',
+ maxLength: 'Maximum Length',
+}
+
+export function ValidationPanel({ field, onChange }: ValidationPanelProps) {
+ const handleNumberChange = (key: NumericValidationKey, value: string) => {
+ const parsedValue = value === '' ? undefined : Number(value)
+ const nextValidation = {
+ ...field.validation,
+ [key]: Number.isNaN(parsedValue) ? undefined : parsedValue,
+ }
+
+ onChange({ validation: nextValidation })
+ }
+
+ return (
+
+
+
+
+ onChange({ required: checked })} />
+ onChange({ unique: checked })} />
+ onChange({ editable: checked })} />
+ onChange({ searchable: checked })} />
+
+
+ )
+}
+
+interface ToggleProps {
+ label: string
+ checked?: boolean
+ onCheckedChange: (value: boolean) => void
+}
+
+function Toggle({ label, checked, onCheckedChange }: ToggleProps) {
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/schema/level4/useSchemaLevel4.ts b/frontends/nextjs/src/components/schema/level4/useSchemaLevel4.ts
new file mode 100644
index 000000000..77c6e8590
--- /dev/null
+++ b/frontends/nextjs/src/components/schema/level4/useSchemaLevel4.ts
@@ -0,0 +1,127 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { toast } from 'sonner'
+
+import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
+
+interface UseSchemaLevel4Props {
+ schemas: ModelSchema[]
+ onSchemasChange: (schemas: ModelSchema[]) => void
+}
+
+export function useSchemaLevel4({ schemas, onSchemasChange }: UseSchemaLevel4Props) {
+ const [selectedModel, setSelectedModel] = useState(schemas[0]?.name ?? null)
+
+ useEffect(() => {
+ if (!selectedModel && schemas[0]) {
+ setSelectedModel(schemas[0].name)
+ }
+
+ if (selectedModel && !schemas.some(schema => schema.name === selectedModel)) {
+ setSelectedModel(schemas[0]?.name ?? null)
+ }
+ }, [schemas, selectedModel])
+
+ const currentModel = useMemo(
+ () => schemas.find((schema) => schema.name === selectedModel) ?? null,
+ [schemas, selectedModel]
+ )
+
+ const applyChanges = useCallback(
+ (nextSchemas: ModelSchema[]) => {
+ onSchemasChange(nextSchemas)
+ },
+ [onSchemasChange]
+ )
+
+ const handleAddModel = useCallback(() => {
+ const newModel: ModelSchema = {
+ name: `Model_${Date.now()}`,
+ label: 'New Model',
+ fields: [],
+ }
+
+ applyChanges([...schemas, newModel])
+ setSelectedModel(newModel.name)
+ toast.success('Model created')
+ }, [applyChanges, schemas])
+
+ const handleDeleteModel = useCallback(
+ (modelName: string) => {
+ const updatedSchemas = schemas.filter((schema) => schema.name !== modelName)
+
+ applyChanges(updatedSchemas)
+ if (selectedModel === modelName) {
+ setSelectedModel(updatedSchemas[0]?.name ?? null)
+ }
+ toast.success('Model deleted')
+ },
+ [applyChanges, schemas, selectedModel]
+ )
+
+ const handleUpdateModel = useCallback(
+ (updates: Partial) => {
+ if (!currentModel) return
+
+ applyChanges(
+ schemas.map((schema) =>
+ schema.name === currentModel.name ? { ...schema, ...updates } : schema
+ )
+ )
+ },
+ [applyChanges, currentModel, schemas]
+ )
+
+ const handleAddField = useCallback(() => {
+ if (!currentModel) return
+
+ const newField: FieldSchema = {
+ name: `field_${Date.now()}`,
+ type: 'string',
+ label: 'New Field',
+ required: false,
+ editable: true,
+ }
+
+ handleUpdateModel({
+ fields: [...currentModel.fields, newField],
+ })
+ toast.success('Field added')
+ }, [currentModel, handleUpdateModel])
+
+ const handleDeleteField = useCallback(
+ (fieldName: string) => {
+ if (!currentModel) return
+
+ handleUpdateModel({
+ fields: currentModel.fields.filter((field) => field.name !== fieldName),
+ })
+ toast.success('Field deleted')
+ },
+ [currentModel, handleUpdateModel]
+ )
+
+ const handleUpdateField = useCallback(
+ (fieldName: string, updates: Partial) => {
+ if (!currentModel) return
+
+ handleUpdateModel({
+ fields: currentModel.fields.map((field) =>
+ field.name === fieldName ? { ...field, ...updates } : field
+ ),
+ })
+ },
+ [currentModel, handleUpdateModel]
+ )
+
+ return {
+ currentModel,
+ selectedModel,
+ selectModel: setSelectedModel,
+ handleAddField,
+ handleAddModel,
+ handleDeleteField,
+ handleDeleteModel,
+ handleUpdateField,
+ handleUpdateModel,
+ }
+}
From 4f2bff3a473414e2f6dd9f34899d6706fb357f5e Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:44:34 +0000
Subject: [PATCH 20/80] feat: add contact form example config and preview
---
.../examples/contact-form/FormConfig.ts | 60 ++++++++
.../examples/contact-form/Preview.tsx | 145 ++++++++++++++++++
2 files changed, 205 insertions(+)
create mode 100644 frontends/nextjs/src/components/examples/contact-form/FormConfig.ts
create mode 100644 frontends/nextjs/src/components/examples/contact-form/Preview.tsx
diff --git a/frontends/nextjs/src/components/examples/contact-form/FormConfig.ts b/frontends/nextjs/src/components/examples/contact-form/FormConfig.ts
new file mode 100644
index 000000000..6fb66cf33
--- /dev/null
+++ b/frontends/nextjs/src/components/examples/contact-form/FormConfig.ts
@@ -0,0 +1,60 @@
+export type ContactFormFieldType = 'text' | 'email' | 'textarea'
+
+export interface ContactFormField {
+ name: 'name' | 'email' | 'message'
+ label: string
+ placeholder: string
+ type: ContactFormFieldType
+ required?: boolean
+ helperText?: string
+}
+
+export interface ContactFormConfig {
+ title: string
+ description: string
+ submitLabel: string
+ successTitle: string
+ successMessage: string
+ fields: ContactFormField[]
+}
+
+export const contactFormConfig: ContactFormConfig = {
+ title: 'Contact form',
+ description: 'Collect a name, email, and short message with simple validation.',
+ submitLabel: 'Send message',
+ successTitle: 'Message sent',
+ successMessage: 'Thanks for reaching out. We will get back to you shortly.',
+ fields: [
+ {
+ name: 'name',
+ label: 'Name',
+ placeholder: 'Your name',
+ type: 'text',
+ required: true,
+ },
+ {
+ name: 'email',
+ label: 'Email',
+ placeholder: 'you@example.com',
+ type: 'email',
+ required: true,
+ helperText: 'We will only use this to reply to your note.',
+ },
+ {
+ name: 'message',
+ label: 'Message',
+ placeholder: 'How can we help?',
+ type: 'textarea',
+ required: true,
+ },
+ ],
+}
+
+export type ContactFormState = Record
+
+export function createInitialContactFormState(): ContactFormState {
+ return contactFormConfig.fields.reduce((state, field) => {
+ state[field.name] = ''
+ return state
+ }, {} as ContactFormState)
+}
diff --git a/frontends/nextjs/src/components/examples/contact-form/Preview.tsx b/frontends/nextjs/src/components/examples/contact-form/Preview.tsx
new file mode 100644
index 000000000..b097be962
--- /dev/null
+++ b/frontends/nextjs/src/components/examples/contact-form/Preview.tsx
@@ -0,0 +1,145 @@
+import { ChangeEvent, FormEvent, useMemo, useState } from 'react'
+import {
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Input,
+ Textarea,
+} from '@/components/ui'
+
+import {
+ contactFormConfig,
+ ContactFormField,
+ ContactFormState,
+ createInitialContactFormState,
+} from './FormConfig'
+
+type ValidationErrors = Partial>
+
+function validateContactForm(values: ContactFormState): ValidationErrors {
+ const errors: ValidationErrors = {}
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+ contactFormConfig.fields.forEach(field => {
+ const value = values[field.name]?.trim() ?? ''
+
+ if (field.required && !value) {
+ errors[field.name] = `${field.label} is required`
+ return
+ }
+
+ if (field.type === 'email' && value && !emailPattern.test(value)) {
+ errors[field.name] = 'Enter a valid email address'
+ }
+ })
+
+ return errors
+}
+
+export function ContactFormPreview() {
+ const [formValues, setFormValues] = useState(
+ createInitialContactFormState()
+ )
+ const [errors, setErrors] = useState({})
+ const [submitted, setSubmitted] = useState(false)
+
+ const hasErrors = useMemo(() => Object.keys(errors).length > 0, [errors])
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault()
+
+ const validationErrors = validateContactForm(formValues)
+
+ if (Object.keys(validationErrors).length > 0) {
+ setErrors(validationErrors)
+ setSubmitted(false)
+ return
+ }
+
+ setErrors({})
+ setSubmitted(true)
+ setFormValues(createInitialContactFormState())
+ setTimeout(() => setSubmitted(false), 3200)
+ }
+
+ const renderField = (field: ContactFormField) => {
+ const commonProps = {
+ id: field.name,
+ name: field.name,
+ value: formValues[field.name],
+ onChange: (event: ChangeEvent) => {
+ const { value } = event.target
+ setFormValues(current => ({ ...current, [field.name]: value }))
+ },
+ 'aria-describedby': errors[field.name] ? `${field.name}-error` : undefined,
+ placeholder: field.placeholder,
+ }
+
+ if (field.type === 'textarea') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {contactFormConfig.title}
+ {contactFormConfig.description}
+
+
+
+
+
+ )
+}
From 57a6bd32d6f99c6690f1b02b3aabecbaa1966cfb Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:45:14 +0000
Subject: [PATCH 21/80] refactor: share level section components
---
.../level/level1/CredentialsSection.tsx | 113 ++++++
.../components/level/level1/Level1Tabs.tsx | 52 +++
.../level/level2/ChatTabContent.tsx | 18 +
.../level/level2/CommentsTabContent.tsx | 65 ++++
.../level/level2/ProfileTabContent.tsx | 37 ++
.../components/level/level3/CommentsTable.tsx | 59 ++++
.../level/level3/EditUserDialog.tsx | 50 +++
.../components/level/level3/Level3Stats.tsx | 35 ++
.../src/components/level/level3/UserTable.tsx | 105 ++++++
.../level/level5/CreateTenantDialog.tsx | 45 +++
.../level/level5/Level5Navigator.tsx | 104 ++++++
.../level/level5/TransferConfirmDialog.tsx | 51 +++
.../src/components/level/levels/Level1.tsx | 196 +----------
.../src/components/level/levels/Level2.tsx | 159 ++-------
.../src/components/level/levels/Level3.tsx | 313 ++++-------------
.../src/components/level/levels/Level4.tsx | 21 +-
.../src/components/level/levels/Level5.tsx | 323 ++++--------------
.../level/levels/hooks/useLevel2State.ts | 93 +++++
.../level/levels/hooks/useLevel5State.ts | 133 ++++++++
.../level/sections/ChallengePanel.tsx | 24 ++
.../level/sections/IntroSection.tsx | 26 ++
.../components/level/sections/ResultsPane.tsx | 20 ++
22 files changed, 1200 insertions(+), 842 deletions(-)
create mode 100644 frontends/nextjs/src/components/level/level1/CredentialsSection.tsx
create mode 100644 frontends/nextjs/src/components/level/level1/Level1Tabs.tsx
create mode 100644 frontends/nextjs/src/components/level/level2/ChatTabContent.tsx
create mode 100644 frontends/nextjs/src/components/level/level2/CommentsTabContent.tsx
create mode 100644 frontends/nextjs/src/components/level/level2/ProfileTabContent.tsx
create mode 100644 frontends/nextjs/src/components/level/level3/CommentsTable.tsx
create mode 100644 frontends/nextjs/src/components/level/level3/EditUserDialog.tsx
create mode 100644 frontends/nextjs/src/components/level/level3/Level3Stats.tsx
create mode 100644 frontends/nextjs/src/components/level/level3/UserTable.tsx
create mode 100644 frontends/nextjs/src/components/level/level5/CreateTenantDialog.tsx
create mode 100644 frontends/nextjs/src/components/level/level5/Level5Navigator.tsx
create mode 100644 frontends/nextjs/src/components/level/level5/TransferConfirmDialog.tsx
create mode 100644 frontends/nextjs/src/components/level/levels/hooks/useLevel2State.ts
create mode 100644 frontends/nextjs/src/components/level/levels/hooks/useLevel5State.ts
create mode 100644 frontends/nextjs/src/components/level/sections/ChallengePanel.tsx
create mode 100644 frontends/nextjs/src/components/level/sections/IntroSection.tsx
create mode 100644 frontends/nextjs/src/components/level/sections/ResultsPane.tsx
diff --git a/frontends/nextjs/src/components/level/level1/CredentialsSection.tsx b/frontends/nextjs/src/components/level/level1/CredentialsSection.tsx
new file mode 100644
index 000000000..e26a85feb
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level1/CredentialsSection.tsx
@@ -0,0 +1,113 @@
+"use client"
+
+import { useEffect, useState } from 'react'
+import { getScrambledPassword } from '@/lib/auth'
+import { GodCredentialsBanner } from '../level1/GodCredentialsBanner'
+import { ChallengePanel } from '../sections/ChallengePanel'
+
+export function CredentialsSection() {
+ const [showGodCredentials, setShowGodCredentials] = useState(false)
+ const [showSuperGodCredentials, setShowSuperGodCredentials] = useState(false)
+ const [showPassword, setShowPassword] = useState(false)
+ const [showSuperGodPassword, setShowSuperGodPassword] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const [copiedSuper, setCopiedSuper] = useState(false)
+ const [timeRemaining, setTimeRemaining] = useState('')
+
+ useEffect(() => {
+ let interval: ReturnType | undefined
+
+ const checkCredentials = async () => {
+ try {
+ const { Database } = await import('@/lib/database')
+
+ const shouldShow = await Database.shouldShowGodCredentials()
+ setShowGodCredentials(shouldShow)
+
+ const superGod = await Database.getSuperGod()
+ const firstLoginFlags = await Database.getFirstLoginFlags()
+ setShowSuperGodCredentials(superGod !== null && firstLoginFlags['supergod'] === true)
+
+ if (shouldShow) {
+ const expiry = await Database.getGodCredentialsExpiry()
+ const updateTimer = () => {
+ const now = Date.now()
+ const diff = expiry - now
+
+ if (diff <= 0) {
+ setShowGodCredentials(false)
+ setTimeRemaining('')
+ return
+ }
+
+ const minutes = Math.floor(diff / 60000)
+ const seconds = Math.floor((diff % 60000) / 1000)
+ setTimeRemaining(`${minutes}m ${seconds}s`)
+ }
+
+ updateTimer()
+ interval = setInterval(updateTimer, 1000)
+ }
+ } catch {
+ setShowGodCredentials(false)
+ setShowSuperGodCredentials(false)
+ setTimeRemaining('')
+ }
+ }
+
+ void checkCredentials()
+
+ return () => {
+ if (interval) clearInterval(interval)
+ }
+ }, [])
+
+ const handleCopyPassword = async () => {
+ await navigator.clipboard.writeText(getScrambledPassword('god'))
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ const handleCopySuperGodPassword = async () => {
+ await navigator.clipboard.writeText(getScrambledPassword('supergod'))
+ setCopiedSuper(true)
+ setTimeout(() => setCopiedSuper(false), 2000)
+ }
+
+ if (!showGodCredentials && !showSuperGodCredentials) return null
+
+ return (
+
+
+ {showSuperGodCredentials && (
+ setShowSuperGodPassword(!showSuperGodPassword)}
+ copied={copiedSuper}
+ onCopy={handleCopySuperGodPassword}
+ timeRemaining=""
+ variant="supergod"
+ />
+ )}
+
+ {showGodCredentials && (
+ setShowPassword(!showPassword)}
+ copied={copied}
+ onCopy={handleCopyPassword}
+ timeRemaining={timeRemaining}
+ variant="god"
+ />
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level1/Level1Tabs.tsx b/frontends/nextjs/src/components/level/level1/Level1Tabs.tsx
new file mode 100644
index 000000000..e9b498c85
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level1/Level1Tabs.tsx
@@ -0,0 +1,52 @@
+import { HeroSection } from '../../level1/HeroSection'
+import { FeaturesSection } from '../../level1/FeaturesSection'
+import { ContactSection } from '../../level1/ContactSection'
+import { ServerStatusPanel } from '../../status/ServerStatusPanel'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
+import { GitHubActionsFetcher } from '../../misc/github/GitHubActionsFetcher'
+import { IntroSection } from '../sections/IntroSection'
+
+interface Level1TabsProps {
+ onNavigate: (level: number) => void
+}
+
+export function Level1Tabs({ onNavigate }: Level1TabsProps) {
+ return (
+
+
+ Home
+ GitHub Actions
+ Server Status
+
+
+
+
+
+
+
+ Whether you're a designer who wants to create without code, or a developer who wants to work at a higher level of
+ abstraction, MetaBuilder adapts to your needs.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level2/ChatTabContent.tsx b/frontends/nextjs/src/components/level/level2/ChatTabContent.tsx
new file mode 100644
index 000000000..bc382dc6e
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level2/ChatTabContent.tsx
@@ -0,0 +1,18 @@
+import { IRCWebchatDeclarative } from '../../misc/demos/IRCWebchatDeclarative'
+import { ResultsPane } from '../sections/ResultsPane'
+import type { User } from '@/lib/level-types'
+
+interface ChatTabContentProps {
+ user: User
+}
+
+export function ChatTabContent({ user }: ChatTabContentProps) {
+ return (
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level2/CommentsTabContent.tsx b/frontends/nextjs/src/components/level/level2/CommentsTabContent.tsx
new file mode 100644
index 000000000..5c69338c4
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level2/CommentsTabContent.tsx
@@ -0,0 +1,65 @@
+import { useMemo } from 'react'
+import { Button } from '@/components/ui'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+import { Textarea } from '@/components/ui'
+import { CommentsList } from '../../level2/CommentsList'
+import { ChallengePanel } from '../sections/ChallengePanel'
+import type { Comment, User } from '@/lib/level-types'
+
+interface CommentsTabContentProps {
+ comments: Comment[]
+ users: User[]
+ currentUserId: string
+ newComment: string
+ onChangeComment: (value: string) => void
+ onPostComment: () => void
+ onDeleteComment: (commentId: string) => void
+}
+
+export function CommentsTabContent({
+ comments,
+ users,
+ currentUserId,
+ newComment,
+ onChangeComment,
+ onPostComment,
+ onDeleteComment,
+}: CommentsTabContentProps) {
+ const userComments = useMemo(() => comments.filter(c => c.userId === currentUserId), [comments, currentUserId])
+
+ return (
+
+
+
+ Post a Comment
+ Share your thoughts with the community
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level2/ProfileTabContent.tsx b/frontends/nextjs/src/components/level/level2/ProfileTabContent.tsx
new file mode 100644
index 000000000..c44824bfd
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level2/ProfileTabContent.tsx
@@ -0,0 +1,37 @@
+import { ProfileCard } from '../../level2/ProfileCard'
+import type { User } from '@/lib/level-types'
+
+interface ProfileTabContentProps {
+ user: User
+ editingProfile: boolean
+ profileForm: { bio: string; email: string }
+ onEdit: () => void
+ onCancel: () => void
+ onSave: () => void
+ onFormChange: (value: { bio: string; email: string }) => void
+ onRequestPasswordReset: () => void
+}
+
+export function ProfileTabContent({
+ user,
+ editingProfile,
+ profileForm,
+ onEdit,
+ onCancel,
+ onSave,
+ onFormChange,
+ onRequestPasswordReset,
+}: ProfileTabContentProps) {
+ return (
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level3/CommentsTable.tsx b/frontends/nextjs/src/components/level/level3/CommentsTable.tsx
new file mode 100644
index 000000000..dcc71804f
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level3/CommentsTable.tsx
@@ -0,0 +1,59 @@
+import { useMemo } from 'react'
+import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
+import { ChallengePanel } from '../sections/ChallengePanel'
+import { Trash } from '@phosphor-icons/react'
+import type { Comment, User } from '@/lib/level-types'
+
+interface CommentsTableProps {
+ comments: Comment[]
+ users: User[]
+ searchTerm: string
+ onDeleteComment: (commentId: string) => void
+}
+
+export function CommentsTable({ comments, users, searchTerm, onDeleteComment }: CommentsTableProps) {
+ const filteredComments = useMemo(
+ () => comments.filter(c => c.content.toLowerCase().includes(searchTerm.toLowerCase())),
+ [comments, searchTerm]
+ )
+
+ return (
+
+
+
+
+ User
+ Content
+ Created
+ Actions
+
+
+
+ {filteredComments.length === 0 ? (
+
+
+ No comments found
+
+
+ ) : (
+ filteredComments.map((c) => {
+ const commentUser = users.find(u => u.id === c.userId)
+ return (
+
+ {commentUser?.username || 'Unknown'}
+ {c.content}
+ {new Date(c.createdAt).toLocaleDateString()}
+
+ onDeleteComment(c.id)}>
+
+
+
+
+ )
+ })
+ )}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level3/EditUserDialog.tsx b/frontends/nextjs/src/components/level/level3/EditUserDialog.tsx
new file mode 100644
index 000000000..a95168305
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level3/EditUserDialog.tsx
@@ -0,0 +1,50 @@
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
+import { Button, Input } from '@/components/ui'
+import type { User } from '@/lib/level-types'
+
+interface EditUserDialogProps {
+ open: boolean
+ user: User | null
+ onClose: (open: boolean) => void
+ onChange: (user: User) => void
+ onSave: () => void
+}
+
+export function EditUserDialog({ open, user, onClose, onChange, onSave }: EditUserDialogProps) {
+ if (!user) return null
+
+ return (
+
+
+
+ Edit User
+ Update user information
+
+
+
+ Username
+ onChange({ ...user, username: e.target.value })} />
+
+
+ Email
+ onChange({ ...user, email: e.target.value })}
+ />
+
+
+ Bio
+ onChange({ ...user, bio: e.target.value })} />
+
+
+ onClose(false)}>
+ Cancel
+
+ Save Changes
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level3/Level3Stats.tsx b/frontends/nextjs/src/components/level/level3/Level3Stats.tsx
new file mode 100644
index 000000000..814023ec7
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level3/Level3Stats.tsx
@@ -0,0 +1,35 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
+import { ChatCircle, Users } from '@phosphor-icons/react'
+import type { User, Comment } from '@/lib/level-types'
+
+interface Level3StatsProps {
+ users: User[]
+ comments: Comment[]
+}
+
+export function Level3Stats({ users, comments }: Level3StatsProps) {
+ const adminCount = users.filter(u => u.role === 'admin' || u.role === 'god').length
+
+ const stats = [
+ { label: 'Total Users', value: users.length, icon: Users, helper: 'Registered accounts' },
+ { label: 'Total Comments', value: comments.length, icon: ChatCircle, helper: 'Posted by users' },
+ { label: 'Admins', value: adminCount, icon: Users, helper: 'Admin & god users' },
+ ]
+
+ return (
+
+ {stats.map((stat) => (
+
+
+ {stat.label}
+
+
+
+ {stat.value}
+ {stat.helper}
+
+
+ ))}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level3/UserTable.tsx b/frontends/nextjs/src/components/level/level3/UserTable.tsx
new file mode 100644
index 000000000..e16bb3392
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level3/UserTable.tsx
@@ -0,0 +1,105 @@
+import { useMemo } from 'react'
+import { Badge, Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
+import { ChallengePanel } from '../sections/ChallengePanel'
+import { MagnifyingGlass, PencilSimple, Trash, Users, ChatCircle } from '@phosphor-icons/react'
+import type { User } from '@/lib/level-types'
+
+interface UserTableProps {
+ users: User[]
+ searchTerm: string
+ onSearchChange: (value: string) => void
+ onEditUser: (user: User) => void
+ onDeleteUser: (userId: string) => void
+ currentUserId: string
+ commentCount: number
+ commentLabel: string
+}
+
+export function UserTable({
+ users,
+ searchTerm,
+ onSearchChange,
+ onEditUser,
+ onDeleteUser,
+ currentUserId,
+ commentCount,
+ commentLabel,
+}: UserTableProps) {
+ const filteredUsers = useMemo(
+ () =>
+ users.filter(
+ u => u.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ u.email.toLowerCase().includes(searchTerm.toLowerCase())
+ ),
+ [users, searchTerm]
+ )
+
+ return (
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="pl-9 w-64"
+ />
+
+
+ {users.length}
+ {commentLabel} {commentCount}
+
+
+
+
+
+
+ Username
+ Email
+ Role
+ Created
+ Actions
+
+
+
+ {filteredUsers.length === 0 ? (
+
+
+ No users found
+
+
+ ) : (
+ filteredUsers.map((u) => (
+
+ {u.username}
+ {u.email}
+
+
+ {u.role}
+
+
+ {new Date(u.createdAt).toLocaleDateString()}
+
+
+
onEditUser(u)}>
+
+
+
onDeleteUser(u.id)}
+ disabled={u.id === currentUserId}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level5/CreateTenantDialog.tsx b/frontends/nextjs/src/components/level/level5/CreateTenantDialog.tsx
new file mode 100644
index 000000000..51e2116e8
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level5/CreateTenantDialog.tsx
@@ -0,0 +1,45 @@
+import { Button, Input, Label } from '@/components/ui'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
+
+interface CreateTenantDialogProps {
+ open: boolean
+ newTenantName: string
+ onChangeTenantName: (value: string) => void
+ onClose: (open: boolean) => void
+ onCreate: () => void
+}
+
+export function CreateTenantDialog({ open, newTenantName, onChangeTenantName, onClose, onCreate }: CreateTenantDialogProps) {
+ return (
+
+
+
+ Create New Tenant
+
+ Create a new tenant instance with its own homepage configuration
+
+
+
+
+ Tenant Name
+ onChangeTenantName(e.target.value)}
+ placeholder="Enter tenant name"
+ className="bg-white/5 border-white/10 text-white"
+ />
+
+
+
+ onClose(false)} className="border-white/20 text-white hover:bg-white/10">
+ Cancel
+
+
+ Create
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level5/Level5Navigator.tsx b/frontends/nextjs/src/components/level/level5/Level5Navigator.tsx
new file mode 100644
index 000000000..10f63a21c
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level5/Level5Navigator.tsx
@@ -0,0 +1,104 @@
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
+import { Buildings, Users, ArrowsLeftRight, Eye, Camera, Warning } from '@phosphor-icons/react'
+import { ResultsPane } from '../sections/ResultsPane'
+import { TenantsTab } from '../../level5/tabs/TenantsTab'
+import { GodUsersTab } from '../../level5/tabs/GodUsersTab'
+import { PowerTransferTab } from '../../level5/tabs/power-transfer/PowerTransferTab'
+import { PreviewTab } from '../../level5/tabs/PreviewTab'
+import { ErrorLogsTab } from '../../level5/tabs/error-logs/ErrorLogsTab'
+import { ScreenshotAnalyzer } from '../../misc/demos/ScreenshotAnalyzer'
+import type { AppLevel, Tenant, User } from '@/lib/level-types'
+
+interface Level5NavigatorProps {
+ tenants: Tenant[]
+ allUsers: User[]
+ godUsers: User[]
+ transferRefresh: number
+ currentUser: User
+ onCreateTenant: () => void
+ onDeleteTenant: (tenantId: string) => void
+ onAssignHomepage: (tenantId: string, pageId: string) => Promise
+ onInitiateTransfer: (userId: string) => void
+ onPreview: (level: AppLevel) => void
+}
+
+export function Level5Navigator({
+ tenants,
+ allUsers,
+ godUsers,
+ transferRefresh,
+ currentUser,
+ onCreateTenant,
+ onDeleteTenant,
+ onAssignHomepage,
+ onInitiateTransfer,
+ onPreview,
+}: Level5NavigatorProps) {
+ return (
+
+
+
+
+
+ Tenants
+
+
+
+ God Users
+
+
+
+ Power Transfer
+
+
+
+ Preview Levels
+
+
+
+ Screenshot
+
+
+
+ Error Logs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/level5/TransferConfirmDialog.tsx b/frontends/nextjs/src/components/level/level5/TransferConfirmDialog.tsx
new file mode 100644
index 000000000..114c0e219
--- /dev/null
+++ b/frontends/nextjs/src/components/level/level5/TransferConfirmDialog.tsx
@@ -0,0 +1,51 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui'
+import { Crown } from '@phosphor-icons/react'
+import type { User } from '@/lib/level-types'
+
+interface TransferConfirmDialogProps {
+ open: boolean
+ allUsers: User[]
+ selectedUserId: string
+ onClose: (open: boolean) => void
+ onConfirm: () => void
+}
+
+export function TransferConfirmDialog({ open, allUsers, selectedUserId, onClose, onConfirm }: TransferConfirmDialogProps) {
+ return (
+
+
+
+
+
+ Confirm Power Transfer
+
+
+ Are you absolutely sure? This will transfer your Super God privileges to{' '}
+ {allUsers.find(u => u.id === selectedUserId)?.username} .
+ You will be downgraded to God level and cannot reverse this action.
+
+
+
+
+ Cancel
+
+
+ Transfer Power
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/levels/Level1.tsx b/frontends/nextjs/src/components/level/levels/Level1.tsx
index 995138101..91648b206 100644
--- a/frontends/nextjs/src/components/level/levels/Level1.tsx
+++ b/frontends/nextjs/src/components/level/levels/Level1.tsx
@@ -1,209 +1,25 @@
"use client"
-/**
- * Level1 Component - Public/Unauthenticated Interface
- *
- * The Level1 component serves as the main entry point for unauthenticated users.
- * It displays the public-facing interface with features, hero sections, and
- * navigation to login or other public pages.
- *
- * Key Features:
- * - Navigation bar for public users
- * - Hero section with marketing content
- * - Features overview
- * - Contact form
- * - Credentials display (god/supergod) during setup
- * - Login prompt for authenticated access
- */
-
-import { useState, useEffect } from 'react'
-import { getScrambledPassword } from '@/lib/auth'
+import { useState } from 'react'
import { NavigationBar } from '../../level1/NavigationBar'
-import { GodCredentialsBanner } from '../../level1/GodCredentialsBanner'
-import { HeroSection } from '../../level1/HeroSection'
-import { FeaturesSection } from '../../level1/FeaturesSection'
-import { ContactSection } from '../../level1/ContactSection'
import { AppFooter } from '../../shared/AppFooter'
-import { ServerStatusPanel } from '../../status/ServerStatusPanel'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import { GitHubActionsFetcher } from '../../misc/github/GitHubActionsFetcher'
+import { CredentialsSection } from '../level1/CredentialsSection'
+import { Level1Tabs } from '../level1/Level1Tabs'
-// Props for Level1 component
interface Level1Props {
- // Callback when user navigates to another level
onNavigate: (level: number) => void
}
-/**
- * Level1 - Public interface component
- * @param props - Component props
- */
export function Level1({ onNavigate }: Level1Props) {
- // Menu visibility state
const [menuOpen, setMenuOpen] = useState(false)
- // Show god credentials banner during setup
- const [showGodCredentials, setShowGodCredentials] = useState(false)
- // Show supergod credentials banner during setup
- const [showSuperGodCredentials, setShowSuperGodCredentials] = useState(false)
- // Password visibility toggle for god credentials
- const [showPassword, setShowPassword] = useState(false)
- // Password visibility toggle for supergod credentials
- const [showSuperGodPassword, setShowSuperGodPassword] = useState(false)
- // Track clipboard copy state for god credentials
- const [copied, setCopied] = useState(false)
- // Track clipboard copy state for supergod credentials
- const [copiedSuper, setCopiedSuper] = useState(false)
- // Display remaining time for god credentials expiry
- const [timeRemaining, setTimeRemaining] = useState('')
-
- // Initialize component state on mount
- useEffect(() => {
- let interval: ReturnType | undefined
-
- const checkCredentials = async () => {
- try {
- const { Database } = await import('@/lib/database')
-
- // Check if god credentials should be displayed
- const shouldShow = await Database.shouldShowGodCredentials()
- setShowGodCredentials(shouldShow)
-
- // Get supergod account if exists
- const superGod = await Database.getSuperGod()
- const firstLoginFlags = await Database.getFirstLoginFlags()
- setShowSuperGodCredentials(superGod !== null && firstLoginFlags['supergod'] === true)
-
- // Update timer for god credentials expiry
- if (shouldShow) {
- const expiry = await Database.getGodCredentialsExpiry()
- const updateTimer = () => {
- const now = Date.now()
- const diff = expiry - now
-
- // Hide credentials when expired
- if (diff <= 0) {
- setShowGodCredentials(false)
- setTimeRemaining('')
- return
- }
-
- // Display remaining time in minutes and seconds
- const minutes = Math.floor(diff / 60000)
- const seconds = Math.floor((diff % 60000) / 1000)
- setTimeRemaining(`${minutes}m ${seconds}s`)
- }
-
- updateTimer()
- interval = setInterval(updateTimer, 1000)
- }
- } catch {
- setShowGodCredentials(false)
- setShowSuperGodCredentials(false)
- setTimeRemaining('')
- }
- }
-
- void checkCredentials()
-
- return () => {
- if (interval) clearInterval(interval)
- }
- }, [])
-
- const handleCopyPassword = async () => {
- await navigator.clipboard.writeText(getScrambledPassword('god'))
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- }
-
- const handleCopySuperGodPassword = async () => {
- await navigator.clipboard.writeText(getScrambledPassword('supergod'))
- setCopiedSuper(true)
- setTimeout(() => setCopiedSuper(false), 2000)
- }
return (
- {(showGodCredentials || showSuperGodCredentials) && (
-
- {showSuperGodCredentials && (
- setShowSuperGodPassword(!showSuperGodPassword)}
- copied={copiedSuper}
- onCopy={handleCopySuperGodPassword}
- timeRemaining=""
- variant="supergod"
- />
- )}
-
- {showGodCredentials && (
- setShowPassword(!showPassword)}
- copied={copied}
- onCopy={handleCopyPassword}
- timeRemaining={timeRemaining}
- variant="god"
- />
- )}
-
- )}
-
-
-
-
- Home
- GitHub Actions
- Server Status
-
-
-
-
-
-
-
-
-
About MetaBuilder
-
- MetaBuilder is a revolutionary platform that lets you build entire application stacks
- through visual interfaces. From public websites to complex admin panels, everything
- is generated from declarative configurations, workflows, and embedded scripts.
-
-
- Whether you're a designer who wants to create without code, or a developer who wants
- to work at a higher level of abstraction, MetaBuilder adapts to your needs.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Runtime observability
-
Server Status
-
- Monitor the DBAL stack, Prisma schema, and the C++ daemon from this interface—so you
- can see how the daemon is progressing toward production readiness.
-
-
-
-
-
-
+
+
+
diff --git a/frontends/nextjs/src/components/level/levels/Level2.tsx b/frontends/nextjs/src/components/level/levels/Level2.tsx
index 528ea20ec..fbd0502f5 100644
--- a/frontends/nextjs/src/components/level/levels/Level2.tsx
+++ b/frontends/nextjs/src/components/level/levels/Level2.tsx
@@ -1,19 +1,14 @@
"use client"
-import { useState, useEffect } from 'react'
-import { Button } from '@/components/ui'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Textarea } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { User, ChatCircle } from '@phosphor-icons/react'
-import { toast } from 'sonner'
-import { Database, hashPassword } from '@/lib/database'
-import { generateScrambledPassword, simulateEmailSend } from '@/lib/password-utils'
-import { IRCWebchatDeclarative } from '../../misc/demos/IRCWebchatDeclarative'
-import { ProfileCard } from '../../level2/ProfileCard'
-import { CommentsList } from '../../level2/CommentsList'
import { AppHeader } from '../../shared/AppHeader'
-import type { User as UserType, Comment } from '@/lib/level-types'
+import type { User as UserType } from '@/lib/level-types'
+import { IntroSection } from '../sections/IntroSection'
+import { ProfileTabContent } from '../level2/ProfileTabContent'
+import { CommentsTabContent } from '../level2/CommentsTabContent'
+import { ChatTabContent } from '../level2/ChatTabContent'
+import { useLevel2State } from './hooks/useLevel2State'
export interface Level2Props {
user: UserType
@@ -22,87 +17,21 @@ export interface Level2Props {
}
export function Level2({ user, onLogout, onNavigate }: Level2Props) {
- const [currentUser, setCurrentUser] = useState
(user)
- const [users, setUsers] = useState([])
- const [comments, setComments] = useState([])
- const [newComment, setNewComment] = useState('')
- const [editingProfile, setEditingProfile] = useState(false)
- const [profileForm, setProfileForm] = useState({
- bio: user.bio || '',
- email: user.email,
- })
-
- useEffect(() => {
- const loadData = async () => {
- const loadedUsers = await Database.getUsers({ scope: 'all' })
- setUsers(loadedUsers)
- const foundUser = loadedUsers.find(u => u.id === user.id)
- if (foundUser) {
- setCurrentUser(foundUser)
- setProfileForm({
- bio: foundUser.bio || '',
- email: foundUser.email,
- })
- }
- const loadedComments = await Database.getComments()
- setComments(loadedComments)
- }
- loadData()
- }, [user.id])
-
- const handleProfileSave = async () => {
- await Database.updateUser(user.id, {
- bio: profileForm.bio,
- email: profileForm.email,
- })
- setCurrentUser({ ...currentUser, bio: profileForm.bio, email: profileForm.email })
- setEditingProfile(false)
- toast.success('Profile updated successfully')
- }
-
- const handlePostComment = async () => {
- if (!newComment.trim()) {
- toast.error('Comment cannot be empty')
- return
- }
-
- const comment: Comment = {
- id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
- userId: user.id,
- content: newComment,
- createdAt: Date.now(),
- }
-
- await Database.addComment(comment)
- setComments((current) => [...current, comment])
- setNewComment('')
- toast.success('Comment posted')
- }
-
- const handleDeleteComment = async (commentId: string) => {
- await Database.deleteComment(commentId)
- setComments((current) => current.filter(c => c.id !== commentId))
- toast.success('Comment deleted')
- }
-
- const handleRequestPasswordReset = async () => {
- const newPassword = generateScrambledPassword(16)
- const passwordHash = await hashPassword(newPassword)
- await Database.setCredential(currentUser.username, passwordHash)
-
- const smtpConfig = await Database.getSMTPConfig()
- await simulateEmailSend(
- currentUser.email,
- 'Your New MetaBuilder Password',
- `Your password has been reset at your request.\n\nUsername: ${currentUser.username}\nNew Password: ${newPassword}\n\nPlease login with this password and change it from your profile settings if desired.`,
- smtpConfig || undefined
- )
-
- toast.success('New password sent to your email! Check console (simulated email)')
- }
-
- const userComments = comments.filter(c => c.userId === user.id)
- const allComments = comments
+ const {
+ comments,
+ currentUser,
+ editingProfile,
+ newComment,
+ profileForm,
+ users,
+ setEditingProfile,
+ setNewComment,
+ setProfileForm,
+ handleDeleteComment,
+ handlePostComment,
+ handleProfileSave,
+ handleRequestPasswordReset,
+ } = useLevel2State(user as User)
return (
@@ -114,8 +43,12 @@ export function Level2({ user, onLogout, onNavigate }: Level2Props) {
variant="user"
/>
-
-
User Dashboard
+
+
@@ -134,7 +67,7 @@ export function Level2({ user, onLogout, onNavigate }: Level2Props) {
-
-
-
- Post a Comment
- Share your thoughts with the community
-
-
-
-
-
-
-
-
-
+
diff --git a/frontends/nextjs/src/components/level/levels/Level3.tsx b/frontends/nextjs/src/components/level/levels/Level3.tsx
index 18dc32796..27e65984d 100644
--- a/frontends/nextjs/src/components/level/levels/Level3.tsx
+++ b/frontends/nextjs/src/components/level/levels/Level3.tsx
@@ -1,33 +1,19 @@
"use client"
-import { useState, useEffect } from 'react'
-import { Button } from '@/components/ui'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui'
+import { useEffect, useState } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import { MagnifyingGlass, Plus, PencilSimple, Trash, Users, ChatCircle } from '@phosphor-icons/react'
+import { Users, ChatCircle } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { getUsers, deleteUser, updateUser } from '@/lib/db/users'
import { getComments, deleteComment } from '@/lib/db/comments'
import { AppHeader } from '../../shared/AppHeader'
import type { User as UserType, Comment } from '@/lib/level-types'
import type { ModelSchema } from '@/lib/schema-types'
+import { IntroSection } from '../sections/IntroSection'
+import { Level3Stats } from '../level3/Level3Stats'
+import { UserTable } from '../level3/UserTable'
+import { CommentsTable } from '../level3/CommentsTable'
+import { EditUserDialog } from '../level3/EditUserDialog'
interface Level3Props {
user: UserType
@@ -39,8 +25,8 @@ export function Level3({ user, onLogout, onNavigate }: Level3Props) {
const [users, setUsers] = useState
([])
const [comments, setComments] = useState([])
const [searchTerm, setSearchTerm] = useState('')
- const [selectedModel, setSelectedModel] = useState<'users' | 'comments'>('users')
- const [editingItem, setEditingItem] = useState(null)
+ const [selectedModel, setSelectedModel] = useState('users' as ModelSchema)
+ const [editingItem, setEditingItem] = useState(null)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
@@ -53,21 +39,9 @@ export function Level3({ user, onLogout, onNavigate }: Level3Props) {
loadData()
}, [])
- const allUsers = users
- const allComments = comments
-
- const filteredUsers = allUsers.filter(u =>
- u.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
- u.email.toLowerCase().includes(searchTerm.toLowerCase())
- )
-
- const filteredComments = allComments.filter(c =>
- c.content.toLowerCase().includes(searchTerm.toLowerCase())
- )
-
const handleDeleteUser = async (userId: string) => {
if (userId === user.id) {
- toast.error("You cannot delete your own account")
+ toast.error('You cannot delete your own account')
return
}
await deleteUser(userId)
@@ -88,11 +62,9 @@ export function Level3({ user, onLogout, onNavigate }: Level3Props) {
const handleSaveUser = async () => {
if (!editingItem) return
-
+
await updateUser(editingItem.id, editingItem)
- setUsers((current) =>
- current.map(u => u.id === editingItem.id ? editingItem : u)
- )
+ setUsers((current) => current.map(u => u.id === editingItem.id ? editingItem : u))
setDialogOpen(false)
setEditingItem(null)
toast.success('User updated')
@@ -109,227 +81,58 @@ export function Level3({ user, onLogout, onNavigate }: Level3Props) {
variant="admin"
/>
-
-
-
Data Management
-
Manage all application data and users
-
+
+
-
-
-
- Total Users
-
-
-
- {allUsers.length}
- Registered accounts
-
-
+
-
-
- Total Comments
-
-
-
- {allComments.length}
- Posted by users
-
-
+
setSelectedModel(v as ModelSchema)}>
+
+
+
+ Users ({users.length})
+
+
+
+ Comments ({comments.length})
+
+
-
-
- Admins
-
-
-
-
- {allUsers.filter(u => u.role === 'admin' || u.role === 'god').length}
-
- Admin & god users
-
-
-
+
+
+
-
-
-
-
- Models
- Browse and manage data models
-
-
-
-
- setSearchTerm(e.target.value)}
- className="pl-9 w-64"
- />
-
-
-
-
-
- setSelectedModel(v as any)}>
-
-
-
- Users ({allUsers.length})
-
-
-
- Comments ({allComments.length})
-
-
-
-
-
-
-
- Username
- Email
- Role
- Created
- Actions
-
-
-
- {filteredUsers.length === 0 ? (
-
-
- No users found
-
-
- ) : (
- filteredUsers.map((u) => (
-
- {u.username}
- {u.email}
-
-
- {u.role}
-
-
- {new Date(u.createdAt).toLocaleDateString()}
-
-
-
handleEditUser(u)}
- >
-
-
-
handleDeleteUser(u.id)}
- disabled={u.id === user.id}
- >
-
-
-
-
-
- ))
- )}
-
-
-
-
-
-
-
-
- User
- Content
- Created
- Actions
-
-
-
- {filteredComments.length === 0 ? (
-
-
- No comments found
-
-
- ) : (
- filteredComments.map((c) => {
- const commentUser = allUsers.find(u => u.id === c.userId)
- return (
-
-
- {commentUser?.username || 'Unknown'}
-
- {c.content}
- {new Date(c.createdAt).toLocaleDateString()}
-
- handleDeleteComment(c.id)}
- >
-
-
-
-
- )
- })
- )}
-
-
-
-
-
-
+
+
+
+
-
-
-
- Edit User
- Update user information
-
- {editingItem && (
-
-
- Username
- setEditingItem({ ...editingItem, username: e.target.value })}
- />
-
-
- Email
- setEditingItem({ ...editingItem, email: e.target.value })}
- />
-
-
- Bio
- setEditingItem({ ...editingItem, bio: e.target.value })}
- />
-
-
- setDialogOpen(false)}>
- Cancel
-
-
- Save Changes
-
-
-
- )}
-
-
+
setEditingItem(item)}
+ onSave={handleSaveUser}
+ />
)
}
diff --git a/frontends/nextjs/src/components/level/levels/Level4.tsx b/frontends/nextjs/src/components/level/levels/Level4.tsx
index be74a350a..b725877f8 100644
--- a/frontends/nextjs/src/components/level/levels/Level4.tsx
+++ b/frontends/nextjs/src/components/level/levels/Level4.tsx
@@ -6,6 +6,7 @@ import { Level4Summary } from '../../level4/Level4Summary'
import { NerdModeIDE } from '../../misc/NerdModeIDE'
import type { User as UserType } from '@/lib/level-types'
import { useLevel4AppState } from './hooks/useLevel4AppState'
+import { IntroSection } from '../sections/IntroSection'
interface Level4Props {
user: UserType
@@ -42,16 +43,16 @@ export function Level4({ user, onLogout, onNavigate, onPreview }: Level4Props) {
onImportConfig={handleImportConfig}
/>
-
-
-
Application Builder
-
- {nerdMode
- ? "Design your application declaratively. Define schemas, create workflows, and write Lua scripts."
- : "Build your application visually. Configure pages, users, and data models with simple forms."
- }
-
-
+
+
void
}
-export function Level5({ user, onLogout, onNavigate, onPreview }: Level5Props) {
- const [tenants, setTenants] = useState([])
- const [allUsers, setAllUsers] = useState([])
- const [godUsers, setGodUsers] = useState([])
- const [transferRefresh, setTransferRefresh] = useState(0)
- const [showTransferDialog, setShowTransferDialog] = useState(false)
- const [showConfirmTransfer, setShowConfirmTransfer] = useState(false)
- const [selectedUserId, setSelectedUserId] = useState('')
- const [newTenantName, setNewTenantName] = useState('')
- const [showCreateTenant, setShowCreateTenant] = useState(false)
- const [nerdMode, setNerdMode] = useKV('level5-nerd-mode', false)
-
- useEffect(() => {
- loadData()
- }, [])
-
- const loadData = async () => {
- const [tenantsData, usersData] = await Promise.all([
- Database.getTenants(),
- fetchUsers(),
- ])
-
- setTenants(tenantsData)
- setAllUsers(usersData)
- setGodUsers(usersData.filter(u => u.role === 'god'))
- }
-
- const handleCreateTenant = async () => {
- if (!newTenantName.trim()) {
- toast.error('Tenant name is required')
- return
- }
-
- const newTenant: Tenant = {
- id: `tenant_${Date.now()}`,
- name: newTenantName,
- ownerId: user.id,
- createdAt: Date.now(),
- }
-
- await Database.addTenant(newTenant)
- setTenants(current => [...current, newTenant])
- setNewTenantName('')
- setShowCreateTenant(false)
- toast.success('Tenant created successfully')
- }
-
- const handleAssignHomepage = async (tenantId: string, pageId: string) => {
- await Database.updateTenant(tenantId, {
- homepageConfig: { pageId },
- })
- await loadData()
- toast.success('Homepage assigned to tenant')
- }
-
- const handleInitiateTransfer = (userId: string) => {
- if (!userId) {
- toast.error('Please select a user to transfer power to')
- return
- }
- setSelectedUserId(userId)
- setShowConfirmTransfer(true)
- }
-
- const handleConfirmTransfer = async () => {
- if (!selectedUserId) return
-
- const targetUser = allUsers.find((u) => u.id === selectedUserId)
- if (!targetUser) {
- toast.error('Selected user not found')
- setShowConfirmTransfer(false)
- return
- }
-
- try {
- await createPowerTransferRequest({
- fromUserId: user.id,
- toUserId: selectedUserId,
- })
-
- toast.success(
- `Power transferred to ${targetUser.username}. You are now a God user and will be logged out shortly.`
- )
- setTransferRefresh((prev) => prev + 1)
- await loadData()
-
- setTimeout(() => {
- onLogout()
- }, 2000)
- } catch (error) {
- toast.error('Failed to transfer power: ' + (error as Error).message)
- } finally {
- setShowConfirmTransfer(false)
- setSelectedUserId('')
- }
- }
-
- const handleDeleteTenant = async (tenantId: string) => {
- await Database.deleteTenant(tenantId)
- setTenants(current => current.filter(t => t.id !== tenantId))
- toast.success('Tenant deleted')
- }
-
- const handleToggleNerdMode = () => {
- setNerdMode(!nerdMode)
- toast.info(nerdMode ? 'Nerd Mode disabled' : 'Nerd Mode enabled')
- }
+export function Level5({ user, onLogout, onNavigate: _onNavigate, onPreview }: Level5Props) {
+ const {
+ tenants,
+ allUsers,
+ godUsers,
+ transferRefresh,
+ showConfirmTransfer,
+ selectedUserId,
+ newTenantName,
+ showCreateTenant,
+ nerdMode,
+ setNewTenantName,
+ setShowCreateTenant,
+ setShowConfirmTransfer,
+ setSelectedUserId,
+ handleAssignHomepage,
+ handleConfirmTransfer,
+ handleCreateTenant,
+ handleDeleteTenant,
+ handleInitiateTransfer,
+ handleToggleNerdMode,
+ } = useLevel5State({ user, onLogout })
return (
@@ -163,130 +48,42 @@ export function Level5({ user, onLogout, onNavigate, onPreview }: Level5Props) {
onToggleNerdMode={handleToggleNerdMode}
/>
-
-
-
-
-
- Tenants
-
-
-
- God Users
-
-
-
- Power Transfer
-
-
-
- Preview Levels
-
-
-
- Screenshot
-
-
-
- Error Logs
-
-
+
+
-
- setShowCreateTenant(true)}
- onDeleteTenant={handleDeleteTenant}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ setShowCreateTenant(true)}
+ onDeleteTenant={handleDeleteTenant}
+ onAssignHomepage={handleAssignHomepage}
+ onInitiateTransfer={handleInitiateTransfer}
+ onPreview={onPreview}
+ />
-
-
-
- Create New Tenant
-
- Create a new tenant instance with its own homepage configuration
-
-
-
-
- Tenant Name
- setNewTenantName(e.target.value)}
- placeholder="Enter tenant name"
- className="bg-white/5 border-white/10 text-white"
- />
-
-
-
- setShowCreateTenant(false)} className="border-white/20 text-white hover:bg-white/10">
- Cancel
-
-
- Create
-
-
-
-
+
-
-
-
-
-
- Confirm Power Transfer
-
-
- Are you absolutely sure? This will transfer your Super God privileges to{' '}
-
- {allUsers.find(u => u.id === selectedUserId)?.username}
-
- . You will be downgraded to God level and cannot reverse this action.
-
-
-
-
- Cancel
-
-
- Transfer Power
-
-
-
-
+
{nerdMode && (
diff --git a/frontends/nextjs/src/components/level/levels/hooks/useLevel2State.ts b/frontends/nextjs/src/components/level/levels/hooks/useLevel2State.ts
new file mode 100644
index 000000000..df2809f6c
--- /dev/null
+++ b/frontends/nextjs/src/components/level/levels/hooks/useLevel2State.ts
@@ -0,0 +1,93 @@
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+import { Database, hashPassword } from '@/lib/database'
+import { generateScrambledPassword, simulateEmailSend } from '@/lib/password-utils'
+import type { Comment, User } from '@/lib/level-types'
+
+export function useLevel2State(user: User) {
+ const [currentUser, setCurrentUser] = useState
(user)
+ const [users, setUsers] = useState([])
+ const [comments, setComments] = useState([])
+ const [newComment, setNewComment] = useState('')
+ const [editingProfile, setEditingProfile] = useState(false)
+ const [profileForm, setProfileForm] = useState({ bio: user.bio || '', email: user.email })
+
+ useEffect(() => {
+ const loadData = async () => {
+ const loadedUsers = await Database.getUsers({ scope: 'all' })
+ setUsers(loadedUsers)
+ const foundUser = loadedUsers.find(u => u.id === user.id)
+ if (foundUser) {
+ setCurrentUser(foundUser)
+ setProfileForm({ bio: foundUser.bio || '', email: foundUser.email })
+ }
+ const loadedComments = await Database.getComments()
+ setComments(loadedComments)
+ }
+ void loadData()
+ }, [user.id])
+
+ const handleProfileSave = async () => {
+ await Database.updateUser(user.id, { bio: profileForm.bio, email: profileForm.email })
+ setCurrentUser({ ...currentUser, bio: profileForm.bio, email: profileForm.email })
+ setEditingProfile(false)
+ toast.success('Profile updated successfully')
+ }
+
+ const handlePostComment = async () => {
+ if (!newComment.trim()) {
+ toast.error('Comment cannot be empty')
+ return
+ }
+
+ const comment: Comment = {
+ id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ userId: user.id,
+ content: newComment,
+ createdAt: Date.now(),
+ }
+
+ await Database.addComment(comment)
+ setComments((current) => [...current, comment])
+ setNewComment('')
+ toast.success('Comment posted')
+ }
+
+ const handleDeleteComment = async (commentId: string) => {
+ await Database.deleteComment(commentId)
+ setComments((current) => current.filter(c => c.id !== commentId))
+ toast.success('Comment deleted')
+ }
+
+ const handleRequestPasswordReset = async () => {
+ const newPassword = generateScrambledPassword(16)
+ const passwordHash = await hashPassword(newPassword)
+ await Database.setCredential(currentUser.username, passwordHash)
+
+ const smtpConfig = await Database.getSMTPConfig()
+ await simulateEmailSend(
+ currentUser.email,
+ 'Your New MetaBuilder Password',
+ `Your password has been reset at your request.\n\nUsername: ${currentUser.username}\nNew Password: ${newPassword}\n\nPlease login with this password and change it from your profile settings if desired.`,
+ smtpConfig || undefined
+ )
+
+ toast.success('New password sent to your email! Check console (simulated email)')
+ }
+
+ return {
+ comments,
+ currentUser,
+ editingProfile,
+ newComment,
+ profileForm,
+ users,
+ setEditingProfile,
+ setNewComment,
+ setProfileForm,
+ handleDeleteComment,
+ handlePostComment,
+ handleProfileSave,
+ handleRequestPasswordReset,
+ }
+}
diff --git a/frontends/nextjs/src/components/level/levels/hooks/useLevel5State.ts b/frontends/nextjs/src/components/level/levels/hooks/useLevel5State.ts
new file mode 100644
index 000000000..cf277b87e
--- /dev/null
+++ b/frontends/nextjs/src/components/level/levels/hooks/useLevel5State.ts
@@ -0,0 +1,133 @@
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+import { useKV } from '@github/spark/hooks'
+import { Database } from '@/lib/database'
+import { createPowerTransferRequest } from '@/lib/api/power-transfers'
+import { fetchUsers } from '@/lib/api/users/fetch-users'
+import type { Tenant, User } from '@/lib/level-types'
+
+interface Level5StateOptions {
+ user: User
+ onLogout: () => void
+}
+
+export function useLevel5State({ user, onLogout }: Level5StateOptions) {
+ const [tenants, setTenants] = useState([])
+ const [allUsers, setAllUsers] = useState([])
+ const [godUsers, setGodUsers] = useState([])
+ const [transferRefresh, setTransferRefresh] = useState(0)
+ const [showConfirmTransfer, setShowConfirmTransfer] = useState(false)
+ const [selectedUserId, setSelectedUserId] = useState('')
+ const [newTenantName, setNewTenantName] = useState('')
+ const [showCreateTenant, setShowCreateTenant] = useState(false)
+ const [nerdMode, setNerdMode] = useKV('level5-nerd-mode', false)
+
+ useEffect(() => {
+ void loadData()
+ }, [])
+
+ const loadData = async () => {
+ const [tenantsData, usersData] = await Promise.all([Database.getTenants(), fetchUsers()])
+
+ setTenants(tenantsData)
+ setAllUsers(usersData)
+ setGodUsers(usersData.filter(u => u.role === 'god'))
+ }
+
+ const handleCreateTenant = async () => {
+ if (!newTenantName.trim()) {
+ toast.error('Tenant name is required')
+ return
+ }
+
+ const newTenant: Tenant = {
+ id: `tenant_${Date.now()}`,
+ name: newTenantName,
+ ownerId: user.id,
+ createdAt: Date.now(),
+ }
+
+ await Database.addTenant(newTenant)
+ setTenants(current => [...current, newTenant])
+ setNewTenantName('')
+ setShowCreateTenant(false)
+ toast.success('Tenant created successfully')
+ }
+
+ const handleAssignHomepage = async (tenantId: string, pageId: string) => {
+ await Database.updateTenant(tenantId, { homepageConfig: { pageId } })
+ await loadData()
+ toast.success('Homepage assigned to tenant')
+ }
+
+ const handleInitiateTransfer = (userId: string) => {
+ if (!userId) {
+ toast.error('Please select a user to transfer power to')
+ return
+ }
+ setSelectedUserId(userId)
+ setShowConfirmTransfer(true)
+ }
+
+ const handleConfirmTransfer = async () => {
+ if (!selectedUserId) return
+
+ const targetUser = allUsers.find((u) => u.id === selectedUserId)
+ if (!targetUser) {
+ toast.error('Selected user not found')
+ setShowConfirmTransfer(false)
+ return
+ }
+
+ try {
+ await createPowerTransferRequest({ fromUserId: user.id, toUserId: selectedUserId })
+
+ toast.success(`Power transferred to ${targetUser.username}. You are now a God user and will be logged out shortly.`)
+ setTransferRefresh((prev) => prev + 1)
+ await loadData()
+
+ setTimeout(() => {
+ onLogout()
+ }, 2000)
+ } catch (error) {
+ toast.error('Failed to transfer power: ' + (error as Error).message)
+ } finally {
+ setShowConfirmTransfer(false)
+ setSelectedUserId('')
+ }
+ }
+
+ const handleDeleteTenant = async (tenantId: string) => {
+ await Database.deleteTenant(tenantId)
+ setTenants(current => current.filter(t => t.id !== tenantId))
+ toast.success('Tenant deleted')
+ }
+
+ const handleToggleNerdMode = () => {
+ setNerdMode(!nerdMode)
+ toast.info(nerdMode ? 'Nerd Mode disabled' : 'Nerd Mode enabled')
+ }
+
+ return {
+ tenants,
+ allUsers,
+ godUsers,
+ transferRefresh,
+ showConfirmTransfer,
+ selectedUserId,
+ newTenantName,
+ showCreateTenant,
+ nerdMode,
+ setNewTenantName,
+ setShowCreateTenant,
+ setShowConfirmTransfer,
+ setSelectedUserId,
+ handleAssignHomepage,
+ handleConfirmTransfer,
+ handleCreateTenant,
+ handleDeleteTenant,
+ handleInitiateTransfer,
+ handleToggleNerdMode,
+ loadData,
+ }
+}
diff --git a/frontends/nextjs/src/components/level/sections/ChallengePanel.tsx b/frontends/nextjs/src/components/level/sections/ChallengePanel.tsx
new file mode 100644
index 000000000..720497e8e
--- /dev/null
+++ b/frontends/nextjs/src/components/level/sections/ChallengePanel.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+
+interface ChallengePanelProps {
+ title: string
+ description?: ReactNode
+ actions?: ReactNode
+ children: ReactNode
+}
+
+export function ChallengePanel({ title, description, actions, children }: ChallengePanelProps) {
+ return (
+
+
+
+ {title}
+ {description && {description} }
+
+ {actions && {actions}
}
+
+ {children}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/sections/IntroSection.tsx b/frontends/nextjs/src/components/level/sections/IntroSection.tsx
new file mode 100644
index 000000000..c91967631
--- /dev/null
+++ b/frontends/nextjs/src/components/level/sections/IntroSection.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from 'react'
+
+interface IntroSectionProps {
+ title: string
+ description?: ReactNode
+ eyebrow?: string
+ actions?: ReactNode
+ children?: ReactNode
+ id?: string
+}
+
+export function IntroSection({ title, description, eyebrow, actions, children, id }: IntroSectionProps) {
+ return (
+
+
+
+ {eyebrow &&
{eyebrow}
}
+
{title}
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ {children && {children}
}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/level/sections/ResultsPane.tsx b/frontends/nextjs/src/components/level/sections/ResultsPane.tsx
new file mode 100644
index 000000000..39f13bebc
--- /dev/null
+++ b/frontends/nextjs/src/components/level/sections/ResultsPane.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from 'react'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+
+interface ResultsPaneProps {
+ title: string
+ description?: ReactNode
+ children: ReactNode
+}
+
+export function ResultsPane({ title, description, children }: ResultsPaneProps) {
+ return (
+
+
+ {title}
+ {description && {description} }
+
+ {children}
+
+ )
+}
From 871b84ebf4bbede8638f215b2a6461a5f08c120b Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:46:06 +0000
Subject: [PATCH 22/80] refactor: modularize level4 tabs
---
.../src/components/level4/Level4Tabs.tsx | 180 ++----------------
.../src/components/level4/tabs/TabContent.tsx | 153 +++++++++++++++
.../src/components/level4/tabs/config.ts | 59 ++++++
3 files changed, 233 insertions(+), 159 deletions(-)
create mode 100644 frontends/nextjs/src/components/level4/tabs/TabContent.tsx
create mode 100644 frontends/nextjs/src/components/level4/tabs/config.ts
diff --git a/frontends/nextjs/src/components/level4/Level4Tabs.tsx b/frontends/nextjs/src/components/level4/Level4Tabs.tsx
index 69ec06924..bba0cac5d 100644
--- a/frontends/nextjs/src/components/level4/Level4Tabs.tsx
+++ b/frontends/nextjs/src/components/level4/Level4Tabs.tsx
@@ -1,23 +1,7 @@
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import { Database as DatabaseIcon, Lightning, Code, BookOpen, HardDrives, MapTrifold, Tree, Users, Gear, Palette, ListDashes, Sparkle, Package, SquaresFour, Warning } from '@phosphor-icons/react'
-import { SchemaEditorLevel4 } from '@/components/SchemaEditorLevel4'
-import { WorkflowEditor } from '@/components/WorkflowEditor'
-import { LuaEditor } from '@/components/editors/lua/LuaEditor'
-import { LuaBlocksEditor } from '@/components/editors/lua/LuaBlocksEditor'
-import { LuaSnippetLibrary } from '@/components/editors/lua/LuaSnippetLibrary'
-import { DatabaseManager } from '@/components/managers/database/DatabaseManager'
-import { PageRoutesManager } from '@/components/managers/PageRoutesManager'
-import { ComponentHierarchyEditor } from '@/components/ComponentHierarchyEditor'
-import { UserManagement } from '@/components/UserManagement'
-import { GodCredentialsSettings } from '@/components/GodCredentialsSettings'
-import { CssClassManager } from '@/components/CssClassManager'
-import { DropdownConfigManager } from '@/components/DropdownConfigManager'
-import { QuickGuide } from '@/components/QuickGuide'
-import { PackageManager } from '@/components/PackageManager'
-import { ThemeEditor } from '@/components/ThemeEditor'
-import { SMTPConfigEditor } from '@/components/SMTPConfigEditor'
-import { ErrorLogsTab } from '@/components/level5/tabs/error-logs/ErrorLogsTab'
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui'
import type { AppConfiguration, User } from '@/lib/level-types'
+import { level4TabsConfig } from './tabs/config'
+import { TabContent } from './tabs/TabContent'
interface Level4TabsProps {
appConfig: AppConfiguration
@@ -36,153 +20,31 @@ export function Level4Tabs({
onWorkflowsChange,
onLuaScriptsChange,
}: Level4TabsProps) {
+ const visibleTabs = level4TabsConfig.filter((tab) => (tab.nerdOnly ? nerdMode : true))
+
return (
-
-
- Guide
-
-
-
- Packages
-
-
-
- Page Routes
-
-
-
- Components
-
-
-
- Users
-
-
-
- Schemas
-
- {nerdMode && (
- <>
-
-
- Workflows
-
-
-
- Lua Scripts
-
-
-
- Lua Blocks
-
-
-
- Snippets
-
-
-
- CSS Classes
-
-
-
- Dropdowns
-
-
-
- Database
-
- >
- )}
-
-
- Settings
-
-
-
- Error Logs
-
+ {visibleTabs.map((tab) => (
+
+
+ {tab.label}
+
+ ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- (
+
-
-
- {nerdMode && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
-
-
-
-
-
-
-
-
+ ))}
)
}
diff --git a/frontends/nextjs/src/components/level4/tabs/TabContent.tsx b/frontends/nextjs/src/components/level4/tabs/TabContent.tsx
new file mode 100644
index 000000000..1ae86b581
--- /dev/null
+++ b/frontends/nextjs/src/components/level4/tabs/TabContent.tsx
@@ -0,0 +1,153 @@
+import { TabsContent } from '@/components/ui'
+import { SchemaEditorLevel4 } from '@/components/SchemaEditorLevel4'
+import { WorkflowEditor } from '@/components/WorkflowEditor'
+import { LuaEditor } from '@/components/editors/lua/LuaEditor'
+import { LuaBlocksEditor } from '@/components/editors/lua/LuaBlocksEditor'
+import { LuaSnippetLibrary } from '@/components/editors/lua/LuaSnippetLibrary'
+import { DatabaseManager } from '@/components/managers/database/DatabaseManager'
+import { PageRoutesManager } from '@/components/managers/PageRoutesManager'
+import { ComponentHierarchyEditor } from '@/components/ComponentHierarchyEditor'
+import { UserManagement } from '@/components/UserManagement'
+import { GodCredentialsSettings } from '@/components/GodCredentialsSettings'
+import { CssClassManager } from '@/components/CssClassManager'
+import { DropdownConfigManager } from '@/components/DropdownConfigManager'
+import { QuickGuide } from '@/components/QuickGuide'
+import { PackageManager } from '@/components/PackageManager'
+import { ThemeEditor } from '@/components/ThemeEditor'
+import { SMTPConfigEditor } from '@/components/SMTPConfigEditor'
+import { ErrorLogsTab } from '@/components/level5/tabs/error-logs/ErrorLogsTab'
+import type { AppConfiguration, User } from '@/lib/level-types'
+
+import type { Level4TabConfig } from './config'
+
+interface Level4TabContentProps {
+ tab: Level4TabConfig
+ appConfig: AppConfiguration
+ user: User
+ nerdMode: boolean
+ onSchemasChange: (schemas: any[]) => Promise
+ onWorkflowsChange: (workflows: any[]) => Promise
+ onLuaScriptsChange: (scripts: any[]) => Promise
+}
+
+export function TabContent({
+ tab,
+ appConfig,
+ user,
+ nerdMode,
+ onSchemasChange,
+ onWorkflowsChange,
+ onLuaScriptsChange,
+}: Level4TabContentProps) {
+ if (tab.nerdOnly && !nerdMode) return null
+
+ switch (tab.value) {
+ case 'guide':
+ return (
+
+
+
+ )
+ case 'packages':
+ return (
+
+
+
+ )
+ case 'pages':
+ return (
+
+
+
+ )
+ case 'hierarchy':
+ return (
+
+
+
+ )
+ case 'users':
+ return (
+
+
+
+ )
+ case 'schemas':
+ return (
+
+
+
+ )
+ case 'workflows':
+ return (
+
+
+
+ )
+ case 'lua':
+ return (
+
+
+
+ )
+ case 'blocks':
+ return (
+
+
+
+ )
+ case 'snippets':
+ return (
+
+
+
+ )
+ case 'css':
+ return (
+
+
+
+ )
+ case 'dropdowns':
+ return (
+
+
+
+ )
+ case 'database':
+ return (
+
+
+
+ )
+ case 'settings':
+ return (
+
+
+
+
+
+ )
+ case 'errorlogs':
+ return (
+
+
+
+ )
+ default:
+ return null
+ }
+}
diff --git a/frontends/nextjs/src/components/level4/tabs/config.ts b/frontends/nextjs/src/components/level4/tabs/config.ts
new file mode 100644
index 000000000..1dd2eec26
--- /dev/null
+++ b/frontends/nextjs/src/components/level4/tabs/config.ts
@@ -0,0 +1,59 @@
+import {
+ BookOpen,
+ Code,
+ Database as DatabaseIcon,
+ Gear,
+ HardDrives,
+ Lightning,
+ ListDashes,
+ MapTrifold,
+ Package,
+ Palette,
+ Sparkle,
+ SquaresFour,
+ Tree,
+ Users,
+ Warning,
+} from '@phosphor-icons/react'
+
+export type Level4TabValue =
+ | 'guide'
+ | 'packages'
+ | 'pages'
+ | 'hierarchy'
+ | 'users'
+ | 'schemas'
+ | 'workflows'
+ | 'lua'
+ | 'blocks'
+ | 'snippets'
+ | 'css'
+ | 'dropdowns'
+ | 'database'
+ | 'settings'
+ | 'errorlogs'
+
+export interface Level4TabConfig {
+ value: Level4TabValue
+ label: string
+ icon: typeof DatabaseIcon
+ nerdOnly?: boolean
+}
+
+export const level4TabsConfig: Level4TabConfig[] = [
+ { value: 'guide', label: 'Guide', icon: Sparkle },
+ { value: 'packages', label: 'Packages', icon: Package },
+ { value: 'pages', label: 'Page Routes', icon: MapTrifold },
+ { value: 'hierarchy', label: 'Components', icon: Tree },
+ { value: 'users', label: 'Users', icon: Users },
+ { value: 'schemas', label: 'Schemas', icon: DatabaseIcon },
+ { value: 'workflows', label: 'Workflows', icon: Lightning, nerdOnly: true },
+ { value: 'lua', label: 'Lua Scripts', icon: Code, nerdOnly: true },
+ { value: 'blocks', label: 'Lua Blocks', icon: SquaresFour, nerdOnly: true },
+ { value: 'snippets', label: 'Snippets', icon: BookOpen, nerdOnly: true },
+ { value: 'css', label: 'CSS Classes', icon: Palette, nerdOnly: true },
+ { value: 'dropdowns', label: 'Dropdowns', icon: ListDashes, nerdOnly: true },
+ { value: 'database', label: 'Database', icon: HardDrives, nerdOnly: true },
+ { value: 'settings', label: 'Settings', icon: Gear },
+ { value: 'errorlogs', label: 'Error Logs', icon: Warning },
+]
From 1523cf735c96c2922201237aa9ca15ff185ae329 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:47:02 +0000
Subject: [PATCH 23/80] refactor: extract power transfer sections
---
.../tabs/power-transfer/PowerTransferTab.tsx | 135 ++--------------
.../level5/tabs/power-transfer/sections.tsx | 148 ++++++++++++++++++
2 files changed, 164 insertions(+), 119 deletions(-)
create mode 100644 frontends/nextjs/src/components/level5/tabs/power-transfer/sections.tsx
diff --git a/frontends/nextjs/src/components/level5/tabs/power-transfer/PowerTransferTab.tsx b/frontends/nextjs/src/components/level5/tabs/power-transfer/PowerTransferTab.tsx
index 20a91aba2..3f8157ed6 100644
--- a/frontends/nextjs/src/components/level5/tabs/power-transfer/PowerTransferTab.tsx
+++ b/frontends/nextjs/src/components/level5/tabs/power-transfer/PowerTransferTab.tsx
@@ -1,15 +1,11 @@
'use client'
import { useEffect, useState } from 'react'
-import { Button } from '@/components/ui'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { Separator } from '@/components/ui'
-import { Alert, AlertDescription } from '@/components/ui'
-import { Crown, ArrowsLeftRight } from '@phosphor-icons/react'
+import { ArrowsLeftRight } from '@phosphor-icons/react'
+import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator } from '@/components/ui'
import type { PowerTransferRequest, User } from '@/lib/level-types'
import { fetchPowerTransferRequests } from '@/lib/api/power-transfers'
+import { CriticalActionNotice, TransferHistory, UserSelectionList } from './sections'
interface PowerTransferTabProps {
currentUser: User
@@ -18,12 +14,6 @@ interface PowerTransferTabProps {
refreshSignal?: number
}
-const STATUS_VARIANTS: Record = {
- accepted: 'default',
- pending: 'secondary',
- rejected: 'destructive',
-}
-
export function PowerTransferTab({
currentUser,
allUsers,
@@ -35,9 +25,7 @@ export function PowerTransferTab({
const [isLoadingRequests, setIsLoadingRequests] = useState(true)
const [requestError, setRequestError] = useState(null)
- const highlightedUsers = allUsers.filter(
- (u) => u.id !== currentUser.id && u.role !== 'supergod'
- )
+ const highlightedUsers = allUsers.filter((u) => u.id !== currentUser.id && u.role !== 'supergod')
useEffect(() => {
let isActive = true
@@ -69,22 +57,6 @@ export function PowerTransferTab({
}
}, [refreshSignal])
- const sortedRequests = [...requests].sort((a, b) => b.createdAt - a.createdAt)
-
- const formatDate = (timestamp: number) => {
- return new Date(timestamp).toLocaleString()
- }
-
- const formatExpiry = (expiresAt: number) => {
- const diff = expiresAt - Date.now()
- if (diff <= 0) {
- return 'Expired'
- }
- const minutes = Math.floor(diff / 60000)
- const seconds = Math.floor((diff % 60000) / 1000)
- return `${minutes}m ${seconds}s remaining`
- }
-
const getUserLabel = (userId: string) => {
const user = allUsers.find((u) => u.id === userId)
return user ? user.username : userId
@@ -100,97 +72,22 @@ export function PowerTransferTab({
-
-
-
-
-
Critical Action
-
- This action cannot be undone. Only one Super God can exist at a time. After transfer,
- you will have God-level access only.
-
-
-
-
+
-
-
Select User to Transfer Power To:
-
-
- {highlightedUsers.map((user) => (
-
setSelectedUserId(user.id)}
- >
-
-
-
-
{user.username}
-
{user.email}
-
-
- {user.role}
-
-
-
-
- ))}
-
-
-
+
-
-
-
Recent transfers
- {isLoadingRequests && (
- Refreshing...
- )}
-
-
- {requestError && (
-
- {requestError}
-
- )}
-
-
-
- {sortedRequests.length === 0 && !isLoadingRequests ? (
-
No transfer history available.
- ) : (
- sortedRequests.map((request) => (
-
-
-
-
- Transfer to {getUserLabel(request.toUserId)}
-
-
- Requested by {getUserLabel(request.fromUserId)}
-
-
-
- {request.status.charAt(0).toUpperCase() + request.status.slice(1)}
-
-
-
- Created: {formatDate(request.createdAt)}
- Expires: {formatDate(request.expiresAt)}
- {formatExpiry(request.expiresAt)}
-
-
- ))
- )}
-
-
-
+
onInitiateTransfer(selectedUserId)}
diff --git a/frontends/nextjs/src/components/level5/tabs/power-transfer/sections.tsx b/frontends/nextjs/src/components/level5/tabs/power-transfer/sections.tsx
new file mode 100644
index 000000000..9ca90453b
--- /dev/null
+++ b/frontends/nextjs/src/components/level5/tabs/power-transfer/sections.tsx
@@ -0,0 +1,148 @@
+'use client'
+
+import { Crown } from '@phosphor-icons/react'
+import {
+ Alert,
+ AlertDescription,
+ Badge,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ ScrollArea,
+} from '@/components/ui'
+import type { PowerTransferRequest, User } from '@/lib/level-types'
+
+const STATUS_VARIANTS: Record = {
+ accepted: 'default',
+ pending: 'secondary',
+ rejected: 'destructive',
+}
+
+export const formatDate = (timestamp: number) => new Date(timestamp).toLocaleString()
+
+export const formatExpiry = (expiresAt: number) => {
+ const diff = expiresAt - Date.now()
+ if (diff <= 0) {
+ return 'Expired'
+ }
+ const minutes = Math.floor(diff / 60000)
+ const seconds = Math.floor((diff % 60000) / 1000)
+ return `${minutes}m ${seconds}s remaining`
+}
+
+export function CriticalActionNotice() {
+ return (
+
+
+
+
+
Critical Action
+
+ This action cannot be undone. Only one Super God can exist at a time. After transfer, you
+ will have God-level access only.
+
+
+
+
+ )
+}
+
+interface UserSelectionListProps {
+ users: User[]
+ selectedUserId: string
+ onSelect: (userId: string) => void
+}
+
+export function UserSelectionList({ users, selectedUserId, onSelect }: UserSelectionListProps) {
+ return (
+
+
Select User to Transfer Power To:
+
+
+ {users.map((user) => (
+
onSelect(user.id)}
+ >
+
+
+
+
{user.username}
+
{user.email}
+
+
+ {user.role}
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+interface TransferHistoryProps {
+ requests: PowerTransferRequest[]
+ getUserLabel: (userId: string) => string
+ isLoading: boolean
+ requestError: string | null
+}
+
+export function TransferHistory({ requests, getUserLabel, isLoading, requestError }: TransferHistoryProps) {
+ const sortedRequests = [...requests].sort((a, b) => b.createdAt - a.createdAt)
+
+ return (
+
+
+
Recent transfers
+ {isLoading && Refreshing... }
+
+
+ {requestError && (
+
+ {requestError}
+
+ )}
+
+
+
+ {sortedRequests.length === 0 && !isLoading ? (
+
No transfer history available.
+ ) : (
+ sortedRequests.map((request) => (
+
+
+
+
+ Transfer to {getUserLabel(request.toUserId)}
+
+
+ Requested by {getUserLabel(request.fromUserId)}
+
+
+
+ {request.status.charAt(0).toUpperCase() + request.status.slice(1)}
+
+
+
+ Created: {formatDate(request.createdAt)}
+ Expires: {formatDate(request.expiresAt)}
+ {formatExpiry(request.expiresAt)}
+
+
+ ))
+ )}
+
+
+
+ )
+}
From 1e9a6271ea8d7c6b6bb481123316d5fae8f40a9e Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:47:43 +0000
Subject: [PATCH 24/80] feat: add user management subcomponents
---
.../managers/user-management/AuditTrail.tsx | 149 ++++++++++++++++++
.../managers/user-management/RoleEditor.tsx | 125 +++++++++++++++
.../managers/user-management/UserList.tsx | 149 ++++++++++++++++++
3 files changed, 423 insertions(+)
create mode 100644 frontends/nextjs/src/components/managers/user-management/AuditTrail.tsx
create mode 100644 frontends/nextjs/src/components/managers/user-management/RoleEditor.tsx
create mode 100644 frontends/nextjs/src/components/managers/user-management/UserList.tsx
diff --git a/frontends/nextjs/src/components/managers/user-management/AuditTrail.tsx b/frontends/nextjs/src/components/managers/user-management/AuditTrail.tsx
new file mode 100644
index 000000000..216ebf7c9
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/user-management/AuditTrail.tsx
@@ -0,0 +1,149 @@
+'use client'
+import { useMemo, useState } from 'react'
+import {
+ Badge,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Input,
+ Label,
+ ScrollArea,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui'
+import { Clock, ShieldWarning, UserSwitch } from '@phosphor-icons/react'
+export type AuditSeverity = 'info' | 'warning' | 'critical'
+export interface AuditEvent {
+ id: string
+ actor: string
+ action: string
+ target?: string
+ timestamp: string | number
+ severity: AuditSeverity
+}
+interface AuditTrailProps {
+ events: AuditEvent[]
+ showSearch?: boolean
+ maxRows?: number
+}
+const SEVERITY_META: Record = {
+ info: { label: 'Info', variant: 'secondary' },
+ warning: { label: 'Warning', variant: 'default' },
+ critical: { label: 'Critical', variant: 'destructive' },
+}
+const formatTime = (value: string | number) => new Date(value).toLocaleString()
+export function AuditTrail({ events, showSearch = true, maxRows }: AuditTrailProps) {
+ const [query, setQuery] = useState('')
+ const [severity, setSeverity] = useState('all')
+ const filtered = useMemo(() => {
+ const text = query.toLowerCase()
+ return events
+ .filter((event) => {
+ const matchesText =
+ !text || `${event.actor} ${event.action} ${event.target ?? ''}`.toLowerCase().includes(text)
+ const matchesSeverity = severity === 'all' || event.severity === severity
+ return matchesText && matchesSeverity
+ })
+ .slice(0, maxRows ?? events.length)
+ }, [events, query, severity, maxRows])
+
+ return (
+
+
+
+
+ Audit trail
+ Recent security-sensitive actions.
+
+
+
+ {events.length} events
+
+
+ {showSearch && (
+
+
+ Search
+ setQuery(event.target.value)}
+ />
+
+
+ Severity
+ setSeverity(event.target.value as AuditSeverity | 'all')}
+ >
+ All events
+ {Object.entries(SEVERITY_META).map(([value, meta]) => (
+
+ {meta.label}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+ Timestamp
+ Actor
+ Action
+ Severity
+
+
+
+ {filtered.map((event) => (
+
+
+
+
+ {formatTime(event.timestamp)}
+
+
+ {event.actor}
+
+ {event.action}
+ {event.target && (
+
+ {event.target}
+
+ )}
+
+
+
+
+ {SEVERITY_META[event.severity].label}
+
+
+
+ ))}
+ {filtered.length === 0 && (
+
+
+ No audit events found.
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/user-management/RoleEditor.tsx b/frontends/nextjs/src/components/managers/user-management/RoleEditor.tsx
new file mode 100644
index 000000000..15acb1179
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/user-management/RoleEditor.tsx
@@ -0,0 +1,125 @@
+'use client'
+import {
+ Badge,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Switch,
+} from '@/components/ui'
+import type { UserRole } from '@/lib/level-types'
+
+interface RoleEditorProps {
+ role: UserRole
+ onRoleChange: (role: UserRole) => void
+ isInstanceOwner?: boolean
+ onInstanceOwnerChange?: (value: boolean) => void
+ allowedRoles?: UserRole[]
+}
+
+const ROLE_INFO: Record = {
+ public: {
+ blurb: 'Read-only access for guest viewers.',
+ highlights: ['View public resources', 'No authentication needed'],
+ },
+ user: {
+ blurb: 'Standard workspace member with personal settings.',
+ highlights: ['Create content', 'Access shared libraries'],
+ },
+ moderator: {
+ blurb: 'Content moderator with collaboration tools.',
+ highlights: ['Manage comments', 'Resolve reports', 'Escalate to admins'],
+ },
+ admin: {
+ blurb: 'Tenant-level administrator controls.',
+ highlights: ['Invite users', 'Configure pages', 'Reset credentials'],
+ },
+ god: {
+ blurb: 'Power user with platform configuration access.',
+ highlights: ['Manage integrations', 'Run advanced scripts', 'Override safety flags'],
+ },
+ supergod: {
+ blurb: 'Instance owner with full control.',
+ highlights: ['Edit system settings', 'Manage tenants', 'Bypass feature gates'],
+ },
+}
+
+const roleLabel = (role: UserRole) => role.charAt(0).toUpperCase() + role.slice(1)
+
+export function RoleEditor({
+ role,
+ onRoleChange,
+ isInstanceOwner,
+ onInstanceOwnerChange,
+ allowedRoles,
+}: RoleEditorProps) {
+ const options = allowedRoles ?? (Object.keys(ROLE_INFO) as UserRole[])
+
+ return (
+
+
+ User role
+ Adjust access level and ownership flags.
+
+
+
+
Role
+
onRoleChange(value as UserRole)}>
+
+
+
+
+ {options.map((value) => (
+
+
+ {roleLabel(value)}
+
+ {value === 'public' ? 'Read only' : 'Level access'}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
{roleLabel(role)}
+
{ROLE_INFO[role].blurb}
+
+
{ROLE_INFO[role].highlights.length} capabilities
+
+
+ {ROLE_INFO[role].highlights.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+ {onInstanceOwnerChange && (
+
+
+
Instance owner
+
+ Grants access to backup, billing, and infrastructure settings.
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/user-management/UserList.tsx b/frontends/nextjs/src/components/managers/user-management/UserList.tsx
new file mode 100644
index 000000000..5dd9151c5
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/user-management/UserList.tsx
@@ -0,0 +1,149 @@
+'use client'
+import { useMemo, useState } from 'react'
+import {
+ Avatar,
+ AvatarFallback,
+ Badge,
+ Button,
+ Input,
+ Label,
+ ScrollArea,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui'
+import { FunnelSimple, PencilSimple, Trash } from '@phosphor-icons/react'
+import type { User, UserRole } from '@/lib/level-types'
+interface UserListProps {
+ users: User[]
+ onEdit?: (user: User) => void
+ onDelete?: (user: User) => void
+ compact?: boolean
+}
+const ROLE_STYLES: Record = {
+ public: { label: 'Public', variant: 'outline' },
+ user: { label: 'User', variant: 'outline' },
+ moderator: { label: 'Moderator', variant: 'secondary' },
+ admin: { label: 'Admin', variant: 'secondary' },
+ god: { label: 'God', variant: 'default' },
+ supergod: { label: 'Supergod', variant: 'default' },
+ }
+
+function initials(value: string) {
+ return value
+ .split(' ')
+ .map((chunk) => chunk[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase()
+}
+
+export function UserList({ users, onEdit, onDelete, compact }: UserListProps) {
+ const [query, setQuery] = useState('')
+ const [role, setRole] = useState('all')
+
+ const filtered = useMemo(() => {
+ return users.filter((user) => {
+ const matchesQuery = `${user.username} ${user.email}`
+ .toLowerCase()
+ .includes(query.toLowerCase())
+ const matchesRole = role === 'all' || user.role === role
+ return matchesQuery && matchesRole
+ })
+ }, [users, query, role])
+
+ return (
+
+
+
+ Search users
+ setQuery(event.target.value)}
+ />
+
+
+
+ Role filter
+
+ setRole(event.target.value as UserRole | 'all')}
+ >
+ All roles
+ {Object.entries(ROLE_STYLES).map(([value, meta]) => (
+
+ {meta.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ User
+ Email
+ Role
+ Joined
+ {(onEdit || onDelete) && Actions }
+
+
+
+ {filtered.map((user) => (
+
+
+
+ {user.profilePicture && }
+ {initials(user.username)}
+
+
+
{user.username}
+
ID: {user.id}
+
+
+ {user.email}
+
+ {ROLE_STYLES[user.role]?.label}
+
+
+ {new Date(user.createdAt).toLocaleDateString()}
+
+ {(onEdit || onDelete) && (
+
+ {onEdit && (
+ onEdit(user)}>
+
+
+ )}
+ {onDelete && (
+ onDelete(user)}>
+
+
+ )}
+
+ )}
+
+ ))}
+ {filtered.length === 0 && (
+
+
+ No users match your filters.
+
+
+ )}
+
+
+
+
+ )
+}
From f57b41f86d020a125d72c5aa0be3f0692d30dfc2 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:48:15 +0000
Subject: [PATCH 25/80] refactor: extract dialog fields and hierarchy tree
---
.../component/ComponentConfigDialog.tsx | 230 ++---------------
.../ComponentConfigDialog/Actions.tsx | 20 ++
.../ComponentConfigDialog/Fields.tsx | 238 ++++++++++++++++++
.../component/ComponentHierarchyEditor.tsx | 68 ++---
.../ComponentHierarchyEditor/Tree.tsx | 65 +++++
.../ComponentHierarchyEditor/selectors.ts | 10 +
6 files changed, 364 insertions(+), 267 deletions(-)
create mode 100644 frontends/nextjs/src/components/managers/component/ComponentConfigDialog/Actions.tsx
create mode 100644 frontends/nextjs/src/components/managers/component/ComponentConfigDialog/Fields.tsx
create mode 100644 frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor/Tree.tsx
create mode 100644 frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor/selectors.ts
diff --git a/frontends/nextjs/src/components/managers/component/ComponentConfigDialog.tsx b/frontends/nextjs/src/components/managers/component/ComponentConfigDialog.tsx
index f4da06685..33f5af4f8 100644
--- a/frontends/nextjs/src/components/managers/component/ComponentConfigDialog.tsx
+++ b/frontends/nextjs/src/components/managers/component/ComponentConfigDialog.tsx
@@ -1,24 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
-import { Button } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Label } from '@/components/ui'
-import { Textarea } from '@/components/ui'
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
-import { Switch } from '@/components/ui'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
import { Database, ComponentNode, ComponentConfig } from '@/lib/database'
import { componentCatalog } from '@/lib/components/component-catalog'
import { toast } from 'sonner'
-import type { PropDefinition } from '@/lib/builder-types'
-
-/** Select option type for property schema options */
-interface SelectOption {
- value: string
- label: string
-}
+import { ComponentConfigActions } from './ComponentConfigDialog/Actions'
+import { ComponentConfigFields } from './ComponentConfigDialog/Fields'
interface ComponentConfigDialogProps {
node: ComponentNode
@@ -74,65 +60,6 @@ export function ComponentConfigDialog({ node, isOpen, onClose, onSave, nerdMode
const componentDef = componentCatalog.find(c => c.type === node.type)
- const renderPropEditor = (propSchema: PropDefinition) => {
- const value = props[propSchema.name] ?? propSchema.defaultValue
-
- switch (propSchema.type) {
- case 'string':
- return (
- setProps({ ...props, [propSchema.name]: e.target.value })}
- placeholder={String(propSchema.defaultValue || '')}
- />
- )
-
- case 'number':
- return (
- setProps({ ...props, [propSchema.name]: Number(e.target.value) })}
- />
- )
-
- case 'boolean':
- return (
- setProps({ ...props, [propSchema.name]: checked })}
- />
- )
-
- case 'select':
- return (
- setProps({ ...props, [propSchema.name]: val })}
- >
-
-
-
-
- {propSchema.options?.map((opt: SelectOption) => (
-
- {opt.label}
-
- ))}
-
-
- )
-
- default:
- return (
- setProps({ ...props, [propSchema.name]: e.target.value })}
- />
- )
- }
- }
-
return (
@@ -143,147 +70,18 @@ export function ComponentConfigDialog({ node, isOpen, onClose, onSave, nerdMode
-
-
- Properties
- Styles
- {nerdMode && Events }
-
+
-
-
- {componentDef?.propSchema && componentDef.propSchema.length > 0 ? (
- componentDef.propSchema.map((propSchema) => (
-
- {propSchema.label}
- {renderPropEditor(propSchema)}
-
- ))
- ) : (
-
-
No configurable properties for this component
-
- )}
-
- {nerdMode && (
-
-
- Custom Properties (JSON)
-
- Add additional props as JSON
-
-
-
-
-
- )}
-
-
-
-
- Tailwind Classes
- setProps({ ...props, className: e.target.value })}
- placeholder="p-4 bg-white rounded-lg"
- />
-
-
- {nerdMode && (
-
-
- Custom Styles (CSS-in-JS)
-
- Define inline styles as JSON object
-
-
-
-
-
- )}
-
-
- {nerdMode && (
-
-
-
- Event Handlers
-
- Map events to Lua script IDs
-
-
-
- {['onClick', 'onChange', 'onSubmit', 'onFocus', 'onBlur'].map((eventName) => (
-
- {eventName}
- setEvents({ ...events, [eventName]: e.target.value })}
- placeholder="lua_script_id"
- />
-
- ))}
-
-
-
-
-
- Custom Events (JSON)
-
- Define additional event handlers
-
-
-
-
-
-
- )}
-
-
-
-
- Cancel
- void handleSave()}>Save Configuration
-
+
)
diff --git a/frontends/nextjs/src/components/managers/component/ComponentConfigDialog/Actions.tsx b/frontends/nextjs/src/components/managers/component/ComponentConfigDialog/Actions.tsx
new file mode 100644
index 000000000..925d87bd4
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/component/ComponentConfigDialog/Actions.tsx
@@ -0,0 +1,20 @@
+import { Button } from '@/components/ui'
+import { DialogFooter } from '@/components/ui'
+
+interface ComponentConfigActionsProps {
+ onClose: () => void
+ onSave: () => Promise | void
+}
+
+export function ComponentConfigActions({ onClose, onSave }: ComponentConfigActionsProps) {
+ return (
+
+
+ Cancel
+
+ void onSave()}>
+ Save Configuration
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/component/ComponentConfigDialog/Fields.tsx b/frontends/nextjs/src/components/managers/component/ComponentConfigDialog/Fields.tsx
new file mode 100644
index 000000000..df4c88732
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/component/ComponentConfigDialog/Fields.tsx
@@ -0,0 +1,238 @@
+import { Input } from '@/components/ui'
+import { Label } from '@/components/ui'
+import { Textarea } from '@/components/ui'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
+import { Switch } from '@/components/ui'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+import { ScrollArea } from '@/components/ui'
+import type { ComponentDefinition, PropDefinition } from '@/lib/components/types'
+
+interface SelectOption {
+ value: string
+ label: string
+}
+
+interface ComponentConfigFieldsProps {
+ componentDef?: ComponentDefinition
+ props: Record
+ setProps: (value: Record) => void
+ styles: Record
+ setStyles: (value: Record) => void
+ events: Record
+ setEvents: (value: Record) => void
+ nerdMode: boolean
+}
+
+function renderPropEditor(
+ propSchema: PropDefinition,
+ props: Record,
+ setProps: (value: Record) => void
+) {
+ const value = props[propSchema.name] ?? propSchema.defaultValue
+
+ switch (propSchema.type) {
+ case 'string':
+ return (
+ setProps({ ...props, [propSchema.name]: e.target.value })}
+ placeholder={String(propSchema.defaultValue || '')}
+ />
+ )
+
+ case 'number':
+ return (
+ setProps({ ...props, [propSchema.name]: Number(e.target.value) })}
+ />
+ )
+
+ case 'boolean':
+ return (
+ setProps({ ...props, [propSchema.name]: checked })}
+ />
+ )
+
+ case 'select':
+ return (
+ setProps({ ...props, [propSchema.name]: val })}
+ >
+
+
+
+
+ {propSchema.options?.map((opt: SelectOption) => (
+
+ {opt.label}
+
+ ))}
+
+
+ )
+
+ default:
+ return (
+ setProps({ ...props, [propSchema.name]: e.target.value })}
+ />
+ )
+ }
+}
+
+export function ComponentConfigFields({
+ componentDef,
+ props,
+ setProps,
+ styles,
+ setStyles,
+ events,
+ setEvents,
+ nerdMode,
+}: ComponentConfigFieldsProps) {
+ return (
+
+
+ Properties
+ Styles
+ {nerdMode && Events }
+
+
+
+
+ {componentDef?.propSchema && componentDef.propSchema.length > 0 ? (
+ componentDef.propSchema.map((propSchema) => (
+
+ {propSchema.label}
+ {renderPropEditor(propSchema, props, setProps)}
+
+ ))
+ ) : (
+
+
No configurable properties for this component
+
+ )}
+
+ {nerdMode && (
+
+
+ Custom Properties (JSON)
+
+ Add additional props as JSON
+
+
+
+
+
+ )}
+
+
+
+
+ Tailwind Classes
+ setProps({ ...props, className: e.target.value })}
+ placeholder="p-4 bg-white rounded-lg"
+ />
+
+
+ {nerdMode && (
+
+
+ Custom Styles (CSS-in-JS)
+
+ Define inline styles as JSON object
+
+
+
+
+
+ )}
+
+
+ {nerdMode && (
+
+
+
+ Event Handlers
+
+ Map events to Lua script IDs
+
+
+
+ {['onClick', 'onChange', 'onSubmit', 'onFocus', 'onBlur'].map((eventName) => (
+
+ {eventName}
+ setEvents({ ...events, [eventName]: e.target.value })}
+ placeholder="lua_script_id"
+ />
+
+ ))}
+
+
+
+
+
+ Custom Events (JSON)
+
+ Define additional event handlers
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx
index 00715843f..63218d5c3 100644
--- a/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx
+++ b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx
@@ -6,7 +6,6 @@ import { ScrollArea } from '@/components/ui'
import { Separator } from '@/components/ui'
import {
ArrowsOutCardinal,
- Cursor,
Plus,
Tree,
} from '@phosphor-icons/react'
@@ -14,9 +13,10 @@ import { Database, type ComponentNode } from '@/lib/database'
import { componentCatalog } from '@/lib/components/component-catalog'
import { toast } from 'sonner'
import { ComponentConfigDialog } from './ComponentConfigDialog'
-import { TreeNode } from './modules/TreeNode'
import { useHierarchyData } from './modules/useHierarchyData'
import { useHierarchyDragDrop } from './modules/useHierarchyDragDrop'
+import { HierarchyTree } from './ComponentHierarchyEditor/Tree'
+import { selectRootNodes } from './ComponentHierarchyEditor/selectors'
export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: boolean }) {
const { pages, selectedPageId, setSelectedPageId, hierarchy, loadHierarchy } = useHierarchyData()
@@ -37,10 +37,7 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
const componentIdPrefix = useId()
const rootNodes = useMemo(
- () =>
- Object.values(hierarchy)
- .filter(node => node.pageId === selectedPageId && !node.parentId)
- .sort((a, b) => a.order - b.order),
+ () => selectRootNodes(hierarchy, selectedPageId),
[hierarchy, selectedPageId]
)
@@ -108,50 +105,6 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
[hierarchy, loadHierarchy]
)
- const renderTree = useMemo(
- () =>
- rootNodes.length === 0 ? (
-
-
-
No components yet. Add one from the catalog!
-
- ) : (
-
- {rootNodes.map((node) => (
-
- ))}
-
- ),
- [
- expandedNodes,
- handleDeleteNode,
- handleDragOver,
- handleDragStart,
- handleDrop,
- handleToggleNode,
- hierarchy,
- rootNodes,
- selectedNodeId,
- draggingNodeId,
- setConfigNodeId,
- setSelectedNodeId,
- ]
- )
-
return (
@@ -191,7 +144,20 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
{selectedPageId ? (
- renderTree
+
) : (
Select a page to edit its component hierarchy
diff --git a/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor/Tree.tsx b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor/Tree.tsx
new file mode 100644
index 000000000..5960b5f7c
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor/Tree.tsx
@@ -0,0 +1,65 @@
+import { Cursor } from '@phosphor-icons/react'
+import type React from 'react'
+import type { ComponentNode } from '@/lib/database'
+import { TreeNode } from '../modules/TreeNode'
+
+interface HierarchyTreeProps {
+ rootNodes: ComponentNode[]
+ hierarchy: Record
+ selectedNodeId: string | null
+ expandedNodes: Record
+ draggingNodeId: string | null
+ onSelect: (nodeId: string) => void
+ onToggle: (nodeId: string) => void
+ onDelete: (nodeId: string) => Promise
+ onConfig: (nodeId: string) => void
+ onDragStart: (event: React.DragEvent, nodeId: string) => void
+ onDragOver: (event: React.DragEvent, nodeId: string) => void
+ onDrop: (event: React.DragEvent, nodeId: string) => void
+}
+
+export function HierarchyTree({
+ rootNodes,
+ hierarchy,
+ selectedNodeId,
+ expandedNodes,
+ draggingNodeId,
+ onSelect,
+ onToggle,
+ onDelete,
+ onConfig,
+ onDragStart,
+ onDragOver,
+ onDrop,
+}: HierarchyTreeProps) {
+ if (rootNodes.length === 0) {
+ return (
+
+
+
No components yet. Add one from the catalog!
+
+ )
+ }
+
+ return (
+
+ {rootNodes.map((node) => (
+
+ ))}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor/selectors.ts b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor/selectors.ts
new file mode 100644
index 000000000..383c12303
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor/selectors.ts
@@ -0,0 +1,10 @@
+import type { ComponentNode } from '@/lib/database'
+
+export function selectRootNodes(
+ hierarchy: Record,
+ selectedPageId: string | null
+): ComponentNode[] {
+ return Object.values(hierarchy)
+ .filter(node => node.pageId === selectedPageId && !node.parentId)
+ .sort((a, b) => a.order - b.order)
+}
From cb90ae91b559bf237b6d43f2228d873bb799e98c Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:49:26 +0000
Subject: [PATCH 26/80] refactor: modularize css class builder
---
.../managers/css/CssClassBuilder.tsx | 264 ++++--------------
.../managers/css/class-builder/Preview.tsx | 24 ++
.../managers/css/class-builder/RuleEditor.tsx | 109 ++++++++
.../managers/css/class-builder/hooks.ts | 147 ++++++++++
4 files changed, 328 insertions(+), 216 deletions(-)
create mode 100644 frontends/nextjs/src/components/managers/css/class-builder/Preview.tsx
create mode 100644 frontends/nextjs/src/components/managers/css/class-builder/RuleEditor.tsx
create mode 100644 frontends/nextjs/src/components/managers/css/class-builder/hooks.ts
diff --git a/frontends/nextjs/src/components/managers/css/CssClassBuilder.tsx b/frontends/nextjs/src/components/managers/css/CssClassBuilder.tsx
index 4672a3889..3d03603e9 100644
--- a/frontends/nextjs/src/components/managers/css/CssClassBuilder.tsx
+++ b/frontends/nextjs/src/components/managers/css/CssClassBuilder.tsx
@@ -1,14 +1,12 @@
-import { useState, useEffect, useMemo, useCallback } from 'react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
+import { Badge } from '@/components/ui'
import { Button } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { Database } from '@/lib/database'
-import { Plus, X, FloppyDisk } from '@phosphor-icons/react'
-import { toast } from 'sonner'
+import { useClassBuilderState } from './class-builder/hooks'
+import { Preview } from './class-builder/Preview'
+import { RuleEditor } from './class-builder/RuleEditor'
+import { X, FloppyDisk } from '@phosphor-icons/react'
interface CssClassBuilderProps {
open: boolean
@@ -17,119 +15,30 @@ interface CssClassBuilderProps {
onSave: (classes: string) => void
}
-interface CssCategory {
- name: string
- classes: string[]
-}
-
-// eslint-disable-next-line no-useless-escape
-const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.\[\]()%#!,=+-]+$/
-const parseClassList = (value: string) => Array.from(new Set(value.split(/\s+/).filter(Boolean)))
-
export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: CssClassBuilderProps) {
- const [selectedClasses, setSelectedClasses] = useState([])
- const [categories, setCategories] = useState([])
- const [searchQuery, setSearchQuery] = useState('')
- const [customClass, setCustomClass] = useState('')
- const [activeTab, setActiveTab] = useState('custom')
+ const {
+ categories,
+ filteredCategories,
+ selectedClasses,
+ selectedClassSet,
+ searchQuery,
+ setSearchQuery,
+ activeTab,
+ setActiveTab,
+ customClass,
+ setCustomClass,
+ invalidCustomTokens,
+ duplicateCustomTokens,
+ unknownCustomTokens,
+ canAddCustom,
+ addCustomClass,
+ toggleClass,
+ clearSelectedClasses,
+ } = useClassBuilderState({ open, initialValue })
- const knownClassSet = useMemo(
- () => new Set(categories.flatMap((category) => category.classes)),
- [categories]
- )
- const selectedClassSet = useMemo(() => new Set(selectedClasses), [selectedClasses])
- const normalizedSearch = searchQuery.trim().toLowerCase()
- const filteredCategories = useMemo(() => {
- if (!normalizedSearch) {
- return categories
- }
-
- return categories
- .map((category) => ({
- ...category,
- classes: category.classes.filter((cls) => cls.toLowerCase().includes(normalizedSearch)),
- }))
- .filter((category) => category.classes.length > 0)
- }, [categories, normalizedSearch])
-
- const customTokens = customClass.trim().split(/\s+/).filter(Boolean)
- const uniqueCustomTokens = Array.from(new Set(customTokens))
- const invalidCustomTokens = uniqueCustomTokens.filter((token) => !CLASS_TOKEN_PATTERN.test(token))
- const duplicateCustomTokens = uniqueCustomTokens.filter((token) => selectedClassSet.has(token))
- const unknownCustomTokens = uniqueCustomTokens.filter((token) => !knownClassSet.has(token))
- const canAddCustom =
- uniqueCustomTokens.length > 0 &&
- invalidCustomTokens.length === 0 &&
- uniqueCustomTokens.some((token) => !selectedClassSet.has(token))
-
- const loadCssClasses = useCallback(async () => {
- const classes = await Database.getCssClasses()
- const sorted = classes.slice().sort((a, b) => a.name.localeCompare(b.name))
- setCategories(sorted)
- }, [])
-
- useEffect(() => {
- if (open) {
- loadCssClasses()
- setSelectedClasses(parseClassList(initialValue))
- setSearchQuery('')
- setCustomClass('')
- }
- }, [open, initialValue, loadCssClasses])
-
- useEffect(() => {
- if (!open) {
- return
- }
-
- if (filteredCategories.length === 0) {
- setActiveTab('custom')
- return
- }
-
- if (activeTab === 'custom') {
- return
- }
-
- const hasActiveTab = filteredCategories.some((category) => category.name === activeTab)
- if (!hasActiveTab) {
- setActiveTab(filteredCategories[0]?.name ?? 'custom')
- }
- }, [activeTab, filteredCategories, open])
-
- const toggleClass = (cssClass: string) => {
- setSelectedClasses(current => {
- if (current.includes(cssClass)) {
- return current.filter(c => c !== cssClass)
- } else {
- return [...current, cssClass]
- }
- })
- }
-
- const addCustomClass = () => {
- if (uniqueCustomTokens.length === 0) {
- return
- }
-
- if (invalidCustomTokens.length > 0) {
- toast.error(`Invalid class name: ${invalidCustomTokens.join(', ')}`)
- return
- }
-
- const newTokens = uniqueCustomTokens.filter((token) => !selectedClassSet.has(token))
- if (newTokens.length === 0) {
- toast.info('Those classes are already selected')
- return
- }
-
- setSelectedClasses((current) => [...current, ...newTokens])
- setCustomClass('')
- }
-
- const clearSelectedClasses = () => {
- setSelectedClasses([])
- }
+ const normalizedSearch = searchQuery.trim()
+ const hasNoCategories = filteredCategories.length === 0 && categories.length === 0
+ const hasNoSearchResults = filteredCategories.length === 0 && categories.length > 0 && normalizedSearch
const handleSave = () => {
onSave(selectedClasses.join(' '))
@@ -176,21 +85,16 @@ export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: Cs
- {selectedClasses.map(cls => (
+ {selectedClasses.map((cls) => (
{cls}
- toggleClass(cls)}
- className="hover:text-destructive"
- >
+ toggleClass(cls)} className="hover:text-destructive">
))}
-
- {selectedClasses.join(' ')}
-
+
{selectedClasses.join(' ')}
) : (
@@ -198,106 +102,34 @@ export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: Cs
)}
-
-
Preview
-
-
-
Preview element
-
- This sample updates as you add or remove classes.
-
-
- Sample button
-
-
-
-
+
- {filteredCategories.length === 0 && categories.length === 0 && (
+ {hasNoCategories && (
No CSS categories available yet. Add some in the CSS Classes tab.
)}
- {filteredCategories.length === 0 && categories.length > 0 && normalizedSearch && (
+ {hasNoSearchResults && (
No classes match "{searchQuery}".
)}
-
-
-
- {filteredCategories.map(category => (
-
- {category.name}
-
- ))}
- Custom
-
-
-
- {filteredCategories.map(category => (
-
-
-
- {category.classes.map(cls => (
- toggleClass(cls)}
- aria-pressed={selectedClassSet.has(cls)}
- className={`
- px-3 py-2 text-sm rounded border text-left font-mono transition-all duration-150 active:scale-95
- ${selectedClassSet.has(cls)
- ? 'bg-primary text-primary-foreground border-primary'
- : 'bg-card hover:bg-accent hover:text-accent-foreground'
- }
- `}
- >
- {cls}
-
- ))}
-
-
-
- ))}
-
-
-
-
-
setCustomClass(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && canAddCustom && addCustomClass()}
- className={`font-mono ${invalidCustomTokens.length > 0 ? 'border-destructive focus-visible:ring-destructive' : ''}`}
- />
-
-
- Add
-
-
- {invalidCustomTokens.length > 0 && (
-
- Invalid class names: {invalidCustomTokens.join(', ')}
-
- )}
- {invalidCustomTokens.length === 0 && unknownCustomTokens.length > 0 && (
-
- Not in library: {unknownCustomTokens.join(', ')}. They will still be added.
-
- )}
- {duplicateCustomTokens.length > 0 && (
-
- Already selected: {duplicateCustomTokens.join(', ')}
-
- )}
-
- Add custom CSS classes that aren't in the predefined list.
-
-
-
-
+
diff --git a/frontends/nextjs/src/components/managers/css/class-builder/Preview.tsx b/frontends/nextjs/src/components/managers/css/class-builder/Preview.tsx
new file mode 100644
index 000000000..0c1159976
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/css/class-builder/Preview.tsx
@@ -0,0 +1,24 @@
+import { Label } from '@/components/ui'
+
+interface PreviewProps {
+ selectedClasses: string[]
+}
+
+export function Preview({ selectedClasses }: PreviewProps) {
+ return (
+
+
Preview
+
+
+
Preview element
+
+ This sample updates as you add or remove classes.
+
+
+ Sample button
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/css/class-builder/RuleEditor.tsx b/frontends/nextjs/src/components/managers/css/class-builder/RuleEditor.tsx
new file mode 100644
index 000000000..a13cd055d
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/css/class-builder/RuleEditor.tsx
@@ -0,0 +1,109 @@
+import { Button, Input, ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
+import type { CssCategory } from '@/lib/database'
+import { Plus } from '@phosphor-icons/react'
+
+interface RuleEditorProps {
+ filteredCategories: CssCategory[]
+ activeTab: string
+ onTabChange: (value: string) => void
+ selectedClassSet: Set
+ toggleClass: (cssClass: string) => void
+ customClass: string
+ setCustomClass: (value: string) => void
+ canAddCustom: boolean
+ addCustomClass: () => void
+ invalidCustomTokens: string[]
+ duplicateCustomTokens: string[]
+ unknownCustomTokens: string[]
+}
+
+export function RuleEditor({
+ filteredCategories,
+ activeTab,
+ onTabChange,
+ selectedClassSet,
+ toggleClass,
+ customClass,
+ setCustomClass,
+ canAddCustom,
+ addCustomClass,
+ invalidCustomTokens,
+ duplicateCustomTokens,
+ unknownCustomTokens,
+}: RuleEditorProps) {
+ return (
+
+
+
+ {filteredCategories.map((category) => (
+
+ {category.name}
+
+ ))}
+ Custom
+
+
+
+ {filteredCategories.map((category) => (
+
+
+
+ {category.classes.map((cls) => (
+ toggleClass(cls)}
+ aria-pressed={selectedClassSet.has(cls)}
+ className={`
+ px-3 py-2 text-sm rounded border text-left font-mono transition-all duration-150 active:scale-95
+ ${selectedClassSet.has(cls)
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-card hover:bg-accent hover:text-accent-foreground'
+ }
+ `}
+ >
+ {cls}
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+
setCustomClass(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && canAddCustom && addCustomClass()}
+ className={`font-mono ${invalidCustomTokens.length > 0 ? 'border-destructive focus-visible:ring-destructive' : ''}`}
+ />
+
+
+ Add
+
+
+ {invalidCustomTokens.length > 0 && (
+
+ Invalid class names: {invalidCustomTokens.join(', ')}
+
+ )}
+ {invalidCustomTokens.length === 0 && unknownCustomTokens.length > 0 && (
+
+ Not in library: {unknownCustomTokens.join(', ')}. They will still be added.
+
+ )}
+ {duplicateCustomTokens.length > 0 && (
+
+ Already selected: {duplicateCustomTokens.join(', ')}
+
+ )}
+
+ Add custom CSS classes that aren't in the predefined list.
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/css/class-builder/hooks.ts b/frontends/nextjs/src/components/managers/css/class-builder/hooks.ts
new file mode 100644
index 000000000..d4a7ff8c0
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/css/class-builder/hooks.ts
@@ -0,0 +1,147 @@
+import { useState, useEffect, useMemo, useCallback } from 'react'
+import { Database, CssCategory } from '@/lib/database'
+import { toast } from 'sonner'
+
+const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.\[\]()%#!,=+-]+$/
+const parseClassList = (value: string) => Array.from(new Set(value.split(/\s+/).filter(Boolean)))
+
+interface UseClassBuilderStateProps {
+ open: boolean
+ initialValue: string
+}
+
+export function useClassBuilderState({ open, initialValue }: UseClassBuilderStateProps) {
+ const [selectedClasses, setSelectedClasses] = useState([])
+ const [categories, setCategories] = useState([])
+ const [searchQuery, setSearchQuery] = useState('')
+ const [customClass, setCustomClass] = useState('')
+ const [activeTab, setActiveTab] = useState('custom')
+
+ const knownClassSet = useMemo(() => new Set(categories.flatMap((category) => category.classes)), [categories])
+ const selectedClassSet = useMemo(() => new Set(selectedClasses), [selectedClasses])
+ const normalizedSearch = searchQuery.trim().toLowerCase()
+
+ const filteredCategories = useMemo(() => {
+ if (!normalizedSearch) {
+ return categories
+ }
+
+ return categories
+ .map((category) => ({
+ ...category,
+ classes: category.classes.filter((cls) => cls.toLowerCase().includes(normalizedSearch)),
+ }))
+ .filter((category) => category.classes.length > 0)
+ }, [categories, normalizedSearch])
+
+ const customTokens = useMemo(() => customClass.trim().split(/\s+/).filter(Boolean), [customClass])
+ const uniqueCustomTokens = useMemo(() => Array.from(new Set(customTokens)), [customTokens])
+ const invalidCustomTokens = useMemo(
+ () => uniqueCustomTokens.filter((token) => !CLASS_TOKEN_PATTERN.test(token)),
+ [uniqueCustomTokens]
+ )
+ const duplicateCustomTokens = useMemo(
+ () => uniqueCustomTokens.filter((token) => selectedClassSet.has(token)),
+ [uniqueCustomTokens, selectedClassSet]
+ )
+ const unknownCustomTokens = useMemo(
+ () => uniqueCustomTokens.filter((token) => !knownClassSet.has(token)),
+ [uniqueCustomTokens, knownClassSet]
+ )
+ const canAddCustom = useMemo(
+ () =>
+ uniqueCustomTokens.length > 0 &&
+ invalidCustomTokens.length === 0 &&
+ uniqueCustomTokens.some((token) => !selectedClassSet.has(token)),
+ [invalidCustomTokens.length, selectedClassSet, uniqueCustomTokens]
+ )
+
+ const loadCssClasses = useCallback(async () => {
+ const classes = await Database.getCssClasses()
+ const sorted = classes.slice().sort((a, b) => a.name.localeCompare(b.name))
+ setCategories(sorted)
+ }, [])
+
+ useEffect(() => {
+ if (open) {
+ loadCssClasses()
+ setSelectedClasses(parseClassList(initialValue))
+ setSearchQuery('')
+ setCustomClass('')
+ }
+ }, [open, initialValue, loadCssClasses])
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ if (filteredCategories.length === 0) {
+ setActiveTab('custom')
+ return
+ }
+
+ if (activeTab === 'custom') {
+ return
+ }
+
+ const hasActiveTab = filteredCategories.some((category) => category.name === activeTab)
+ if (!hasActiveTab) {
+ setActiveTab(filteredCategories[0]?.name ?? 'custom')
+ }
+ }, [activeTab, filteredCategories, open])
+
+ const toggleClass = (cssClass: string) => {
+ setSelectedClasses((current) => {
+ if (current.includes(cssClass)) {
+ return current.filter((c) => c !== cssClass)
+ }
+
+ return [...current, cssClass]
+ })
+ }
+
+ const addCustomClass = () => {
+ if (uniqueCustomTokens.length === 0) {
+ return
+ }
+
+ if (invalidCustomTokens.length > 0) {
+ toast.error(`Invalid class name: ${invalidCustomTokens.join(', ')}`)
+ return
+ }
+
+ const newTokens = uniqueCustomTokens.filter((token) => !selectedClassSet.has(token))
+ if (newTokens.length === 0) {
+ toast.info('Those classes are already selected')
+ return
+ }
+
+ setSelectedClasses((current) => [...current, ...newTokens])
+ setCustomClass('')
+ }
+
+ const clearSelectedClasses = () => {
+ setSelectedClasses([])
+ }
+
+ return {
+ categories,
+ filteredCategories,
+ selectedClasses,
+ selectedClassSet,
+ searchQuery,
+ setSearchQuery,
+ activeTab,
+ setActiveTab,
+ customClass,
+ setCustomClass,
+ invalidCustomTokens,
+ duplicateCustomTokens,
+ unknownCustomTokens,
+ canAddCustom,
+ addCustomClass,
+ toggleClass,
+ clearSelectedClasses,
+ }
+}
From 6797acc724aef2d4ab9a4ec034085f25d4deaa07 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:50:05 +0000
Subject: [PATCH 27/80] feat: modularize database manager UI
---
.../managers/database/ActionToolbar.tsx | 38 ++++
.../managers/database/ConnectionForm.tsx | 115 ++++++++++++
.../managers/database/DatabaseManager.tsx | 176 +++++++++---------
.../managers/database/SchemaViewer.tsx | 81 ++++++++
4 files changed, 325 insertions(+), 85 deletions(-)
create mode 100644 frontends/nextjs/src/components/managers/database/ActionToolbar.tsx
create mode 100644 frontends/nextjs/src/components/managers/database/ConnectionForm.tsx
create mode 100644 frontends/nextjs/src/components/managers/database/SchemaViewer.tsx
diff --git a/frontends/nextjs/src/components/managers/database/ActionToolbar.tsx b/frontends/nextjs/src/components/managers/database/ActionToolbar.tsx
new file mode 100644
index 000000000..ddd2029cb
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/database/ActionToolbar.tsx
@@ -0,0 +1,38 @@
+import { Button } from '@/components/ui'
+import { ArrowsClockwise, Export, UploadSimple, Trash } from '@phosphor-icons/react'
+
+interface ActionToolbarProps {
+ isLoading?: boolean
+ onRefresh: () => void
+ onExport: () => void
+ onImport: () => void
+ onClear: () => void
+}
+
+export function ActionToolbar({ isLoading, onRefresh, onExport, onImport, onClear }: ActionToolbarProps) {
+ return (
+
+
+
Database Management
+
Manage all persistent data across the application
+
+
+
+
+
+
+
+ Export
+
+
+
+ Import
+
+
+
+ Clear DB
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/database/ConnectionForm.tsx b/frontends/nextjs/src/components/managers/database/ConnectionForm.tsx
new file mode 100644
index 000000000..ac3dc3ce8
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/database/ConnectionForm.tsx
@@ -0,0 +1,115 @@
+import { useState, type FormEvent } from 'react'
+import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@/components/ui'
+
+export interface ConnectionDetails {
+ driver: string
+ host: string
+ port: string
+ database: string
+ username: string
+ password: string
+}
+
+interface ConnectionFormProps {
+ onConnect: (details: ConnectionDetails) => Promise | void
+ isConnecting?: boolean
+ status: 'disconnected' | 'connecting' | 'connected'
+ lastConnectedAt: Date | null
+}
+
+export function ConnectionForm({ onConnect, isConnecting, status, lastConnectedAt }: ConnectionFormProps) {
+ const [details, setDetails] = useState({
+ driver: 'prisma-client',
+ host: 'localhost',
+ port: '5432',
+ database: 'metabuilder',
+ username: 'admin',
+ password: '',
+ })
+
+ const handleChange = (key: keyof ConnectionDetails, value: string) => {
+ setDetails((prev) => ({ ...prev, [key]: value }))
+ }
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+ await onConnect(details)
+ }
+
+ const statusVariant = status === 'connected' ? 'default' : status === 'connecting' ? 'secondary' : 'outline'
+ const statusLabel =
+ status === 'connected'
+ ? 'Connected'
+ : status === 'connecting'
+ ? 'Connecting...'
+ : 'Not connected'
+
+ return (
+
+
+
+ Connection
+ Initialize and validate access to the database layer
+
+ {statusLabel}
+
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/database/DatabaseManager.tsx b/frontends/nextjs/src/components/managers/database/DatabaseManager.tsx
index ba65e5fd1..d951f226a 100644
--- a/frontends/nextjs/src/components/managers/database/DatabaseManager.tsx
+++ b/frontends/nextjs/src/components/managers/database/DatabaseManager.tsx
@@ -1,9 +1,7 @@
-import { useState, useEffect } from 'react'
-import { Button } from '@/components/ui'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
import { Database, DB_KEYS } from '@/lib/database'
+import type { ModelSchema } from '@/lib/types/schema-types'
import { toast } from 'sonner'
import {
Database as DatabaseIcon,
@@ -16,12 +14,27 @@ import {
ChatCircle,
Tree,
Gear,
- Trash,
- ArrowsClockwise,
} from '@phosphor-icons/react'
+import { ActionToolbar } from './ActionToolbar'
+import { ConnectionForm, type ConnectionDetails } from './ConnectionForm'
+import { SchemaViewer } from './SchemaViewer'
+
+interface DatabaseStats {
+ users: number
+ credentials: number
+ workflows: number
+ luaScripts: number
+ pages: number
+ schemas: number
+ comments: number
+ componentNodes: number
+ componentConfigs: number
+}
+
+type ConnectionState = 'disconnected' | 'connecting' | 'connected'
export function DatabaseManager() {
- const [stats, setStats] = useState({
+ const [stats, setStats] = useState({
users: 0,
credentials: 0,
workflows: 0,
@@ -32,14 +45,12 @@ export function DatabaseManager() {
componentNodes: 0,
componentConfigs: 0,
})
-
+ const [schemas, setSchemas] = useState([])
const [isLoading, setIsLoading] = useState(false)
+ const [connectionState, setConnectionState] = useState('disconnected')
+ const [lastConnectedAt, setLastConnectedAt] = useState(null)
- useEffect(() => {
- loadStats()
- }, [])
-
- const loadStats = async () => {
+ const loadStats = useCallback(async () => {
setIsLoading(true)
try {
const [
@@ -48,7 +59,7 @@ export function DatabaseManager() {
workflows,
luaScripts,
pages,
- schemas,
+ schemaData,
comments,
hierarchy,
configs,
@@ -70,19 +81,25 @@ export function DatabaseManager() {
workflows: workflows.length,
luaScripts: luaScripts.length,
pages: pages.length,
- schemas: schemas.length,
+ schemas: schemaData.length,
comments: comments.length,
componentNodes: Object.keys(hierarchy).length,
componentConfigs: Object.keys(configs).length,
})
+ setSchemas(schemaData)
} catch (error) {
+ console.error(error)
toast.error('Failed to load database statistics')
} finally {
setIsLoading(false)
}
- }
+ }, [])
- const handleClearDatabase = async () => {
+ useEffect(() => {
+ void loadStats()
+ }, [loadStats])
+
+ const handleClearDatabase = useCallback(async () => {
if (!confirm('Are you sure you want to clear the entire database? This action cannot be undone!')) {
return
}
@@ -97,11 +114,12 @@ export function DatabaseManager() {
await loadStats()
toast.success('Database cleared and reinitialized')
} catch (error) {
+ console.error(error)
toast.error('Failed to clear database')
}
- }
+ }, [loadStats])
- const handleExportDatabase = async () => {
+ const handleExportDatabase = useCallback(async () => {
try {
const data = await Database.exportDatabase()
const blob = new Blob([data], { type: 'application/json' })
@@ -113,11 +131,12 @@ export function DatabaseManager() {
URL.revokeObjectURL(url)
toast.success('Database exported successfully')
} catch (error) {
+ console.error(error)
toast.error('Failed to export database')
}
- }
+ }, [])
- const handleImportDatabase = () => {
+ const handleImportDatabase = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json'
@@ -131,51 +150,64 @@ export function DatabaseManager() {
await loadStats()
toast.success('Database imported successfully')
} catch (error) {
+ console.error(error)
toast.error('Failed to import database')
}
}
input.click()
- }
+ }, [loadStats])
- const totalRecords = Object.values(stats).reduce((a, b) => a + b, 0)
+ const handleConnect = useCallback(
+ async (details: ConnectionDetails) => {
+ setConnectionState('connecting')
+ try {
+ await Database.initializeDatabase()
+ setConnectionState('connected')
+ setLastConnectedAt(new Date())
+ toast.success(`Connected to ${details.database || 'Metabuilder database'} via ${details.driver}`)
+ await loadStats()
+ } catch (error) {
+ console.error(error)
+ setConnectionState('disconnected')
+ toast.error('Failed to initialize database connection')
+ }
+ },
+ [loadStats],
+ )
- const dbEntities = [
- { key: 'users', icon: Users, label: 'Users', count: stats.users, color: 'text-blue-500' },
- { key: 'credentials', icon: Key, label: 'Credentials (SHA-512)', count: stats.credentials, color: 'text-amber-500' },
- { key: 'workflows', icon: Lightning, label: 'Workflows', count: stats.workflows, color: 'text-purple-500' },
- { key: 'luaScripts', icon: Code, label: 'Lua Scripts', count: stats.luaScripts, color: 'text-indigo-500' },
- { key: 'pages', icon: FileText, label: 'Pages', count: stats.pages, color: 'text-cyan-500' },
- { key: 'schemas', icon: TableIcon, label: 'Data Schemas', count: stats.schemas, color: 'text-green-500' },
- { key: 'comments', icon: ChatCircle, label: 'Comments', count: stats.comments, color: 'text-pink-500' },
- { key: 'componentNodes', icon: Tree, label: 'Component Hierarchy', count: stats.componentNodes, color: 'text-teal-500' },
- { key: 'componentConfigs', icon: Gear, label: 'Component Configs', count: stats.componentConfigs, color: 'text-orange-500' },
- ]
+ const dbEntities = useMemo(
+ () => [
+ { key: 'users', icon: Users, label: 'Users', count: stats.users, color: 'text-blue-500' },
+ { key: 'credentials', icon: Key, label: 'Credentials (SHA-512)', count: stats.credentials, color: 'text-amber-500' },
+ { key: 'workflows', icon: Lightning, label: 'Workflows', count: stats.workflows, color: 'text-purple-500' },
+ { key: 'luaScripts', icon: Code, label: 'Lua Scripts', count: stats.luaScripts, color: 'text-indigo-500' },
+ { key: 'pages', icon: FileText, label: 'Pages', count: stats.pages, color: 'text-cyan-500' },
+ { key: 'schemas', icon: TableIcon, label: 'Data Schemas', count: stats.schemas, color: 'text-green-500' },
+ { key: 'comments', icon: ChatCircle, label: 'Comments', count: stats.comments, color: 'text-pink-500' },
+ { key: 'componentNodes', icon: Tree, label: 'Component Hierarchy', count: stats.componentNodes, color: 'text-teal-500' },
+ { key: 'componentConfigs', icon: Gear, label: 'Component Configs', count: stats.componentConfigs, color: 'text-orange-500' },
+ ],
+ [stats],
+ )
+
+ const totalRecords = useMemo(() => Object.values(stats).reduce((a, b) => a + b, 0), [stats])
return (
-
-
-
Database Management
-
- Manage all persistent data across the application
-
-
-
-
-
-
-
- Export
-
-
- Import
-
-
-
- Clear DB
-
-
-
+
void loadStats()}
+ onExport={handleExportDatabase}
+ onImport={handleImportDatabase}
+ onClear={handleClearDatabase}
+ />
+
+
@@ -184,9 +216,7 @@ export function DatabaseManager() {
Database Overview
-
- All data stored using SHA-512 password hashing and KV persistence
-
+ All data stored using SHA-512 password hashing and KV persistence
{totalRecords}
@@ -210,31 +240,7 @@ export function DatabaseManager() {
))}
-
-
- Database Keys
-
- All KV storage keys used by the application
-
-
-
-
-
- {Object.entries(DB_KEYS).map(([key, value]) => (
-
- ))}
-
-
-
-
+
diff --git a/frontends/nextjs/src/components/managers/database/SchemaViewer.tsx b/frontends/nextjs/src/components/managers/database/SchemaViewer.tsx
new file mode 100644
index 000000000..f601641c0
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/database/SchemaViewer.tsx
@@ -0,0 +1,81 @@
+import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from '@/components/ui'
+import type { ModelSchema } from '@/lib/types/schema-types'
+
+interface SchemaViewerProps {
+ schemas: ModelSchema[]
+ dbKeys: Record
+}
+
+export function SchemaViewer({ schemas, dbKeys }: SchemaViewerProps) {
+ return (
+
+
+
+ Schemas
+ Models and fields available in the database
+
+
+ {schemas.length === 0 ? (
+ No schemas configured yet.
+ ) : (
+
+ {schemas.map((schema) => (
+
+
+
+
+ {schema.icon && {schema.icon} }
+ {schema.label || schema.name}
+ {schema.name}
+
+ {schema.labelPlural && (
+
Plural: {schema.labelPlural}
+ )}
+
+
{schema.fields.length} fields
+
+ {schema.fields.length > 0 && (
+
+ {schema.fields.slice(0, 6).map((field) => (
+
+ {field.label || field.name} ({field.type})
+
+ ))}
+ {schema.fields.length > 6 && (
+ +{schema.fields.length - 6} more
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+
+ Database Keys
+ All KV storage keys used by the application
+
+
+
+
+ {Object.entries(dbKeys).map(([key, value]) => (
+
+ ))}
+
+
+
+
+
+ )
+}
From f0bdeb860ab783dad7da74c080040385b0f99e72 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:53:08 +0000
Subject: [PATCH 28/80] chore: split theme type declarations
---
.../nextjs/src/theme/types/components.d.ts | 71 ++++++
frontends/nextjs/src/theme/types/layout.d.ts | 70 ++++++
frontends/nextjs/src/theme/types/palette.d.ts | 38 ++++
frontends/nextjs/src/theme/types/theme.d.ts | 202 +-----------------
4 files changed, 185 insertions(+), 196 deletions(-)
create mode 100644 frontends/nextjs/src/theme/types/components.d.ts
create mode 100644 frontends/nextjs/src/theme/types/layout.d.ts
create mode 100644 frontends/nextjs/src/theme/types/palette.d.ts
diff --git a/frontends/nextjs/src/theme/types/components.d.ts b/frontends/nextjs/src/theme/types/components.d.ts
new file mode 100644
index 000000000..b3c850088
--- /dev/null
+++ b/frontends/nextjs/src/theme/types/components.d.ts
@@ -0,0 +1,71 @@
+import '@mui/material/styles'
+import '@mui/material/Typography'
+import '@mui/material/Button'
+import '@mui/material/Chip'
+import '@mui/material/IconButton'
+import '@mui/material/Badge'
+import '@mui/material/Alert'
+
+// Typography variants and component overrides
+declare module '@mui/material/styles' {
+ interface TypographyVariants {
+ code: React.CSSProperties
+ kbd: React.CSSProperties
+ label: React.CSSProperties
+ }
+
+ interface TypographyVariantsOptions {
+ code?: React.CSSProperties
+ kbd?: React.CSSProperties
+ label?: React.CSSProperties
+ }
+}
+
+declare module '@mui/material/Typography' {
+ interface TypographyPropsVariantOverrides {
+ code: true
+ kbd: true
+ label: true
+ }
+}
+
+declare module '@mui/material/Button' {
+ interface ButtonPropsVariantOverrides {
+ soft: true
+ ghost: true
+ }
+
+ interface ButtonPropsColorOverrides {
+ neutral: true
+ }
+}
+
+declare module '@mui/material/Chip' {
+ interface ChipPropsVariantOverrides {
+ soft: true
+ }
+
+ interface ChipPropsColorOverrides {
+ neutral: true
+ }
+}
+
+declare module '@mui/material/IconButton' {
+ interface IconButtonPropsColorOverrides {
+ neutral: true
+ }
+}
+
+declare module '@mui/material/Badge' {
+ interface BadgePropsColorOverrides {
+ neutral: true
+ }
+}
+
+declare module '@mui/material/Alert' {
+ interface AlertPropsVariantOverrides {
+ soft: true
+ }
+}
+
+export {}
diff --git a/frontends/nextjs/src/theme/types/layout.d.ts b/frontends/nextjs/src/theme/types/layout.d.ts
new file mode 100644
index 000000000..6c9219bfc
--- /dev/null
+++ b/frontends/nextjs/src/theme/types/layout.d.ts
@@ -0,0 +1,70 @@
+import '@mui/material/styles'
+
+// Custom theme properties for layout and design tokens
+declare module '@mui/material/styles' {
+ interface Theme {
+ custom: {
+ fonts: {
+ body: string
+ heading: string
+ mono: string
+ }
+ borderRadius: {
+ none: number
+ sm: number
+ md: number
+ lg: number
+ xl: number
+ full: number
+ }
+ contentWidth: {
+ sm: string
+ md: string
+ lg: string
+ xl: string
+ full: string
+ }
+ sidebar: {
+ width: number
+ collapsedWidth: number
+ }
+ header: {
+ height: number
+ }
+ }
+ }
+
+ interface ThemeOptions {
+ custom?: {
+ fonts?: {
+ body?: string
+ heading?: string
+ mono?: string
+ }
+ borderRadius?: {
+ none?: number
+ sm?: number
+ md?: number
+ lg?: number
+ xl?: number
+ full?: number
+ }
+ contentWidth?: {
+ sm?: string
+ md?: string
+ lg?: string
+ xl?: string
+ full?: string
+ }
+ sidebar?: {
+ width?: number
+ collapsedWidth?: number
+ }
+ header?: {
+ height?: number
+ }
+ }
+ }
+}
+
+export {}
diff --git a/frontends/nextjs/src/theme/types/palette.d.ts b/frontends/nextjs/src/theme/types/palette.d.ts
new file mode 100644
index 000000000..edf25b4a1
--- /dev/null
+++ b/frontends/nextjs/src/theme/types/palette.d.ts
@@ -0,0 +1,38 @@
+import '@mui/material/styles'
+
+// Extend palette with custom neutral colors
+declare module '@mui/material/styles' {
+ interface Palette {
+ neutral: {
+ 50: string
+ 100: string
+ 200: string
+ 300: string
+ 400: string
+ 500: string
+ 600: string
+ 700: string
+ 800: string
+ 900: string
+ 950: string
+ }
+ }
+
+ interface PaletteOptions {
+ neutral?: {
+ 50?: string
+ 100?: string
+ 200?: string
+ 300?: string
+ 400?: string
+ 500?: string
+ 600?: string
+ 700?: string
+ 800?: string
+ 900?: string
+ 950?: string
+ }
+ }
+}
+
+export {}
diff --git a/frontends/nextjs/src/theme/types/theme.d.ts b/frontends/nextjs/src/theme/types/theme.d.ts
index a8623d1fb..e3a795dd5 100644
--- a/frontends/nextjs/src/theme/types/theme.d.ts
+++ b/frontends/nextjs/src/theme/types/theme.d.ts
@@ -1,200 +1,10 @@
/**
* MUI Theme Type Extensions
- *
- * This file extends Material-UI's theme interface with custom properties.
- * All custom design tokens and component variants should be declared here.
+ *
+ * This file aggregates the theme augmentation modules to keep the
+ * main declaration lightweight while still exposing all custom tokens.
*/
-import '@mui/material/styles'
-import '@mui/material/Typography'
-import '@mui/material/Button'
-
-// ============================================================================
-// Custom Palette Extensions
-// ============================================================================
-
-declare module '@mui/material/styles' {
- // Extend palette with custom neutral colors
- interface Palette {
- neutral: {
- 50: string
- 100: string
- 200: string
- 300: string
- 400: string
- 500: string
- 600: string
- 700: string
- 800: string
- 900: string
- 950: string
- }
- }
-
- interface PaletteOptions {
- neutral?: {
- 50?: string
- 100?: string
- 200?: string
- 300?: string
- 400?: string
- 500?: string
- 600?: string
- 700?: string
- 800?: string
- 900?: string
- 950?: string
- }
- }
-
- // Custom typography variants
- interface TypographyVariants {
- code: React.CSSProperties
- kbd: React.CSSProperties
- label: React.CSSProperties
- }
-
- interface TypographyVariantsOptions {
- code?: React.CSSProperties
- kbd?: React.CSSProperties
- label?: React.CSSProperties
- }
-
- // Custom theme properties
- interface Theme {
- custom: {
- fonts: {
- body: string
- heading: string
- mono: string
- }
- borderRadius: {
- none: number
- sm: number
- md: number
- lg: number
- xl: number
- full: number
- }
- contentWidth: {
- sm: string
- md: string
- lg: string
- xl: string
- full: string
- }
- sidebar: {
- width: number
- collapsedWidth: number
- }
- header: {
- height: number
- }
- }
- }
-
- interface ThemeOptions {
- custom?: {
- fonts?: {
- body?: string
- heading?: string
- mono?: string
- }
- borderRadius?: {
- none?: number
- sm?: number
- md?: number
- lg?: number
- xl?: number
- full?: number
- }
- contentWidth?: {
- sm?: string
- md?: string
- lg?: string
- xl?: string
- full?: string
- }
- sidebar?: {
- width?: number
- collapsedWidth?: number
- }
- header?: {
- height?: number
- }
- }
- }
-}
-
-// ============================================================================
-// Typography Variant Mapping
-// ============================================================================
-
-declare module '@mui/material/Typography' {
- interface TypographyPropsVariantOverrides {
- code: true
- kbd: true
- label: true
- }
-}
-
-// ============================================================================
-// Button Variants & Colors
-// ============================================================================
-
-declare module '@mui/material/Button' {
- interface ButtonPropsVariantOverrides {
- soft: true
- ghost: true
- }
-
- interface ButtonPropsColorOverrides {
- neutral: true
- }
-}
-
-// ============================================================================
-// Chip Variants
-// ============================================================================
-
-declare module '@mui/material/Chip' {
- interface ChipPropsVariantOverrides {
- soft: true
- }
-
- interface ChipPropsColorOverrides {
- neutral: true
- }
-}
-
-// ============================================================================
-// IconButton Colors
-// ============================================================================
-
-declare module '@mui/material/IconButton' {
- interface IconButtonPropsColorOverrides {
- neutral: true
- }
-}
-
-// ============================================================================
-// Badge Colors
-// ============================================================================
-
-declare module '@mui/material/Badge' {
- interface BadgePropsColorOverrides {
- neutral: true
- }
-}
-
-// ============================================================================
-// Alert Variants
-// ============================================================================
-
-declare module '@mui/material/Alert' {
- interface AlertPropsVariantOverrides {
- soft: true
- }
-}
-
-export {}
+export * from './palette'
+export * from './layout'
+export * from './components'
From 50f934abbbbcc424ddc7d27919a409766edde7b0 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:53:35 +0000
Subject: [PATCH 29/80] refactor: split default schema definitions
---
.../nextjs/src/lib/schema/default-schema.ts | 306 +-----------------
.../src/lib/schema/default/components.ts | 54 ++++
.../nextjs/src/lib/schema/default/forms.ts | 244 ++++++++++++++
.../src/lib/schema/default/validation.ts | 19 ++
frontends/nextjs/src/lib/schema/index.ts | 3 +
5 files changed, 322 insertions(+), 304 deletions(-)
create mode 100644 frontends/nextjs/src/lib/schema/default/components.ts
create mode 100644 frontends/nextjs/src/lib/schema/default/forms.ts
create mode 100644 frontends/nextjs/src/lib/schema/default/validation.ts
diff --git a/frontends/nextjs/src/lib/schema/default-schema.ts b/frontends/nextjs/src/lib/schema/default-schema.ts
index 398071c44..a01c0a590 100644
--- a/frontends/nextjs/src/lib/schema/default-schema.ts
+++ b/frontends/nextjs/src/lib/schema/default-schema.ts
@@ -1,308 +1,6 @@
import type { SchemaConfig } from '../types/schema-types'
+import { defaultApps } from './default/components'
export const defaultSchema: SchemaConfig = {
- apps: [
- {
- name: 'blog',
- label: 'Blog',
- models: [
- {
- name: 'post',
- label: 'Post',
- labelPlural: 'Posts',
- icon: 'Article',
- listDisplay: ['title', 'author', 'status', 'publishedAt'],
- listFilter: ['status', 'author'],
- searchFields: ['title', 'content'],
- ordering: ['-publishedAt'],
- fields: [
- {
- name: 'id',
- type: 'string',
- label: 'ID',
- required: true,
- unique: true,
- editable: false,
- listDisplay: false,
- },
- {
- name: 'title',
- type: 'string',
- label: 'Title',
- required: true,
- validation: {
- minLength: 3,
- maxLength: 200,
- },
- listDisplay: true,
- searchable: true,
- sortable: true,
- },
- {
- name: 'slug',
- type: 'string',
- label: 'Slug',
- required: true,
- unique: true,
- helpText: 'URL-friendly version of the title',
- validation: {
- pattern: '^[a-z0-9-]+$',
- },
- listDisplay: false,
- sortable: true,
- },
- {
- name: 'content',
- type: 'text',
- label: 'Content',
- required: true,
- helpText: 'Main post content',
- listDisplay: false,
- searchable: true,
- },
- {
- name: 'excerpt',
- type: 'text',
- label: 'Excerpt',
- required: false,
- helpText: ['Short summary of the post', 'Used in list views and previews'],
- validation: {
- maxLength: 500,
- },
- listDisplay: false,
- },
- {
- name: 'author',
- type: 'relation',
- label: 'Author',
- required: true,
- relatedModel: 'author',
- listDisplay: true,
- sortable: true,
- },
- {
- name: 'status',
- type: 'select',
- label: 'Status',
- required: true,
- default: 'draft',
- choices: [
- { value: 'draft', label: 'Draft' },
- { value: 'published', label: 'Published' },
- { value: 'archived', label: 'Archived' },
- ],
- listDisplay: true,
- sortable: true,
- },
- {
- name: 'featured',
- type: 'boolean',
- label: 'Featured',
- default: false,
- helpText: 'Display on homepage',
- listDisplay: true,
- },
- {
- name: 'publishedAt',
- type: 'datetime',
- label: 'Published At',
- required: false,
- listDisplay: true,
- sortable: true,
- },
- {
- name: 'tags',
- type: 'json',
- label: 'Tags',
- required: false,
- helpText: 'JSON array of tag strings',
- listDisplay: false,
- },
- {
- name: 'views',
- type: 'number',
- label: 'Views',
- default: 0,
- validation: {
- min: 0,
- },
- listDisplay: false,
- },
- ],
- },
- {
- name: 'author',
- label: 'Author',
- labelPlural: 'Authors',
- icon: 'User',
- listDisplay: ['name', 'email', 'active', 'createdAt'],
- listFilter: ['active'],
- searchFields: ['name', 'email'],
- ordering: ['name'],
- fields: [
- {
- name: 'id',
- type: 'string',
- label: 'ID',
- required: true,
- unique: true,
- editable: false,
- listDisplay: false,
- },
- {
- name: 'name',
- type: 'string',
- label: 'Name',
- required: true,
- validation: {
- minLength: 2,
- maxLength: 100,
- },
- listDisplay: true,
- searchable: true,
- sortable: true,
- },
- {
- name: 'email',
- type: 'email',
- label: 'Email',
- required: true,
- unique: true,
- listDisplay: true,
- searchable: true,
- sortable: true,
- },
- {
- name: 'bio',
- type: 'text',
- label: 'Bio',
- required: false,
- helpText: 'Author biography',
- validation: {
- maxLength: 1000,
- },
- listDisplay: false,
- },
- {
- name: 'website',
- type: 'url',
- label: 'Website',
- required: false,
- listDisplay: false,
- },
- {
- name: 'active',
- type: 'boolean',
- label: 'Active',
- default: true,
- listDisplay: true,
- },
- {
- name: 'createdAt',
- type: 'datetime',
- label: 'Created At',
- required: true,
- editable: false,
- listDisplay: true,
- sortable: true,
- },
- ],
- },
- ],
- },
- {
- name: 'ecommerce',
- label: 'E-Commerce',
- models: [
- {
- name: 'product',
- label: 'Product',
- labelPlural: 'Products',
- icon: 'ShoppingCart',
- listDisplay: ['name', 'price', 'stock', 'available'],
- listFilter: ['available', 'category'],
- searchFields: ['name', 'description'],
- ordering: ['name'],
- fields: [
- {
- name: 'id',
- type: 'string',
- label: 'ID',
- required: true,
- unique: true,
- editable: false,
- listDisplay: false,
- },
- {
- name: 'name',
- type: 'string',
- label: 'Product Name',
- required: true,
- validation: {
- minLength: 3,
- maxLength: 200,
- },
- listDisplay: true,
- searchable: true,
- sortable: true,
- },
- {
- name: 'description',
- type: 'text',
- label: 'Description',
- required: false,
- helpText: 'Product description',
- listDisplay: false,
- searchable: true,
- },
- {
- name: 'price',
- type: 'number',
- label: 'Price',
- required: true,
- validation: {
- min: 0,
- },
- listDisplay: true,
- sortable: true,
- },
- {
- name: 'stock',
- type: 'number',
- label: 'Stock',
- required: true,
- default: 0,
- validation: {
- min: 0,
- },
- listDisplay: true,
- sortable: true,
- },
- {
- name: 'category',
- type: 'select',
- label: 'Category',
- required: true,
- choices: [
- { value: 'electronics', label: 'Electronics' },
- { value: 'clothing', label: 'Clothing' },
- { value: 'books', label: 'Books' },
- { value: 'home', label: 'Home & Garden' },
- { value: 'toys', label: 'Toys' },
- ],
- listDisplay: false,
- sortable: true,
- },
- {
- name: 'available',
- type: 'boolean',
- label: 'Available',
- default: true,
- listDisplay: true,
- },
- ],
- },
- ],
- },
- ],
+ apps: defaultApps,
}
diff --git a/frontends/nextjs/src/lib/schema/default/components.ts b/frontends/nextjs/src/lib/schema/default/components.ts
new file mode 100644
index 000000000..9fe3f02bd
--- /dev/null
+++ b/frontends/nextjs/src/lib/schema/default/components.ts
@@ -0,0 +1,54 @@
+import type { AppSchema, ModelSchema } from '../../types/schema-types'
+import { authorFields, postFields, productFields } from './forms'
+
+export const blogModels: ModelSchema[] = [
+ {
+ name: 'post',
+ label: 'Post',
+ labelPlural: 'Posts',
+ icon: 'Article',
+ listDisplay: ['title', 'author', 'status', 'publishedAt'],
+ listFilter: ['status', 'author'],
+ searchFields: ['title', 'content'],
+ ordering: ['-publishedAt'],
+ fields: postFields,
+ },
+ {
+ name: 'author',
+ label: 'Author',
+ labelPlural: 'Authors',
+ icon: 'User',
+ listDisplay: ['name', 'email', 'active', 'createdAt'],
+ listFilter: ['active'],
+ searchFields: ['name', 'email'],
+ ordering: ['name'],
+ fields: authorFields,
+ },
+]
+
+export const ecommerceModels: ModelSchema[] = [
+ {
+ name: 'product',
+ label: 'Product',
+ labelPlural: 'Products',
+ icon: 'ShoppingCart',
+ listDisplay: ['name', 'price', 'stock', 'available'],
+ listFilter: ['available', 'category'],
+ searchFields: ['name', 'description'],
+ ordering: ['name'],
+ fields: productFields,
+ },
+]
+
+export const defaultApps: AppSchema[] = [
+ {
+ name: 'blog',
+ label: 'Blog',
+ models: blogModels,
+ },
+ {
+ name: 'ecommerce',
+ label: 'E-Commerce',
+ models: ecommerceModels,
+ },
+]
diff --git a/frontends/nextjs/src/lib/schema/default/forms.ts b/frontends/nextjs/src/lib/schema/default/forms.ts
new file mode 100644
index 000000000..81ae491a5
--- /dev/null
+++ b/frontends/nextjs/src/lib/schema/default/forms.ts
@@ -0,0 +1,244 @@
+import type { FieldSchema } from '../../types/schema-types'
+import { authorValidations, postValidations, productValidations } from './validation'
+
+export const postFields: FieldSchema[] = [
+ {
+ name: 'id',
+ type: 'string',
+ label: 'ID',
+ required: true,
+ unique: true,
+ editable: false,
+ listDisplay: false,
+ },
+ {
+ name: 'title',
+ type: 'string',
+ label: 'Title',
+ required: true,
+ validation: postValidations.title,
+ listDisplay: true,
+ searchable: true,
+ sortable: true,
+ },
+ {
+ name: 'slug',
+ type: 'string',
+ label: 'Slug',
+ required: true,
+ unique: true,
+ helpText: 'URL-friendly version of the title',
+ validation: postValidations.slug,
+ listDisplay: false,
+ sortable: true,
+ },
+ {
+ name: 'content',
+ type: 'text',
+ label: 'Content',
+ required: true,
+ helpText: 'Main post content',
+ listDisplay: false,
+ searchable: true,
+ },
+ {
+ name: 'excerpt',
+ type: 'text',
+ label: 'Excerpt',
+ required: false,
+ helpText: ['Short summary of the post', 'Used in list views and previews'],
+ validation: postValidations.excerpt,
+ listDisplay: false,
+ },
+ {
+ name: 'author',
+ type: 'relation',
+ label: 'Author',
+ required: true,
+ relatedModel: 'author',
+ listDisplay: true,
+ sortable: true,
+ },
+ {
+ name: 'status',
+ type: 'select',
+ label: 'Status',
+ required: true,
+ default: 'draft',
+ choices: [
+ { value: 'draft', label: 'Draft' },
+ { value: 'published', label: 'Published' },
+ { value: 'archived', label: 'Archived' },
+ ],
+ listDisplay: true,
+ sortable: true,
+ },
+ {
+ name: 'featured',
+ type: 'boolean',
+ label: 'Featured',
+ default: false,
+ helpText: 'Display on homepage',
+ listDisplay: true,
+ },
+ {
+ name: 'publishedAt',
+ type: 'datetime',
+ label: 'Published At',
+ required: false,
+ listDisplay: true,
+ sortable: true,
+ },
+ {
+ name: 'tags',
+ type: 'json',
+ label: 'Tags',
+ required: false,
+ helpText: 'JSON array of tag strings',
+ listDisplay: false,
+ },
+ {
+ name: 'views',
+ type: 'number',
+ label: 'Views',
+ default: 0,
+ validation: postValidations.views,
+ listDisplay: false,
+ },
+]
+
+export const authorFields: FieldSchema[] = [
+ {
+ name: 'id',
+ type: 'string',
+ label: 'ID',
+ required: true,
+ unique: true,
+ editable: false,
+ listDisplay: false,
+ },
+ {
+ name: 'name',
+ type: 'string',
+ label: 'Name',
+ required: true,
+ validation: authorValidations.name,
+ listDisplay: true,
+ searchable: true,
+ sortable: true,
+ },
+ {
+ name: 'email',
+ type: 'email',
+ label: 'Email',
+ required: true,
+ unique: true,
+ listDisplay: true,
+ searchable: true,
+ sortable: true,
+ },
+ {
+ name: 'bio',
+ type: 'text',
+ label: 'Bio',
+ required: false,
+ helpText: 'Author biography',
+ validation: authorValidations.bio,
+ listDisplay: false,
+ },
+ {
+ name: 'website',
+ type: 'url',
+ label: 'Website',
+ required: false,
+ listDisplay: false,
+ },
+ {
+ name: 'active',
+ type: 'boolean',
+ label: 'Active',
+ default: true,
+ listDisplay: true,
+ },
+ {
+ name: 'createdAt',
+ type: 'datetime',
+ label: 'Created At',
+ required: true,
+ editable: false,
+ listDisplay: true,
+ sortable: true,
+ },
+]
+
+export const productFields: FieldSchema[] = [
+ {
+ name: 'id',
+ type: 'string',
+ label: 'ID',
+ required: true,
+ unique: true,
+ editable: false,
+ listDisplay: false,
+ },
+ {
+ name: 'name',
+ type: 'string',
+ label: 'Product Name',
+ required: true,
+ validation: productValidations.name,
+ listDisplay: true,
+ searchable: true,
+ sortable: true,
+ },
+ {
+ name: 'description',
+ type: 'text',
+ label: 'Description',
+ required: false,
+ helpText: 'Product description',
+ listDisplay: false,
+ searchable: true,
+ },
+ {
+ name: 'price',
+ type: 'number',
+ label: 'Price',
+ required: true,
+ validation: productValidations.price,
+ listDisplay: true,
+ sortable: true,
+ },
+ {
+ name: 'stock',
+ type: 'number',
+ label: 'Stock',
+ required: true,
+ default: 0,
+ validation: productValidations.stock,
+ listDisplay: true,
+ sortable: true,
+ },
+ {
+ name: 'category',
+ type: 'select',
+ label: 'Category',
+ required: true,
+ choices: [
+ { value: 'electronics', label: 'Electronics' },
+ { value: 'clothing', label: 'Clothing' },
+ { value: 'books', label: 'Books' },
+ { value: 'home', label: 'Home & Garden' },
+ { value: 'toys', label: 'Toys' },
+ ],
+ listDisplay: false,
+ sortable: true,
+ },
+ {
+ name: 'available',
+ type: 'boolean',
+ label: 'Available',
+ default: true,
+ listDisplay: true,
+ },
+]
diff --git a/frontends/nextjs/src/lib/schema/default/validation.ts b/frontends/nextjs/src/lib/schema/default/validation.ts
new file mode 100644
index 000000000..0573520f4
--- /dev/null
+++ b/frontends/nextjs/src/lib/schema/default/validation.ts
@@ -0,0 +1,19 @@
+import type { FieldSchema } from '../../types/schema-types'
+
+export const postValidations: Record = {
+ title: { minLength: 3, maxLength: 200 },
+ slug: { pattern: '^[a-z0-9-]+$' },
+ excerpt: { maxLength: 500 },
+ views: { min: 0 },
+}
+
+export const authorValidations: Record = {
+ name: { minLength: 2, maxLength: 100 },
+ bio: { maxLength: 1000 },
+}
+
+export const productValidations: Record = {
+ name: { minLength: 3, maxLength: 200 },
+ price: { min: 0 },
+ stock: { min: 0 },
+}
diff --git a/frontends/nextjs/src/lib/schema/index.ts b/frontends/nextjs/src/lib/schema/index.ts
index 1e68e8671..97056cc3f 100644
--- a/frontends/nextjs/src/lib/schema/index.ts
+++ b/frontends/nextjs/src/lib/schema/index.ts
@@ -1,3 +1,6 @@
// Schema utilities exports
export * from './schema-utils'
export { defaultSchema } from './default-schema'
+export * from './default/components'
+export * from './default/forms'
+export * from './default/validation'
From 33cc1322cc881825cdfdb50bbf5d296dfbfb9fc2 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:54:27 +0000
Subject: [PATCH 30/80] test: split security scanner coverage
---
.../security-scanner.detection.test.ts | 234 ++++++++++++++++
.../security-scanner.reporting.test.ts | 29 ++
.../security/scanner/security-scanner.test.ts | 259 +-----------------
3 files changed, 265 insertions(+), 257 deletions(-)
create mode 100644 frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts
create mode 100644 frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts
diff --git a/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts
new file mode 100644
index 000000000..a910bea9d
--- /dev/null
+++ b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from 'vitest'
+
+import { scanForVulnerabilities, securityScanner } from '@/lib/security-scanner'
+
+describe('security-scanner detection', () => {
+ describe('scanJavaScript', () => {
+ it.each([
+ {
+ name: 'flag eval usage as critical',
+ code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'),
+ expectedSeverity: 'critical',
+ expectedSafe: false,
+ expectedIssueType: 'dangerous',
+ expectedIssuePattern: 'eval',
+ expectedLine: 2,
+ },
+ {
+ name: 'warn on localStorage usage but stay safe',
+ code: 'localStorage.setItem("k", "v")',
+ expectedSeverity: 'low',
+ expectedSafe: true,
+ expectedIssueType: 'warning',
+ expectedIssuePattern: 'localStorage',
+ },
+ {
+ name: 'return safe for benign code',
+ code: 'const sum = (a, b) => a + b',
+ expectedSeverity: 'safe',
+ expectedSafe: true,
+ },
+ ])(
+ 'should $name',
+ ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => {
+ const result = securityScanner.scanJavaScript(code)
+ expect(result.severity).toBe(expectedSeverity)
+ expect(result.safe).toBe(expectedSafe)
+
+ if (expectedIssueType || expectedIssuePattern) {
+ const issue = result.issues.find(item => {
+ const matchesType = expectedIssueType ? item.type === expectedIssueType : true
+ const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
+ return matchesType && matchesPattern
+ })
+ expect(issue).toBeDefined()
+ if (expectedLine !== undefined) {
+ expect(issue?.line).toBe(expectedLine)
+ }
+ } else {
+ expect(result.issues.length).toBe(0)
+ }
+
+ if (expectedSafe) {
+ expect(result.sanitizedCode).toBe(code)
+ } else {
+ expect(result.sanitizedCode).toBeUndefined()
+ }
+ }
+ )
+ })
+
+ describe('scanLua', () => {
+ it.each([
+ {
+ name: 'flag os.execute usage as critical',
+ code: 'os.execute("rm -rf /")',
+ expectedSeverity: 'critical',
+ expectedSafe: false,
+ expectedIssueType: 'malicious',
+ expectedIssuePattern: 'os.execute',
+ },
+ {
+ name: 'return safe for simple Lua function',
+ code: 'function add(a, b) return a + b end',
+ expectedSeverity: 'safe',
+ expectedSafe: true,
+ },
+ ])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => {
+ const result = securityScanner.scanLua(code)
+ expect(result.severity).toBe(expectedSeverity)
+ expect(result.safe).toBe(expectedSafe)
+
+ if (expectedIssueType || expectedIssuePattern) {
+ const issue = result.issues.find(item => {
+ const matchesType = expectedIssueType ? item.type === expectedIssueType : true
+ const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
+ return matchesType && matchesPattern
+ })
+ expect(issue).toBeDefined()
+ } else {
+ expect(result.issues.length).toBe(0)
+ }
+
+ if (expectedSafe) {
+ expect(result.sanitizedCode).toBe(code)
+ } else {
+ expect(result.sanitizedCode).toBeUndefined()
+ }
+ })
+ })
+
+ describe('scanJSON', () => {
+ it.each([
+ {
+ name: 'flag invalid JSON as medium severity',
+ json: '{"value": }',
+ expectedSeverity: 'medium',
+ expectedSafe: false,
+ expectedIssuePattern: 'JSON parse error',
+ },
+ {
+ name: 'flag prototype pollution in JSON as critical',
+ json: '{"__proto__": {"polluted": true}}',
+ expectedSeverity: 'critical',
+ expectedSafe: false,
+ expectedIssuePattern: '__proto__',
+ },
+ {
+ name: 'return safe for valid JSON',
+ json: '{"ok": true}',
+ expectedSeverity: 'safe',
+ expectedSafe: true,
+ },
+ ])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => {
+ const result = securityScanner.scanJSON(json)
+ expect(result.severity).toBe(expectedSeverity)
+ expect(result.safe).toBe(expectedSafe)
+
+ if (expectedIssuePattern) {
+ expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true)
+ } else {
+ expect(result.issues.length).toBe(0)
+ }
+
+ if (expectedSafe) {
+ expect(result.sanitizedCode).toBe(json)
+ } else {
+ expect(result.sanitizedCode).toBeUndefined()
+ }
+ })
+ })
+
+ describe('scanHTML', () => {
+ it.each([
+ {
+ name: 'flag script tags as critical',
+ html: '
',
+ expectedSeverity: 'critical',
+ expectedSafe: false,
+ },
+ {
+ name: 'flag inline handlers as high',
+ html: 'Click ',
+ expectedSeverity: 'high',
+ expectedSafe: false,
+ },
+ {
+ name: 'return safe for plain markup',
+ html: 'Safe
',
+ expectedSeverity: 'safe',
+ expectedSafe: true,
+ },
+ ])('should $name', ({ html, expectedSeverity, expectedSafe }) => {
+ const result = securityScanner.scanHTML(html)
+ expect(result.severity).toBe(expectedSeverity)
+ expect(result.safe).toBe(expectedSafe)
+ })
+ })
+
+ describe('sanitizeInput', () => {
+ it.each([
+ {
+ name: 'remove script tags and inline handlers from text',
+ input: 'Click
x ',
+ type: 'text' as const,
+ shouldExclude: ['',
+ type: 'html' as const,
+ shouldExclude: ['data:text/html', ' ',
+ expectedSeverity: 'critical',
+ },
+ {
+ name: 'falls back to JavaScript scanning',
+ code: 'const result = eval("1 + 1")',
+ expectedSeverity: 'critical',
+ },
+ {
+ name: 'honors explicit type parameter',
+ code: 'return 1',
+ type: 'lua' as const,
+ expectedSeverity: 'safe',
+ },
+ ])('should $name', ({ code, type, expectedSeverity }) => {
+ const result = scanForVulnerabilities(code, type)
+ expect(result.severity).toBe(expectedSeverity)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts
new file mode 100644
index 000000000..42f9a2dd2
--- /dev/null
+++ b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+
+import { getSeverityColor, getSeverityIcon } from '@/lib/security-scanner'
+
+describe('security-scanner reporting', () => {
+ describe('getSeverityColor', () => {
+ it.each([
+ { severity: 'critical', expected: 'error' },
+ { severity: 'high', expected: 'warning' },
+ { severity: 'medium', expected: 'info' },
+ { severity: 'low', expected: 'secondary' },
+ { severity: 'safe', expected: 'success' },
+ ])('should map $severity to expected classes', ({ severity, expected }) => {
+ expect(getSeverityColor(severity)).toBe(expected)
+ })
+ })
+
+ describe('getSeverityIcon', () => {
+ it.each([
+ { severity: 'critical', expected: '\u{1F6A8}' },
+ { severity: 'high', expected: '\u26A0\uFE0F' },
+ { severity: 'medium', expected: '\u26A1' },
+ { severity: 'low', expected: '\u2139\uFE0F' },
+ { severity: 'safe', expected: '\u2713' },
+ ])('should map $severity to expected icon', ({ severity, expected }) => {
+ expect(getSeverityIcon(severity)).toBe(expected)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts b/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts
index 142a8997b..0e9fcbd80 100644
--- a/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts
+++ b/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts
@@ -1,257 +1,2 @@
-import { describe, it, expect } from 'vitest'
-import { securityScanner, scanForVulnerabilities, getSeverityColor, getSeverityIcon } from '@/lib/security-scanner'
-
-describe('security-scanner', () => {
- describe('scanJavaScript', () => {
- it.each([
- {
- name: 'flag eval usage as critical',
- code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'),
- expectedSeverity: 'critical',
- expectedSafe: false,
- expectedIssueType: 'dangerous',
- expectedIssuePattern: 'eval',
- expectedLine: 2,
- },
- {
- name: 'warn on localStorage usage but stay safe',
- code: 'localStorage.setItem("k", "v")',
- expectedSeverity: 'low',
- expectedSafe: true,
- expectedIssueType: 'warning',
- expectedIssuePattern: 'localStorage',
- },
- {
- name: 'return safe for benign code',
- code: 'const sum = (a, b) => a + b',
- expectedSeverity: 'safe',
- expectedSafe: true,
- },
- ])(
- 'should $name',
- ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => {
- const result = securityScanner.scanJavaScript(code)
- expect(result.severity).toBe(expectedSeverity)
- expect(result.safe).toBe(expectedSafe)
-
- if (expectedIssueType || expectedIssuePattern) {
- const issue = result.issues.find(item => {
- const matchesType = expectedIssueType ? item.type === expectedIssueType : true
- const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
- return matchesType && matchesPattern
- })
- expect(issue).toBeDefined()
- if (expectedLine !== undefined) {
- expect(issue?.line).toBe(expectedLine)
- }
- } else {
- expect(result.issues.length).toBe(0)
- }
-
- if (expectedSafe) {
- expect(result.sanitizedCode).toBe(code)
- } else {
- expect(result.sanitizedCode).toBeUndefined()
- }
- }
- )
- })
-
- describe('scanLua', () => {
- it.each([
- {
- name: 'flag os.execute usage as critical',
- code: 'os.execute("rm -rf /")',
- expectedSeverity: 'critical',
- expectedSafe: false,
- expectedIssueType: 'malicious',
- expectedIssuePattern: 'os.execute',
- },
- {
- name: 'return safe for simple Lua function',
- code: 'function add(a, b) return a + b end',
- expectedSeverity: 'safe',
- expectedSafe: true,
- },
- ])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => {
- const result = securityScanner.scanLua(code)
- expect(result.severity).toBe(expectedSeverity)
- expect(result.safe).toBe(expectedSafe)
-
- if (expectedIssueType || expectedIssuePattern) {
- const issue = result.issues.find(item => {
- const matchesType = expectedIssueType ? item.type === expectedIssueType : true
- const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
- return matchesType && matchesPattern
- })
- expect(issue).toBeDefined()
- } else {
- expect(result.issues.length).toBe(0)
- }
-
- if (expectedSafe) {
- expect(result.sanitizedCode).toBe(code)
- } else {
- expect(result.sanitizedCode).toBeUndefined()
- }
- })
- })
-
- describe('scanJSON', () => {
- it.each([
- {
- name: 'flag invalid JSON as medium severity',
- json: '{"value": }',
- expectedSeverity: 'medium',
- expectedSafe: false,
- expectedIssuePattern: 'JSON parse error',
- },
- {
- name: 'flag prototype pollution in JSON as critical',
- json: '{"__proto__": {"polluted": true}}',
- expectedSeverity: 'critical',
- expectedSafe: false,
- expectedIssuePattern: '__proto__',
- },
- {
- name: 'return safe for valid JSON',
- json: '{"ok": true}',
- expectedSeverity: 'safe',
- expectedSafe: true,
- },
- ])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => {
- const result = securityScanner.scanJSON(json)
- expect(result.severity).toBe(expectedSeverity)
- expect(result.safe).toBe(expectedSafe)
-
- if (expectedIssuePattern) {
- expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true)
- } else {
- expect(result.issues.length).toBe(0)
- }
-
- if (expectedSafe) {
- expect(result.sanitizedCode).toBe(json)
- } else {
- expect(result.sanitizedCode).toBeUndefined()
- }
- })
- })
-
- describe('scanHTML', () => {
- it.each([
- {
- name: 'flag script tags as critical',
- html: '
',
- expectedSeverity: 'critical',
- expectedSafe: false,
- },
- {
- name: 'flag inline handlers as high',
- html: 'Click ',
- expectedSeverity: 'high',
- expectedSafe: false,
- },
- {
- name: 'return safe for plain markup',
- html: 'Safe
',
- expectedSeverity: 'safe',
- expectedSafe: true,
- },
- ])('should $name', ({ html, expectedSeverity, expectedSafe }) => {
- const result = securityScanner.scanHTML(html)
- expect(result.severity).toBe(expectedSeverity)
- expect(result.safe).toBe(expectedSafe)
- })
- })
-
- describe('sanitizeInput', () => {
- it.each([
- {
- name: 'remove script tags and inline handlers from text',
- input: 'Click
x ',
- type: 'text' as const,
- shouldExclude: ['',
- type: 'html' as const,
- shouldExclude: ['data:text/html', ' ',
- expectedSeverity: 'critical',
- },
- {
- name: 'falls back to JavaScript scanning',
- code: 'const result = eval("1 + 1")',
- expectedSeverity: 'critical',
- },
- {
- name: 'honors explicit type parameter',
- code: 'return 1',
- type: 'lua' as const,
- expectedSeverity: 'safe',
- },
- ])('should $name', ({ code, type, expectedSeverity }) => {
- const result = scanForVulnerabilities(code, type)
- expect(result.severity).toBe(expectedSeverity)
- })
- })
-})
+import './__tests__/security-scanner.detection.test'
+import './__tests__/security-scanner.reporting.test'
From a37459ed627101f996029b4eefe5a28aaad9a322 Mon Sep 17 00:00:00 2001
From: johndoe6345789
Date: Sat, 27 Dec 2025 18:55:07 +0000
Subject: [PATCH 31/80] chore: split javascript security patterns
---
.../functions/patterns/javascript-patterns.ts | 181 +-----------------
.../patterns/javascript/injection.ts | 53 +++++
.../functions/patterns/javascript/misc.ts | 81 ++++++++
.../functions/patterns/javascript/xss.ts | 53 +++++
4 files changed, 193 insertions(+), 175 deletions(-)
create mode 100644 frontends/nextjs/src/lib/security/functions/patterns/javascript/injection.ts
create mode 100644 frontends/nextjs/src/lib/security/functions/patterns/javascript/misc.ts
create mode 100644 frontends/nextjs/src/lib/security/functions/patterns/javascript/xss.ts
diff --git a/frontends/nextjs/src/lib/security/functions/patterns/javascript-patterns.ts b/frontends/nextjs/src/lib/security/functions/patterns/javascript-patterns.ts
index aa8214905..266675ae6 100644
--- a/frontends/nextjs/src/lib/security/functions/patterns/javascript-patterns.ts
+++ b/frontends/nextjs/src/lib/security/functions/patterns/javascript-patterns.ts
@@ -4,181 +4,12 @@
*/
import type { SecurityPattern } from '../types'
+import { JAVASCRIPT_INJECTION_PATTERNS } from './javascript/injection'
+import { JAVASCRIPT_MISC_PATTERNS } from './javascript/misc'
+import { JAVASCRIPT_XSS_PATTERNS } from './javascript/xss'
export const JAVASCRIPT_PATTERNS: SecurityPattern[] = [
- {
- pattern: /eval\s*\(/gi,
- type: 'dangerous',
- severity: 'critical',
- message: 'Use of eval() detected - can execute arbitrary code',
- recommendation: 'Use safe alternatives like JSON.parse() or Function constructor with strict validation'
- },
- {
- pattern: /Function\s*\(/gi,
- type: 'dangerous',
- severity: 'high',
- message: 'Dynamic Function constructor detected',
- recommendation: 'Avoid dynamic code generation or use with extreme caution'
- },
- {
- pattern: /innerHTML\s*=/gi,
- type: 'dangerous',
- severity: 'high',
- message: 'innerHTML assignment detected - XSS vulnerability risk',
- recommendation: 'Use textContent, createElement, or React JSX instead'
- },
- {
- pattern: /dangerouslySetInnerHTML/gi,
- type: 'dangerous',
- severity: 'high',
- message: 'dangerouslySetInnerHTML detected - XSS vulnerability risk',
- recommendation: 'Sanitize HTML content or use safe alternatives'
- },
- {
- pattern: /document\.write\s*\(/gi,
- type: 'dangerous',
- severity: 'medium',
- message: 'document.write() detected - can cause security issues',
- recommendation: 'Use DOM manipulation methods instead'
- },
- {
- pattern: /\.call\s*\(\s*window/gi,
- type: 'suspicious',
- severity: 'medium',
- message: 'Calling functions with window context',
- recommendation: 'Be careful with context manipulation'
- },
- {
- pattern: /\.apply\s*\(\s*window/gi,
- type: 'suspicious',
- severity: 'medium',
- message: 'Applying functions with window context',
- recommendation: 'Be careful with context manipulation'
- },
- {
- pattern: /__proto__/gi,
- type: 'dangerous',
- severity: 'critical',
- message: 'Prototype pollution attempt detected',
- recommendation: 'Never manipulate __proto__ directly'
- },
- {
- pattern: /constructor\s*\[\s*['"]prototype['"]\s*\]/gi,
- type: 'dangerous',
- severity: 'critical',
- message: 'Prototype manipulation detected',
- recommendation: 'Use Object.create() or proper class syntax'
- },
- {
- pattern: /import\s+.*\s+from\s+['"]https?:/gi,
- type: 'dangerous',
- severity: 'critical',
- message: 'Remote code import detected',
- recommendation: 'Only import from trusted, local sources'
- },
- {
- pattern: /