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[] = [
{
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)
+}
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,
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)) {
diff --git a/dbal/development/src/core/client.ts b/dbal/development/src/core/client.ts
new file mode 100644
index 000000000..789cabfc1
--- /dev/null
+++ b/dbal/development/src/core/client.ts
@@ -0,0 +1,8 @@
+import type { DBALConfig } from '../runtime/config'
+import { DBALClient } from './client/client'
+export { buildAdapter, buildEntityOperations } from './client/builders'
+export { normalizeClientConfig, validateClientConfig } from './client/mappers'
+
+export const createDBALClient = (config: DBALConfig) => new DBALClient(config)
+
+export { DBALClient }
diff --git a/dbal/development/src/core/client/builders.ts b/dbal/development/src/core/client/builders.ts
new file mode 100644
index 000000000..56fcf095f
--- /dev/null
+++ b/dbal/development/src/core/client/builders.ts
@@ -0,0 +1,24 @@
+import type { DBALAdapter } from '../../adapters/adapter'
+import type { DBALConfig } from '../../runtime/config'
+import { createAdapter } from './adapter-factory'
+import {
+ createComponentOperations,
+ createLuaScriptOperations,
+ createPackageOperations,
+ createPageOperations,
+ createSessionOperations,
+ createUserOperations,
+ createWorkflowOperations
+} from '../entities'
+
+export const buildAdapter = (config: DBALConfig): DBALAdapter => createAdapter(config)
+
+export const buildEntityOperations = (adapter: DBALAdapter) => ({
+ users: createUserOperations(adapter),
+ pages: createPageOperations(adapter),
+ components: createComponentOperations(adapter),
+ workflows: createWorkflowOperations(adapter),
+ luaScripts: createLuaScriptOperations(adapter),
+ packages: createPackageOperations(adapter),
+ sessions: createSessionOperations(adapter)
+})
diff --git a/dbal/development/src/core/client/client.ts b/dbal/development/src/core/client/client.ts
index b57eb6ea9..6c9a98691 100644
--- a/dbal/development/src/core/client/client.ts
+++ b/dbal/development/src/core/client/client.ts
@@ -1,7 +1,7 @@
/**
* @file client.ts
* @description DBAL Client - Main interface for database operations
- *
+ *
* Provides CRUD operations for all entities through modular operation handlers.
* Each entity type has its own dedicated operations module following the
* single-responsibility pattern.
@@ -9,82 +9,67 @@
import type { DBALConfig } from '../../runtime/config'
import type { DBALAdapter } from '../../adapters/adapter'
-import { createAdapter } from './adapter-factory'
-import {
- createUserOperations,
- createPageOperations,
- createComponentOperations,
- createWorkflowOperations,
- createLuaScriptOperations,
- createPackageOperations,
- createSessionOperations,
-} from '../entities'
+import { buildAdapter, buildEntityOperations } from './builders'
+import { normalizeClientConfig, validateClientConfig } from './mappers'
export class DBALClient {
private adapter: DBALAdapter
private config: DBALConfig
+ private operations: ReturnType
constructor(config: DBALConfig) {
- this.config = config
-
- // Validate configuration
- if (!config.adapter) {
- throw new Error('Adapter type must be specified')
- }
- if (config.mode !== 'production' && !config.database?.url) {
- throw new Error('Database URL must be specified for non-production mode')
- }
-
- this.adapter = createAdapter(config)
+ this.config = normalizeClientConfig(validateClientConfig(config))
+ this.adapter = buildAdapter(this.config)
+ this.operations = buildEntityOperations(this.adapter)
}
/**
* User entity operations
*/
get users() {
- return createUserOperations(this.adapter)
+ return this.operations.users
}
/**
* Page entity operations
*/
get pages() {
- return createPageOperations(this.adapter)
+ return this.operations.pages
}
/**
* Component hierarchy entity operations
*/
get components() {
- return createComponentOperations(this.adapter)
+ return this.operations.components
}
/**
* Workflow entity operations
*/
get workflows() {
- return createWorkflowOperations(this.adapter)
+ return this.operations.workflows
}
/**
* Lua script entity operations
*/
get luaScripts() {
- return createLuaScriptOperations(this.adapter)
+ return this.operations.luaScripts
}
/**
* Package entity operations
*/
get packages() {
- return createPackageOperations(this.adapter)
+ return this.operations.packages
}
/**
* Session entity operations
*/
get sessions() {
- return createSessionOperations(this.adapter)
+ return this.operations.sessions
}
/**
diff --git a/dbal/development/src/core/client/mappers.ts b/dbal/development/src/core/client/mappers.ts
new file mode 100644
index 000000000..b9abc9661
--- /dev/null
+++ b/dbal/development/src/core/client/mappers.ts
@@ -0,0 +1,25 @@
+import type { DBALConfig } from '../../runtime/config'
+import { DBALError } from '../foundation/errors'
+
+export const validateClientConfig = (config: DBALConfig): DBALConfig => {
+ if (!config.adapter) {
+ throw DBALError.validationError('Adapter type must be specified', [])
+ }
+
+ if (config.mode !== 'production' && !config.database?.url) {
+ throw DBALError.validationError('Database URL must be specified for non-production mode', [])
+ }
+
+ return config
+}
+
+export const normalizeClientConfig = (config: DBALConfig): DBALConfig => ({
+ ...config,
+ security: {
+ sandbox: config.security?.sandbox ?? 'strict',
+ enableAuditLog: config.security?.enableAuditLog ?? true
+ },
+ performance: {
+ ...config.performance
+ }
+})
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 })))
+ }
+}
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)
+}
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
+}
diff --git a/dbal/development/src/index.ts b/dbal/development/src/index.ts
index 7acf658e0..e98734f17 100644
--- a/dbal/development/src/index.ts
+++ b/dbal/development/src/index.ts
@@ -1,4 +1,4 @@
-export { DBALClient } from './core/client/client'
+export { DBALClient, createDBALClient } from './core/client'
export type { DBALConfig } from './runtime/config'
export type * from './core/foundation/types'
export { DBALError, DBALErrorCode } from './core/foundation/errors'
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)
+}
diff --git a/detection/detectors/class-detector.ts b/detection/detectors/class-detector.ts
new file mode 100644
index 000000000..2fb08b909
--- /dev/null
+++ b/detection/detectors/class-detector.ts
@@ -0,0 +1,64 @@
+import * as ts from 'typescript'
+import { Detector, DetectionFinding, DetectorContext } from '..'
+
+const getLocation = (sourceFile: ts.SourceFile, node: ts.Node) => {
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
+
+ return {
+ line: line + 1,
+ column: character + 1
+ }
+}
+
+const getClassName = (
+ node: ts.ClassDeclaration | ts.ClassExpression,
+ sourceFile: ts.SourceFile
+): string => {
+ if (node.name) {
+ return node.name.getText(sourceFile)
+ }
+
+ const parent = node.parent
+
+ if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
+ return parent.name.text
+ }
+
+ return 'anonymous'
+}
+
+const collectClasses = (context: DetectorContext): DetectionFinding[] => {
+ const sourceFile = ts.createSourceFile(
+ context.filePath,
+ context.source,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX
+ )
+
+ const findings: DetectionFinding[] = []
+
+ const visit = (node: ts.Node) => {
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
+ const name = getClassName(node, sourceFile)
+ findings.push({
+ detectorId: 'class-detector',
+ name,
+ message: `Class detected: ${name}`,
+ location: getLocation(sourceFile, node)
+ })
+ }
+
+ ts.forEachChild(node, visit)
+ }
+
+ visit(sourceFile)
+
+ return findings
+}
+
+export const classDetector: Detector = {
+ id: 'class-detector',
+ description: 'Detects class declarations and expressions within a TypeScript/TSX source file.',
+ detect: collectClasses
+}
diff --git a/detection/detectors/function-detector.ts b/detection/detectors/function-detector.ts
new file mode 100644
index 000000000..7bfffafd1
--- /dev/null
+++ b/detection/detectors/function-detector.ts
@@ -0,0 +1,78 @@
+import * as ts from 'typescript'
+import { Detector, DetectionFinding, DetectorContext } from '..'
+
+type FunctionLike =
+ | ts.FunctionDeclaration
+ | ts.FunctionExpression
+ | ts.ArrowFunction
+ | ts.MethodDeclaration
+ | ts.ConstructorDeclaration
+
+const getLocation = (sourceFile: ts.SourceFile, node: ts.Node) => {
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
+
+ return {
+ line: line + 1,
+ column: character + 1
+ }
+}
+
+const getFunctionName = (node: FunctionLike, sourceFile: ts.SourceFile): string => {
+ if ('name' in node && node.name) {
+ return node.name.getText(sourceFile)
+ }
+
+ const parent = node.parent
+
+ if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
+ return parent.name.text
+ }
+
+ if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) {
+ return parent.name.text
+ }
+
+ return 'anonymous'
+}
+
+const collectFunctions = (context: DetectorContext): DetectionFinding[] => {
+ const sourceFile = ts.createSourceFile(
+ context.filePath,
+ context.source,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX
+ )
+
+ const findings: DetectionFinding[] = []
+
+ const visit = (node: ts.Node) => {
+ if (
+ ts.isFunctionDeclaration(node) ||
+ ts.isFunctionExpression(node) ||
+ ts.isArrowFunction(node) ||
+ ts.isMethodDeclaration(node) ||
+ ts.isConstructorDeclaration(node)
+ ) {
+ const name = getFunctionName(node, sourceFile)
+ findings.push({
+ detectorId: 'function-detector',
+ name,
+ message: `Function detected: ${name}`,
+ location: getLocation(sourceFile, node)
+ })
+ }
+
+ ts.forEachChild(node, visit)
+ }
+
+ visit(sourceFile)
+
+ return findings
+}
+
+export const functionDetector: Detector = {
+ id: 'function-detector',
+ description: 'Detects functions and methods within a TypeScript/TSX source file.',
+ detect: collectFunctions
+}
diff --git a/detection/index.ts b/detection/index.ts
new file mode 100644
index 000000000..7f5eb21f4
--- /dev/null
+++ b/detection/index.ts
@@ -0,0 +1,45 @@
+import { classDetector } from './detectors/class-detector'
+import { functionDetector } from './detectors/function-detector'
+
+export type DetectorContext = {
+ filePath: string
+ source: string
+}
+
+export type DetectionFinding = {
+ detectorId: string
+ name: string
+ message: string
+ location?: {
+ line: number
+ column: number
+ }
+}
+
+export interface Detector {
+ id: string
+ description: string
+ detect: (context: DetectorContext) => DetectionFinding[]
+}
+
+export class DetectorRegistry {
+ private readonly detectors: Detector[] = []
+
+ register(detector: Detector): void {
+ this.detectors.push(detector)
+ }
+
+ list(): Detector[] {
+ return [...this.detectors]
+ }
+
+ run(context: DetectorContext): DetectionFinding[] {
+ return this.detectors.flatMap((detector) => detector.detect(context))
+ }
+}
+
+export const registry = new DetectorRegistry()
+
+const builtInDetectors: Detector[] = [functionDetector, classDetector]
+
+builtInDetectors.forEach((detector) => registry.register(detector))
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 }
+}
diff --git a/frontends/nextjs/src/components/auth/god-credentials/Form.tsx b/frontends/nextjs/src/components/auth/god-credentials/Form.tsx
new file mode 100644
index 000000000..88700c036
--- /dev/null
+++ b/frontends/nextjs/src/components/auth/god-credentials/Form.tsx
@@ -0,0 +1,83 @@
+import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
+
+export interface GodCredentialsFormProps {
+ duration: number
+ unit: 'minutes' | 'hours'
+ onDurationChange: (value: number) => void
+ onUnitChange: (unit: 'minutes' | 'hours') => void
+ onSave: () => void
+ onResetExpiry: () => void
+ onClearExpiry: () => void
+}
+
+export function GodCredentialsForm({
+ duration,
+ unit,
+ onDurationChange,
+ onUnitChange,
+ onSave,
+ onResetExpiry,
+ onClearExpiry,
+}: GodCredentialsFormProps) {
+ return (
+
+
+
+
Expiry Duration
+
+ onDurationChange(Number(e.target.value))}
+ className="flex-1"
+ />
+ onUnitChange(value as 'minutes' | 'hours')}>
+
+
+
+
+ Minutes
+ Hours
+
+
+
+
+ Set the duration for how long credentials are visible (1 minute to 24 hours)
+
+
+
+
+
+ Save Duration
+
+
+
+
+
+
+
Expiry Management
+
+ Reset or clear the current expiry timer
+
+
+
+
+
+ Reset Timer
+
+
+ Clear Expiry
+
+
+
+
+ Reset Timer: Restart the countdown using the configured duration
+ Clear Expiry: Remove expiry time (credentials will show on next page load)
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/auth/god-credentials/Summary.tsx b/frontends/nextjs/src/components/auth/god-credentials/Summary.tsx
new file mode 100644
index 000000000..84cba9aa9
--- /dev/null
+++ b/frontends/nextjs/src/components/auth/god-credentials/Summary.tsx
@@ -0,0 +1,42 @@
+import { Alert, AlertDescription, Badge } from '@/components/ui'
+import { CheckCircle, WarningCircle } from '@phosphor-icons/react'
+
+export interface GodCredentialsSummaryProps {
+ isActive: boolean
+ expiryTime: number
+ timeRemaining: string
+}
+
+export function GodCredentialsSummary({ isActive, expiryTime, timeRemaining }: GodCredentialsSummaryProps) {
+ if (isActive) {
+ return (
+
+
+
+
+
+ God credentials are currently visible
+ Active
+
+
+ Time remaining: {timeRemaining}
+
+
+
+
+ )
+ }
+
+ if (!isActive && expiryTime > 0) {
+ return (
+
+
+
+ God credentials have expired or been hidden
+
+
+ )
+ }
+
+ return null
+}
diff --git a/frontends/nextjs/src/components/auth/unified-login/LoginForm.tsx b/frontends/nextjs/src/components/auth/unified-login/LoginForm.tsx
new file mode 100644
index 000000000..9e3a81491
--- /dev/null
+++ b/frontends/nextjs/src/components/auth/unified-login/LoginForm.tsx
@@ -0,0 +1,48 @@
+import { Button, Input, Label, Alert, AlertDescription } from '@/components/ui'
+import { SignIn } from '@phosphor-icons/react'
+
+export interface LoginFormProps {
+ username: string
+ password: string
+ onUsernameChange: (value: string) => void
+ onPasswordChange: (value: string) => void
+ onSubmit: () => void
+}
+
+export function LoginForm({ username, password, onUsernameChange, onPasswordChange, onSubmit }: LoginFormProps) {
+ return (
+
+
+ Username
+ onUsernameChange(e.target.value)}
+ placeholder="Enter username"
+ onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
+ />
+
+
+ Password
+ onPasswordChange(e.target.value)}
+ placeholder="Enter password"
+ onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
+ />
+
+
+
+ Sign In
+
+
+
+ Test Credentials:
+ Check browser console for default user passwords (they are scrambled on first run)
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/auth/unified-login/ProviderList.tsx b/frontends/nextjs/src/components/auth/unified-login/ProviderList.tsx
new file mode 100644
index 000000000..3bfd01527
--- /dev/null
+++ b/frontends/nextjs/src/components/auth/unified-login/ProviderList.tsx
@@ -0,0 +1,50 @@
+import { Button, Separator } from '@/components/ui'
+import { GoogleLogo, GithubLogo, IconProps } from '@phosphor-icons/react'
+
+export interface Provider {
+ name: string
+ description?: string
+ icon?: React.ComponentType
+}
+
+export interface ProviderListProps {
+ providers: Provider[]
+ onSelect?: (provider: Provider) => void
+}
+
+const FALLBACK_PROVIDERS: Provider[] = [
+ { name: 'Google', description: 'Use your Google Workspace account', icon: GoogleLogo },
+ { name: 'GitHub', description: 'Developer SSO via GitHub', icon: GithubLogo },
+]
+
+export function ProviderList({ providers, onSelect }: ProviderListProps) {
+ const entries = providers.length > 0 ? providers : FALLBACK_PROVIDERS
+
+ return (
+
+
+
Or continue with
+
+ {entries.map((provider) => {
+ const Icon = provider.icon
+ return (
+ onSelect?.(provider)}
+ >
+ {Icon ? : null}
+ {provider.name}
+ {provider.description ? (
+
+ {provider.description}
+
+ ) : null}
+
+ )
+ })}
+
+
+ )
+}
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/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/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
+
+
+ )
+}
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/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
+
+ )}
+
+
+
+ ))
+ )}
+
+
+ ))}
+ >
+ )
+}
diff --git a/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts b/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts
index 04143e9b2..786b6f586 100644
--- a/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts
+++ b/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts
@@ -1,14 +1,15 @@
import type { BlockCategory, BlockDefinition } from '../types'
-export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
- const categories: Record = {
- Basics: [],
- Logic: [],
- Loops: [],
- Data: [],
- Functions: [],
- }
+const createCategoryIndex = (): Record => ({
+ Basics: [],
+ Logic: [],
+ Loops: [],
+ Data: [],
+ Functions: [],
+})
+export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
+ const categories = createCategoryIndex()
definitions.forEach((definition) => {
categories[definition.category].push(definition)
})
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,
+ }
+}
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
+ })
+}
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/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
+}
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}
+
+
+
+
+
+ )
+}
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..582fa70a4 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,19 +43,20 @@ 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/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
+
+
+
+ )
+ })}
+
+
+ )}
+
+
+ )
+}
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}
+
+ )
+}
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 },
+]
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)}
+
+
+ ))
+ )}
+
+
+
+ )
+}
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/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/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)
+}
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,
+ }
+}
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]) => (
+
+ ))}
+
+
+
+
+
+ )
+}
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}
+
+ ))}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/package/PackageDetailsDialog.tsx b/frontends/nextjs/src/components/managers/package/PackageDetailsDialog.tsx
new file mode 100644
index 000000000..14b63509e
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/package/PackageDetailsDialog.tsx
@@ -0,0 +1,185 @@
+import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
+import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
+import type { InstalledPackage } from '@/lib/package-types'
+import { Download, Star, Tag, Trash, User } from '@phosphor-icons/react'
+import { DependenciesTab } from './tabs/DependenciesTab'
+import { ScriptsTab } from './tabs/ScriptsTab'
+
+interface PackageDetailsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedPackage: PackageCatalogData | null
+ installing: boolean
+ onInstall: (packageId: string) => void
+ onUninstall: (packageId: string) => void
+ installedPackages: InstalledPackage[]
+ getCatalogEntry: (packageId: string) => PackageCatalogData | undefined
+}
+
+export function PackageDetailsDialog({
+ open,
+ onOpenChange,
+ selectedPackage,
+ installing,
+ onInstall,
+ onUninstall,
+ installedPackages,
+ getCatalogEntry,
+}: PackageDetailsDialogProps) {
+ if (!selectedPackage) return null
+
+ const { manifest, content } = selectedPackage
+
+ return (
+
+
+
+
+
+ {manifest.icon}
+
+
+
{manifest.name}
+
{manifest.description}
+
+
{manifest.category}
+
+
+ {manifest.downloadCount.toLocaleString()}
+
+
+
+ {manifest.rating}
+
+
+
+
+
+
+
+
+
+
+
+ Overview
+ Dependencies
+ Scripts
+
+
+
+
+
+
+
+
+
Author
+
+
+ {manifest.author}
+
+
+
+
Version
+
{manifest.version}
+
+
+
+
+
Tags
+
+ {manifest.tags.map(tag => (
+
+
+ {tag}
+
+ ))}
+
+
+
+
+
Includes
+
+
+
Data Models
+
{content.schemas.length}
+
+
+
Pages
+
{content.pages.length}
+
+
+
Workflows
+
{content.workflows.length}
+
+
+
Scripts
+
{content.luaScripts.length}
+
+
+
+
+ {content.schemas.length > 0 && (
+
+
Data Models
+
+ {content.schemas.map(schema => (
+
+
{schema.displayName || schema.name}
+
{schema.fields.length} fields
+
+ ))}
+
+
+ )}
+
+ {content.pages.length > 0 && (
+
+
Pages
+
+ {content.pages.map(page => (
+
+
{page.title}
+
{page.path}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {manifest.installed ? (
+ onUninstall(manifest.id)}>
+
+ Uninstall
+
+ ) : (
+ onInstall(manifest.id)} disabled={installing}>
+
+ {installing ? 'Installing...' : 'Install Package'}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/package/PackageManager.tsx b/frontends/nextjs/src/components/managers/package/PackageManager.tsx
index 5bdeda96e..ea5300946 100644
--- a/frontends/nextjs/src/components/managers/package/PackageManager.tsx
+++ b/frontends/nextjs/src/components/managers/package/PackageManager.tsx
@@ -1,12 +1,12 @@
import { useState } from 'react'
-import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator } from '@/components/ui'
-import { toast } from 'sonner'
-import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
+import { Button } from '@/components/ui'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
-import { ArrowSquareIn, Download, Export, Package, Star, Tag, Trash, User } from '@phosphor-icons/react'
+import { ArrowSquareIn, Export, Package } from '@phosphor-icons/react'
+import { PackageDetailsDialog } from './PackageDetailsDialog'
import { PackageImportExport } from './PackageImportExport'
import { PackageFilters } from './package-manager/PackageFilters'
import { PackageTabs } from './package-manager/PackageTabs'
+import { usePackageActions } from './package-manager/usePackageActions'
import { usePackages } from './package-manager/usePackages'
interface PackageManagerProps {
@@ -31,61 +31,12 @@ export function PackageManager({ onClose }: PackageManagerProps) {
} = usePackages()
const [selectedPackage, setSelectedPackage] = useState(null)
const [showDetails, setShowDetails] = useState(false)
- const [installing, setInstalling] = useState(false)
const [showImportExport, setShowImportExport] = useState(false)
const [importExportMode, setImportExportMode] = useState<'import' | 'export'>('export')
-
- const handleInstallPackage = async (packageId: string) => {
- setInstalling(true)
- try {
- const packageEntry = getCatalogEntry(packageId)
- if (!packageEntry) {
- toast.error('Package not found')
- return
- }
-
- await installPackage(packageId)
-
- toast.success(`${packageEntry.manifest.name} installed successfully!`)
- await loadPackages()
- setShowDetails(false)
- } catch (error) {
- console.error('Installation error:', error)
- toast.error('Failed to install package')
- } finally {
- setInstalling(false)
- }
- }
-
- const handleUninstallPackage = async (packageId: string) => {
- try {
- const packageEntry = getCatalogEntry(packageId)
- if (!packageEntry) {
- toast.error('Package not found')
- return
- }
-
- await uninstallPackage(packageId)
-
- toast.success(`${packageEntry.manifest.name} uninstalled successfully!`)
- await loadPackages()
- setShowDetails(false)
- } catch (error) {
- console.error('Uninstallation error:', error)
- toast.error('Failed to uninstall package')
- }
- }
-
- const handleTogglePackage = async (packageId: string, enabled: boolean) => {
- try {
- await togglePackageEnabled(packageId, enabled)
- toast.success(enabled ? 'Package enabled' : 'Package disabled')
- await loadPackages()
- } catch (error) {
- console.error('Toggle error:', error)
- toast.error('Failed to toggle package')
- }
- }
+ const { installing, handleInstallPackage, handleUninstallPackage, handleTogglePackage } = usePackageActions({
+ loadPackages,
+ getCatalogEntry,
+ })
const openPackageDetails = (packageId: string) => {
const catalogEntry = getCatalogEntry(packageId)
@@ -162,131 +113,16 @@ export function PackageManager({ onClose }: PackageManagerProps) {
/>
-
-
- {selectedPackage && (
- <>
-
-
-
- {selectedPackage.manifest.icon}
-
-
-
{selectedPackage.manifest.name}
-
{selectedPackage.manifest.description}
-
-
{selectedPackage.manifest.category}
-
-
- {selectedPackage.manifest.downloadCount.toLocaleString()}
-
-
-
- {selectedPackage.manifest.rating}
-
-
-
-
-
-
-
-
-
-
-
-
Author
-
-
- {selectedPackage.manifest.author}
-
-
-
-
-
Version
-
{selectedPackage.manifest.version}
-
-
-
-
Tags
-
- {selectedPackage.manifest.tags.map(tag => (
-
-
- {tag}
-
- ))}
-
-
-
-
-
Includes
-
-
-
Data Models
-
{selectedPackage.content.schemas.length}
-
-
-
Pages
-
{selectedPackage.content.pages.length}
-
-
-
Workflows
-
{selectedPackage.content.workflows.length}
-
-
-
Scripts
-
{selectedPackage.content.luaScripts.length}
-
-
-
-
- {selectedPackage.content.schemas.length > 0 && (
-
-
Data Models
-
- {selectedPackage.content.schemas.map(schema => (
-
-
{schema.displayName || schema.name}
-
{schema.fields.length} fields
-
- ))}
-
-
- )}
-
- {selectedPackage.content.pages.length > 0 && (
-
-
Pages
-
- {selectedPackage.content.pages.map(page => (
-
-
{page.title}
-
{page.path}
-
- ))}
-
-
- )}
-
-
-
-
- {selectedPackage.manifest.installed ? (
- handleUninstallPackage(selectedPackage.manifest.id)}>
-
- Uninstall
-
- ) : (
- handleInstallPackage(selectedPackage.manifest.id)} disabled={installing}>
-
- {installing ? 'Installing...' : 'Install Package'}
-
- )}
-
- >
- )}
-
-
+ handleInstallPackage(packageId, () => setShowDetails(false))}
+ onUninstall={(packageId) => handleUninstallPackage(packageId, () => setShowDetails(false))}
+ installedPackages={installedPackages}
+ getCatalogEntry={getCatalogEntry}
+ />
-
-
- Package Name *
- setManifest(prev => ({ ...prev, name: e.target.value }))}
- />
-
-
-
-
-
- Description
-
-
-
-
Tags
-
- setTagInput(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
- />
-
- Add
-
-
- {manifest.tags && manifest.tags.length > 0 && (
-
- {manifest.tags.map(tag => (
-
- {tag}
- onRemoveTag(tag)}
- className="text-muted-foreground hover:text-foreground"
- >
- ×
-
-
- ))}
-
- )}
-
-
+
-
-
Export Options
-
- {exportOptionLabels.map(({ key, label }) => (
-
-
- setExportOptions(prev => ({ ...prev, [key]: checked as boolean }))
- }
- />
-
- {label}
-
-
- ))}
-
-
+
diff --git a/frontends/nextjs/src/components/managers/package/import-export/ExportManifestForm.tsx b/frontends/nextjs/src/components/managers/package/import-export/ExportManifestForm.tsx
new file mode 100644
index 000000000..6d220d3b3
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/package/import-export/ExportManifestForm.tsx
@@ -0,0 +1,100 @@
+import type React from 'react'
+import { Button, Input, Label, Textarea } from '@/components/ui'
+import type { PackageManifest } from '@/lib/package-types'
+
+interface ExportManifestFormProps {
+ manifest: Partial
+ setManifest: React.Dispatch>>
+ tagInput: string
+ setTagInput: (value: string) => void
+ onAddTag: () => void
+ onRemoveTag: (tag: string) => void
+}
+
+export function ExportManifestForm({
+ manifest,
+ setManifest,
+ tagInput,
+ setTagInput,
+ onAddTag,
+ onRemoveTag,
+}: ExportManifestFormProps) {
+ return (
+
+
+ Package Name *
+ setManifest(prev => ({ ...prev, name: e.target.value }))}
+ />
+
+
+
+
+
+ Description
+
+
+
+
Tags
+
+ setTagInput(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
+ />
+
+ Add
+
+
+ {manifest.tags && manifest.tags.length > 0 && (
+
+ {manifest.tags.map(tag => (
+
+ {tag}
+ onRemoveTag(tag)}
+ className="text-muted-foreground hover:text-foreground"
+ >
+ ×
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/package/import-export/ExportOptions.tsx b/frontends/nextjs/src/components/managers/package/import-export/ExportOptions.tsx
new file mode 100644
index 000000000..82fb5c41a
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/package/import-export/ExportOptions.tsx
@@ -0,0 +1,42 @@
+import { Checkbox, Label } from '@/components/ui'
+import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
+
+const exportOptionLabels: { key: keyof ExportPackageOptions; label: string }[] = [
+ { key: 'includeSchemas', label: 'Include data schemas' },
+ { key: 'includePages', label: 'Include page configurations' },
+ { key: 'includeWorkflows', label: 'Include workflows' },
+ { key: 'includeLuaScripts', label: 'Include Lua scripts' },
+ { key: 'includeComponentHierarchy', label: 'Include component hierarchies' },
+ { key: 'includeComponentConfigs', label: 'Include component configurations' },
+ { key: 'includeCssClasses', label: 'Include CSS classes' },
+ { key: 'includeDropdownConfigs', label: 'Include dropdown configurations' },
+ { key: 'includeSeedData', label: 'Include seed data' },
+ { key: 'includeAssets', label: 'Include assets (images, videos, audio, documents)' },
+]
+
+interface ExportOptionsProps {
+ exportOptions: ExportPackageOptions
+ setExportOptions: React.Dispatch>
+}
+
+export function ExportOptions({ exportOptions, setExportOptions }: ExportOptionsProps) {
+ return (
+
+
Export Options
+
+ {exportOptionLabels.map(({ key, label }) => (
+
+ setExportOptions(prev => ({ ...prev, [key]: checked as boolean }))}
+ />
+
+ {label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/package/package-manager/usePackageActions.ts b/frontends/nextjs/src/components/managers/package/package-manager/usePackageActions.ts
new file mode 100644
index 000000000..700c04435
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/package/package-manager/usePackageActions.ts
@@ -0,0 +1,83 @@
+import { useCallback, useState } from 'react'
+import { toast } from 'sonner'
+import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
+import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
+
+interface UsePackageActionsProps {
+ loadPackages: () => Promise
+ getCatalogEntry: (packageId: string) => PackageCatalogData | undefined
+}
+
+export function usePackageActions({ loadPackages, getCatalogEntry }: UsePackageActionsProps) {
+ const [installing, setInstalling] = useState(false)
+
+ const resolvePackage = useCallback(
+ (packageId: string) => {
+ const packageEntry = getCatalogEntry(packageId)
+ if (!packageEntry) {
+ toast.error('Package not found')
+ return null
+ }
+ return packageEntry
+ },
+ [getCatalogEntry]
+ )
+
+ const handleInstallPackage = useCallback(
+ async (packageId: string, onComplete?: () => void) => {
+ setInstalling(true)
+ const packageEntry = resolvePackage(packageId)
+ if (!packageEntry) {
+ setInstalling(false)
+ return
+ }
+
+ try {
+ await installPackage(packageId)
+ toast.success(`${packageEntry.manifest.name} installed successfully!`)
+ await loadPackages()
+ onComplete?.()
+ } catch (error) {
+ console.error('Installation error:', error)
+ toast.error('Failed to install package')
+ } finally {
+ setInstalling(false)
+ }
+ },
+ [loadPackages, resolvePackage]
+ )
+
+ const handleUninstallPackage = useCallback(
+ async (packageId: string, onComplete?: () => void) => {
+ const packageEntry = resolvePackage(packageId)
+ if (!packageEntry) return
+
+ try {
+ await uninstallPackage(packageId)
+ toast.success(`${packageEntry.manifest.name} uninstalled successfully!`)
+ await loadPackages()
+ onComplete?.()
+ } catch (error) {
+ console.error('Uninstallation error:', error)
+ toast.error('Failed to uninstall package')
+ }
+ },
+ [loadPackages, resolvePackage]
+ )
+
+ const handleTogglePackage = useCallback(
+ async (packageId: string, enabled: boolean) => {
+ try {
+ await togglePackageEnabled(packageId, enabled)
+ toast.success(enabled ? 'Package enabled' : 'Package disabled')
+ await loadPackages()
+ } catch (error) {
+ console.error('Toggle error:', error)
+ toast.error('Failed to toggle package')
+ }
+ },
+ [loadPackages]
+ )
+
+ return { installing, handleInstallPackage, handleUninstallPackage, handleTogglePackage }
+}
diff --git a/frontends/nextjs/src/components/managers/package/tabs/DependenciesTab.tsx b/frontends/nextjs/src/components/managers/package/tabs/DependenciesTab.tsx
new file mode 100644
index 000000000..516b95328
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/package/tabs/DependenciesTab.tsx
@@ -0,0 +1,57 @@
+import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
+import type { InstalledPackage } from '@/lib/package-types'
+import { CheckCircle, WarningCircle } from '@phosphor-icons/react'
+
+interface DependenciesTabProps {
+ dependencies: string[]
+ installedPackages: InstalledPackage[]
+ resolveCatalogEntry: (packageId: string) => PackageCatalogData | undefined
+}
+
+export function DependenciesTab({ dependencies, installedPackages, resolveCatalogEntry }: DependenciesTabProps) {
+ if (dependencies.length === 0) {
+ return No dependencies required.
+ }
+
+ return (
+
+ {dependencies.map(dependencyId => {
+ const catalogEntry = resolveCatalogEntry(dependencyId)
+ const isInstalled = installedPackages.some(pkg => pkg.packageId === dependencyId)
+ const dependencyName = catalogEntry?.manifest.name ?? dependencyId
+ const dependencyDescription = catalogEntry?.manifest.description ?? 'Dependency not found in catalog.'
+
+ return (
+
+
+
+
+ {dependencyName}
+ {isInstalled ? 'Installed' : 'Missing'}
+
+ {dependencyDescription}
+
+ {isInstalled ? (
+
+ ) : (
+
+ )}
+
+ {catalogEntry?.manifest.tags?.length ? (
+
+
+ {catalogEntry.manifest.tags.map(tag => (
+
+ {tag}
+
+ ))}
+
+
+ ) : null}
+
+ )
+ })}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/managers/package/tabs/ScriptsTab.tsx b/frontends/nextjs/src/components/managers/package/tabs/ScriptsTab.tsx
new file mode 100644
index 000000000..795a9a1c2
--- /dev/null
+++ b/frontends/nextjs/src/components/managers/package/tabs/ScriptsTab.tsx
@@ -0,0 +1,42 @@
+import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+
+interface Script {
+ id?: string
+ name?: string
+ description?: string
+ category?: string
+ code?: string
+}
+
+interface ScriptsTabProps {
+ scripts: Script[]
+}
+
+export function ScriptsTab({ scripts }: ScriptsTabProps) {
+ if (!scripts.length) {
+ return No scripts included in this package.
+ }
+
+ return (
+
+ {scripts.map((script, index) => (
+
+
+
+ {script.name ?? 'Unnamed Script'}
+ {script.description ?? 'No description provided.'}
+
+ {script.category ? {script.category} : null}
+
+ {script.code ? (
+
+
+ {script.code}
+
+
+ ) : null}
+
+ ))}
+
+ )
+}
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)}
+ >
+
+
+
+
+
+ ))}
+
+
+ )
+}
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.
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/auth/GodCredentialsSettings.tsx b/frontends/nextjs/src/components/misc/auth/GodCredentialsSettings.tsx
index 6574e2dbf..d1c099d80 100644
--- a/frontends/nextjs/src/components/misc/auth/GodCredentialsSettings.tsx
+++ b/frontends/nextjs/src/components/misc/auth/GodCredentialsSettings.tsx
@@ -1,13 +1,10 @@
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
-import { Button } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Label } from '@/components/ui'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
-import { Alert, AlertDescription } from '@/components/ui'
+import { GodCredentialsForm } from '@/components/auth/god-credentials/Form'
+import { GodCredentialsSummary } from '@/components/auth/god-credentials/Summary'
import { Database } from '@/lib/database'
import { toast } from 'sonner'
-import { Clock, Key, WarningCircle, CheckCircle } from '@phosphor-icons/react'
+import { Key } from '@phosphor-icons/react'
export function GodCredentialsSettings() {
const [duration, setDuration] = useState(60)
@@ -112,91 +109,21 @@ export function GodCredentialsSettings() {
- {isActive && (
-
-
-
-
-
- God credentials are currently visible on the front page
-
-
- Time remaining: {timeRemaining}
-
-
-
-
- )}
+
- {!isActive && expiryTime > 0 && (
-
-
-
-
- God credentials have expired or been hidden
-
-
-
- )}
-
-
-
-
Expiry Duration
-
- setDuration(Number(e.target.value))}
- className="flex-1"
- />
- setUnit(v as 'minutes' | 'hours')}>
-
-
-
-
- Minutes
- Hours
-
-
-
-
- Set the duration for how long credentials are visible (1 minute to 24 hours)
-
-
-
-
-
-
- Save Duration
-
-
-
-
-
-
-
Expiry Management
-
- Reset or clear the current expiry timer
-
-
-
-
-
- Reset Timer
-
-
- Clear Expiry
-
-
-
-
- Reset Timer: Restart the countdown using the configured duration
- Clear Expiry: Remove expiry time (credentials will show on next page load)
-
-
+
)
diff --git a/frontends/nextjs/src/components/misc/auth/UnifiedLogin.tsx b/frontends/nextjs/src/components/misc/auth/UnifiedLogin.tsx
index ea705d863..074034aa1 100644
--- a/frontends/nextjs/src/components/misc/auth/UnifiedLogin.tsx
+++ b/frontends/nextjs/src/components/misc/auth/UnifiedLogin.tsx
@@ -4,11 +4,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import { SignIn, UserPlus, ArrowLeft, Envelope } from '@phosphor-icons/react'
+import { ArrowLeft, Envelope, SignIn, UserPlus } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { Database, hashPassword } from '@/lib/database'
import { generateScrambledPassword, simulateEmailSend } from '@/lib/password-utils'
import { Alert, AlertDescription } from '@/components/ui'
+import { LoginForm } from '@/components/auth/unified-login/LoginForm'
+import { Provider, ProviderList } from '@/components/auth/unified-login/ProviderList'
export interface UnifiedLoginProps {
onLogin: (credentials: { username: string; password: string }) => void
@@ -20,6 +22,14 @@ export function UnifiedLogin({ onLogin, onRegister, onBack }: UnifiedLoginProps)
const [loginForm, setLoginForm] = useState({ username: '', password: '' })
const [registerForm, setRegisterForm] = useState({ username: '', email: '' })
const [resetEmail, setResetEmail] = useState('')
+ const providers: Provider[] = [
+ { name: 'Google', description: 'Use your Google Workspace account' },
+ { name: 'GitHub', description: 'Developer SSO via GitHub' },
+ ]
+
+ const handleProviderSelect = (provider: Provider) => {
+ toast.info(`${provider.name} login is coming soon`)
+ }
const handleLogin = () => {
if (!loginForm.username || !loginForm.password) {
@@ -119,37 +129,14 @@ export function UnifiedLogin({ onLogin, onRegister, onBack }: UnifiedLoginProps)
-
- Username
- setLoginForm({ ...loginForm, username: e.target.value })}
- placeholder="Enter username"
- onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
- />
-
-
- Password
- setLoginForm({ ...loginForm, password: e.target.value })}
- placeholder="Enter password"
- onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
- />
-
-
-
- Sign In
-
-
-
- Test Credentials:
- Check browser console for default user passwords (they are scrambled on first run)
-
-
+ setLoginForm({ ...loginForm, username })}
+ onPasswordChange={(password) => setLoginForm({ ...loginForm, password })}
+ onSubmit={handleLogin}
+ />
+
diff --git a/frontends/nextjs/src/components/misc/data/generic-page/Preview.tsx b/frontends/nextjs/src/components/misc/data/generic-page/Preview.tsx
new file mode 100644
index 000000000..65836e4ab
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/data/generic-page/Preview.tsx
@@ -0,0 +1,120 @@
+import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator } from '@/components/ui'
+import { PageDefinition } from '@/lib/rendering/page/page-renderer'
+import { Eye, Layout, ShieldCheck } from '@phosphor-icons/react'
+
+interface GenericPagePreviewProps {
+ page: PageDefinition
+ updatedAt?: string
+ footerText?: string
+}
+
+const layoutCopy: Record = {
+ default: 'Default layout with header and footer',
+ sidebar: 'Sidebar layout with navigation',
+ dashboard: 'Dashboard layout with widgets',
+ blank: 'Blank canvas for custom layouts'
+}
+
+export function Preview({ page, updatedAt, footerText }: GenericPagePreviewProps) {
+ const showHeader = page.metadata?.showHeader !== false
+ const showFooter = page.metadata?.showFooter !== false
+
+ return (
+
+
+
+
+ Page preview
+
+
+
+ {layoutCopy[page.layout]}
+
+
+
+
+
+
{page.description || 'No description provided.'}
+
+
+ Level {page.level}
+
+ {page.components.length} components
+ {page.permissions?.requiresAuth && (
+
+
+ Auth required{page.permissions?.requiredRole ? ` (${page.permissions.requiredRole})` : ''}
+
+ )}
+ {updatedAt && Last updated {updatedAt} }
+
+
+
{page.metadata?.headerTitle || page.title}
+
+
+
+ {showHeader && (
+
+ Header
+ {page.metadata?.headerTitle || 'Default title'}
+
+ )}
+
+
+ {page.layout === 'sidebar' && (
+
+ Sidebar navigation
+
+ )}
+
+
+
Component tree
+
+ {page.components.slice(0, 4).map(component => (
+
+
+ {component.type}
+ {component.children && component.children.length > 0 && (
+ {component.children.length} children
+ )}
+
+ {component.props?.className &&
{component.props.className}
}
+
+ ))}
+ {page.components.length === 0 && (
+
Add components to see them previewed here.
+ )}
+
+
+
+
+ {showFooter && (
+
+ Footer
+ {footerText || 'Configured in metadata'}
+
+ )}
+
+
+
+
+
+
+
Lua hooks
+
onLoad: {page.luaScripts?.onLoad || 'Not configured'}
+
onUnload: {page.luaScripts?.onUnload || 'Not configured'}
+
+
+
Metadata
+
Header actions: {page.metadata?.headerActions?.length ?? 0}
+
Sidebar items: {page.metadata?.sidebarItems?.length ?? 0}
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/data/generic-page/SectionList.tsx b/frontends/nextjs/src/components/misc/data/generic-page/SectionList.tsx
new file mode 100644
index 000000000..4dd8ce0f7
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/data/generic-page/SectionList.tsx
@@ -0,0 +1,101 @@
+import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea, Separator } from '@/components/ui'
+import { ListNumbers, Plus, PushPinSimple, SquaresFour } from '@phosphor-icons/react'
+
+export interface PageSection {
+ id: string
+ title: string
+ description?: string
+ componentCount?: number
+ status?: 'draft' | 'review' | 'published'
+ updatedAt?: string
+}
+
+interface SectionListProps {
+ sections: PageSection[]
+ selectedSectionId?: string
+ onSelectSection?: (section: PageSection) => void
+ onCreateSection?: () => void
+}
+
+const statusVariant: Record, 'default' | 'secondary' | 'outline'> = {
+ draft: 'secondary',
+ review: 'outline',
+ published: 'default'
+}
+
+export function SectionList({ sections, selectedSectionId, onSelectSection, onCreateSection }: SectionListProps) {
+ return (
+
+
+
+
+
+ Sections
+
+ Outline the sections that make up your generic page.
+
+
+
+ Add Section
+
+
+
+ {sections.length === 0 ? (
+
+
No sections yet. Create your first section to start building the page.
+
+ ) : (
+
+
+ {sections.map(section => (
+
onSelectSection?.(section)}
+ >
+
+
+ {section.status ? (
+
+ {section.status}
+
+ ) : (
+ Draft
+ )}
+
+
+
+
+
{section.title}
+ {section.description && (
+
{section.description}
+ )}
+
+ {section.updatedAt && (
+
Updated {section.updatedAt}
+ )}
+
+
+
+
+ {section.componentCount ?? 0} components
+
+
+
+
+ ID: {section.id}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/data/quick-guide/MediaPane.tsx b/frontends/nextjs/src/components/misc/data/quick-guide/MediaPane.tsx
new file mode 100644
index 000000000..7f7f2dd27
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/data/quick-guide/MediaPane.tsx
@@ -0,0 +1,69 @@
+import Image from 'next/image'
+import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@/components/ui'
+import { FilmSlate, ImageSquare } from '@phosphor-icons/react'
+
+interface MediaPaneProps {
+ thumbnailUrl?: string
+ videoUrl?: string
+ onThumbnailChange?: (value: string) => void
+ onVideoChange?: (value: string) => void
+}
+
+export function MediaPane({ thumbnailUrl, videoUrl, onThumbnailChange, onVideoChange }: MediaPaneProps) {
+ return (
+
+
+
+
+ Media
+
+ Optional visuals to make the quick guide easier to follow.
+
+
+
+
Thumbnail image
+
onThumbnailChange?.(e.target.value)}
+ placeholder="https://images.example.com/quick-guide.png"
+ />
+
Shown in dashboards and previews.
+ {thumbnailUrl && (
+
+
+
+ )}
+
+
+
+
Demo video (optional)
+
onVideoChange?.(e.target.value)}
+ placeholder="YouTube or direct MP4 link"
+ />
+
Embed a short clip that shows the flow in action.
+ {videoUrl && (
+
+ )}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/data/quick-guide/StepsEditor.tsx b/frontends/nextjs/src/components/misc/data/quick-guide/StepsEditor.tsx
new file mode 100644
index 000000000..093b90fb0
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/data/quick-guide/StepsEditor.tsx
@@ -0,0 +1,139 @@
+import { useEffect, useState } from 'react'
+import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Textarea } from '@/components/ui'
+import { ArrowCounterClockwise, ListNumbers, Plus, Trash } from '@phosphor-icons/react'
+
+export interface GuideStep {
+ id: string
+ title: string
+ description: string
+ mediaUrl?: string
+ duration?: string
+}
+
+interface StepsEditorProps {
+ steps: GuideStep[]
+ onChange?: (steps: GuideStep[]) => void
+}
+
+export function StepsEditor({ steps, onChange }: StepsEditorProps) {
+ const [localSteps, setLocalSteps] = useState(steps)
+
+ useEffect(() => {
+ setLocalSteps(steps)
+ }, [steps])
+
+ const updateStep = (id: string, payload: Partial) => {
+ const nextSteps = localSteps.map(step => (step.id === id ? { ...step, ...payload } : step))
+ setLocalSteps(nextSteps)
+ onChange?.(nextSteps)
+ }
+
+ const removeStep = (id: string) => {
+ const nextSteps = localSteps.filter(step => step.id !== id)
+ setLocalSteps(nextSteps)
+ onChange?.(nextSteps)
+ }
+
+ const addStep = () => {
+ const newStep: GuideStep = {
+ id: crypto.randomUUID(),
+ title: 'New step',
+ description: 'Describe what happens in this step.',
+ duration: '1-2 min'
+ }
+
+ const nextSteps = [...localSteps, newStep]
+ setLocalSteps(nextSteps)
+ onChange?.(nextSteps)
+ }
+
+ const resetOrdering = () => {
+ const nextSteps = localSteps.map((step, index) => ({ ...step, id: `step_${index + 1}` }))
+ setLocalSteps(nextSteps)
+ onChange?.(nextSteps)
+ }
+
+ return (
+
+
+
+
+
+ Steps
+
+ Keep your quick guide instructions concise and actionable.
+
+
+
+
+ Reset IDs
+
+
+
+ Add Step
+
+
+
+
+ {localSteps.length === 0 ? (
+ Add your first step to get started.
+ ) : (
+
+ {localSteps.map((step, index) => (
+
+
+
+ Step {index + 1}
+ Duration: {step.duration || 'n/a'}
+
+
removeStep(step.id)}>
+
+
+
+
+
+ Description
+
+
+ Media URL (optional)
+ updateStep(step.id, { mediaUrl: e.target.value })}
+ placeholder="Link to an image, GIF, or short video"
+ />
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/data/smtp/ConnectionForm.tsx b/frontends/nextjs/src/components/misc/data/smtp/ConnectionForm.tsx
new file mode 100644
index 000000000..8c5b15269
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/data/smtp/ConnectionForm.tsx
@@ -0,0 +1,112 @@
+import { useMemo } from 'react'
+import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Input, Label, Switch } from '@/components/ui'
+import { EnvelopeSimple, FloppyDisk } from '@phosphor-icons/react'
+import type { SMTPConfig } from '@/lib/password-utils'
+
+interface ConnectionFormProps {
+ value: SMTPConfig
+ onChange: (value: SMTPConfig) => void
+ onSave?: () => void
+ onTest?: () => void
+}
+
+export function ConnectionForm({ value, onChange, onSave, onTest }: ConnectionFormProps) {
+ const securePort = useMemo(() => (value.tls ? 465 : 587), [value.tls])
+
+ const updateField = (key: K, fieldValue: SMTPConfig[K]) => {
+ onChange({ ...value, [key]: fieldValue })
+ }
+
+ return (
+
+
+
+
+ SMTP connection
+
+ Configure how MetaBuilder connects to your mail provider.
+
+
+
+
+
+
+
+
+
Use secure connection (TLS)
+
Switching on updates the recommended port to {securePort}.
+
+
updateField('tls', checked)} />
+
+
+
+
+ Test connection
+
+
+
+ Save configuration
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/data/smtp/StatusCard.tsx b/frontends/nextjs/src/components/misc/data/smtp/StatusCard.tsx
new file mode 100644
index 000000000..830d9350b
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/data/smtp/StatusCard.tsx
@@ -0,0 +1,55 @@
+import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+import { CheckCircle, Clock, WarningCircle } from '@phosphor-icons/react'
+import type { ReactNode } from 'react'
+
+export type ConnectionStatus = 'idle' | 'connected' | 'error'
+
+interface StatusCardProps {
+ status: ConnectionStatus
+ host?: string
+ lastChecked?: string
+ message?: string
+}
+
+const statusCopy: Record = {
+ idle: {
+ label: 'Not tested',
+ tone: 'bg-muted text-muted-foreground',
+ icon:
+ },
+ connected: {
+ label: 'Connected',
+ tone: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
+ icon:
+ },
+ error: {
+ label: 'Connection failed',
+ tone: 'bg-destructive/15 text-destructive',
+ icon:
+ }
+}
+
+export function StatusCard({ status, host, lastChecked, message }: StatusCardProps) {
+ const copy = statusCopy[status]
+
+ return (
+
+
+ Connection status
+ Stay aware of how the platform talks to your SMTP provider.
+
+
+
+ {copy.icon}
+ {copy.label}
+
+
+
+
Host: {host || 'Not configured'}
+
Last checked: {lastChecked || 'Pending test'}
+
{message || 'Run a test to see connection details.'}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/demos/DBALDemo.tsx b/frontends/nextjs/src/components/misc/demos/DBALDemo.tsx
index 0292b4142..373463a95 100644
--- a/frontends/nextjs/src/components/misc/demos/DBALDemo.tsx
+++ b/frontends/nextjs/src/components/misc/demos/DBALDemo.tsx
@@ -5,10 +5,15 @@
* with the MetaBuilder application.
*/
+import { useMemo, useState } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
+import { useDBAL } from '@/hooks/use-dbal/use-dbal'
import { BlobStorageDemo } from './dbal/BlobStorageDemo'
import { CachedDataDemo } from './dbal/CachedDataDemo'
+import { ConnectionForm } from './dbal/ConnectionForm'
import { KVStoreDemo } from './dbal/KVStoreDemo'
+import { LogsPanel } from './dbal/LogsPanel'
+import { ResultPanel } from './dbal/ResultPanel'
import { DBALTabConfig, DBAL_CONTAINER_CLASS, DBAL_TAB_GRID_CLASS } from './dbal/dbal-demo.utils'
const tabs: DBALTabConfig[] = [
@@ -18,6 +23,25 @@ const tabs: DBALTabConfig[] = [
]
export function DBALDemo() {
+ const { isReady, error } = useDBAL()
+ const [logs, setLogs] = useState([])
+ const [latestResult, setLatestResult] = useState(null)
+
+ const statusMessage = useMemo(() => {
+ if (error) return `Error: ${error}`
+ return isReady ? 'Connected' : 'Initializing...'
+ }, [error, isReady])
+
+ const handleConnect = (config: { endpoint: string; apiKey: string }) => {
+ const timestamp = new Date().toLocaleTimeString()
+ setLogs((current) => [...current, `${timestamp}: Connected to ${config.endpoint}`])
+ setLatestResult({
+ endpoint: config.endpoint,
+ apiKey: config.apiKey ? '***' : 'Not provided',
+ ready: isReady,
+ })
+ }
+
return (
@@ -27,6 +51,20 @@ export function DBALDemo() {
+
+
+
+
+
+
+
+
+
{tabs.map((tab) => (
diff --git a/frontends/nextjs/src/components/misc/demos/IRCWebchat.tsx b/frontends/nextjs/src/components/misc/demos/IRCWebchat.tsx
index 746af5e67..068de8118 100644
--- a/frontends/nextjs/src/components/misc/demos/IRCWebchat.tsx
+++ b/frontends/nextjs/src/components/misc/demos/IRCWebchat.tsx
@@ -1,21 +1,9 @@
-import { useState, useEffect, useRef } from 'react'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Button } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { PaperPlaneTilt, Users, SignOut, Gear } from '@phosphor-icons/react'
+import { useState, useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
import type { User } from '@/lib/level-types'
-
-interface ChatMessage {
- id: string
- username: string
- userId: string
- message: string
- timestamp: number
- type: 'message' | 'system' | 'join' | 'leave'
-}
+import { ChatWindow } from './irc/ChatWindow'
+import { useChatInput, useFormattedTimes } from './irc/hooks'
+import type { ChatMessage } from './irc/types'
interface IRCWebchatProps {
user: User
@@ -26,10 +14,9 @@ interface IRCWebchatProps {
export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebchatProps) {
const [messages, setMessages] = useKV(`chat_${channelName}`, [])
const [onlineUsers, setOnlineUsers] = useKV(`chat_${channelName}_users`, [])
- const [inputMessage, setInputMessage] = useState('')
const [showSettings, setShowSettings] = useState(false)
- const scrollRef = useRef(null)
- const messagesEndRef = useRef(null)
+ const { inputMessage, setInputMessage, handleKeyPress } = useChatInput(handleSendMessage)
+ const formattedTimes = useFormattedTimes(messages || [], formatTime)
useEffect(() => {
addUserToChannel()
@@ -38,14 +25,6 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
}
}, [])
- useEffect(() => {
- scrollToBottom()
- }, [messages])
-
- const scrollToBottom = () => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
- }
-
const addUserToChannel = () => {
setOnlineUsers((current) => {
if (!current) return [user.username]
@@ -89,7 +68,7 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
})
}
- const handleSendMessage = () => {
+ function handleSendMessage() {
const trimmed = inputMessage.trim()
if (!trimmed) return
@@ -151,121 +130,24 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
}
}
- const handleKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleSendMessage()
- }
- }
-
- const formatTime = (timestamp: number) => {
+ function formatTime(timestamp: number) {
const date = new Date(timestamp)
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
}
- const getMessageStyle = (msg: ChatMessage) => {
- if (msg.type === 'system' || msg.type === 'join' || msg.type === 'leave') {
- return 'text-muted-foreground italic text-sm'
- }
- return ''
- }
-
return (
-
-
-
-
- #
- {channelName}
-
-
-
-
- {onlineUsers?.length || 0}
-
- setShowSettings(!showSettings)}>
-
-
- {onClose && (
-
-
-
- )}
-
-
-
-
-
-
-
- {(messages || []).map((msg) => (
-
- {msg.type === 'message' && (
-
- {formatTime(msg.timestamp)}
- <{msg.username}>
- {msg.message}
-
- )}
- {msg.type === 'system' && msg.username === 'System' && (
-
- {formatTime(msg.timestamp)}
- *** {msg.message}
-
- )}
- {msg.type === 'system' && msg.username !== 'System' && (
-
- {formatTime(msg.timestamp)}
- * {msg.username} {msg.message}
-
- )}
- {(msg.type === 'join' || msg.type === 'leave') && (
-
- {formatTime(msg.timestamp)}
-
- --> {msg.message}
-
-
- )}
-
- ))}
-
-
-
-
- {showSettings && (
-
-
Online Users
-
- {(onlineUsers || []).map((username) => (
-
- ))}
-
-
- )}
-
-
-
-
-
setInputMessage(e.target.value)}
- onKeyPress={handleKeyPress}
- placeholder="Type a message... (/help for commands)"
- className="flex-1 font-mono"
- />
-
-
-
-
-
- Press Enter to send. Type /help for commands.
-
-
-
-
+ setShowSettings(!showSettings)}
+ showSettings={showSettings}
+ onClose={onClose}
+ onInputKeyPress={handleKeyPress}
+ />
)
}
diff --git a/frontends/nextjs/src/components/misc/demos/IRCWebchatDeclarative.tsx b/frontends/nextjs/src/components/misc/demos/IRCWebchatDeclarative.tsx
index 1919b9524..8d77f26dc 100644
--- a/frontends/nextjs/src/components/misc/demos/IRCWebchatDeclarative.tsx
+++ b/frontends/nextjs/src/components/misc/demos/IRCWebchatDeclarative.tsx
@@ -1,22 +1,10 @@
-import { useState, useEffect, useRef } from 'react'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Button } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { PaperPlaneTilt, Users, SignOut, Gear } from '@phosphor-icons/react'
+import { useState, useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
import type { User } from '@/lib/level-types'
import { getDeclarativeRenderer } from '@/lib/rendering-lib/declarative-component-renderer'
-
-interface ChatMessage {
- id: string
- username: string
- userId: string
- message: string
- timestamp: number
- type: 'message' | 'system' | 'join' | 'leave' | 'command'
-}
+import { ChatWindow } from './irc/ChatWindow'
+import { useChatInput, useFormattedTimes } from './irc/hooks'
+import type { ChatMessage } from './irc/types'
interface IRCWebchatDeclarativeProps {
user: User
@@ -27,11 +15,10 @@ interface IRCWebchatDeclarativeProps {
export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }: IRCWebchatDeclarativeProps) {
const [messages, setMessages] = useKV(`chat_${channelName}`, [])
const [onlineUsers, setOnlineUsers] = useKV(`chat_${channelName}_users`, [])
- const [inputMessage, setInputMessage] = useState('')
const [showSettings, setShowSettings] = useState(false)
- const scrollRef = useRef(null)
- const messagesEndRef = useRef(null)
+ const { inputMessage, setInputMessage, handleKeyPress } = useChatInput(handleSendMessage)
const renderer = getDeclarativeRenderer()
+ const formattedTimes = useFormattedTimes(messages, formatTime)
useEffect(() => {
addUserToChannel()
@@ -40,14 +27,6 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}, [])
- useEffect(() => {
- scrollToBottom()
- }, [messages])
-
- const scrollToBottom = () => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
- }
-
const addUserToChannel = async () => {
setOnlineUsers((current) => {
if (!current) return [user.username]
@@ -113,7 +92,7 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
- const handleSendMessage = async () => {
+ async function handleSendMessage() {
const trimmed = inputMessage.trim()
if (!trimmed) return
@@ -182,14 +161,9 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
- const handleKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleSendMessage()
- }
}
- const formatTime = async (timestamp: number): Promise => {
+ async function formatTime(timestamp: number): Promise {
try {
const formatted = await renderer.executeLuaScript('lua_irc_format_time', [timestamp])
return formatted || new Date(timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
@@ -198,122 +172,19 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
- const [formattedTimes, setFormattedTimes] = useState>({})
-
- useEffect(() => {
- const updateTimes = async () => {
- const times: Record = {}
- for (const msg of messages || []) {
- times[msg.id] = await formatTime(msg.timestamp)
- }
- setFormattedTimes(times)
- }
- updateTimes()
- }, [messages])
-
- const getMessageStyle = (msg: ChatMessage) => {
- if (msg.type === 'system' || msg.type === 'join' || msg.type === 'leave') {
- return 'text-muted-foreground italic text-sm'
- }
- return ''
- }
-
return (
-
-
-
-
- #
- {channelName}
-
-
-
-
- {onlineUsers?.length || 0}
-
- setShowSettings(!showSettings)}>
-
-
- {onClose && (
-
-
-
- )}
-
-
-
-
-
-
-
- {(messages || []).map((msg) => (
-
- {msg.type === 'message' && (
-
- {formattedTimes[msg.id] || ''}
- <{msg.username}>
- {msg.message}
-
- )}
- {msg.type === 'system' && msg.username === 'System' && (
-
- {formattedTimes[msg.id] || ''}
- *** {msg.message}
-
- )}
- {msg.type === 'system' && msg.username !== 'System' && (
-
- {formattedTimes[msg.id] || ''}
- * {msg.username} {msg.message}
-
- )}
- {(msg.type === 'join' || msg.type === 'leave') && (
-
- {formattedTimes[msg.id] || ''}
-
- --> {msg.message}
-
-
- )}
-
- ))}
-
-
-
-
- {showSettings && (
-
-
Online Users
-
- {(onlineUsers || []).map((username) => (
-
- ))}
-
-
- )}
-
-
-
-
-
setInputMessage(e.target.value)}
- onKeyPress={handleKeyPress}
- placeholder="Type a message... (/help for commands)"
- className="flex-1 font-mono"
- />
-
-
-
-
-
- Press Enter to send. Type /help for commands.
-
-
-
-
+ setShowSettings(!showSettings)}
+ showSettings={showSettings}
+ onClose={onClose}
+ onInputKeyPress={handleKeyPress}
+ />
)
}
diff --git a/frontends/nextjs/src/components/misc/demos/ScreenshotAnalyzer.tsx b/frontends/nextjs/src/components/misc/demos/ScreenshotAnalyzer.tsx
index a8ab25a73..a919b591c 100644
--- a/frontends/nextjs/src/components/misc/demos/ScreenshotAnalyzer.tsx
+++ b/frontends/nextjs/src/components/misc/demos/ScreenshotAnalyzer.tsx
@@ -1,26 +1,11 @@
import { useState } from 'react'
-import {
- Box,
- Button,
- Card,
- CardContent,
- CardHeader,
- Chip,
- CircularProgress,
- Stack,
- Typography,
- Grid,
-} from '@mui/material'
-import {
- CameraAlt as CameraIcon,
- Visibility as EyeIcon,
- Download as DownloadIcon,
- Refresh as RefreshIcon,
-} from '@mui/icons-material'
+import { Box, Card, CardContent, CardHeader, Chip, Grid, Typography } from '@mui/material'
import { toast } from 'sonner'
import { captureDomSnapshot } from '@/lib/screenshot/capture-dom-snapshot'
import { requestScreenshotAnalysis } from '@/lib/screenshot/request-screenshot-analysis'
import type { ScreenshotAnalysisResult } from '@/lib/screenshot/types'
+import { UploadSection } from './screenshot-analyzer/UploadSection'
+import { ResultPanel } from './screenshot-analyzer/ResultPanel'
export function ScreenshotAnalyzer() {
const [isCapturing, setIsCapturing] = useState(false)
@@ -96,108 +81,16 @@ export function ScreenshotAnalyzer() {
-
-
-
-
- }
- sx={{ flex: 1 }}
- >
- {isCapturing ? 'Capturing...' : 'Capture & Analyze'}
-
+
- {screenshotData && (
- <>
- }
- >
- Download
-
-
- }
- >
- Re-analyze
-
- >
- )}
-
-
- {isAnalyzing && (
-
-
- Analyzing with heuristics...
-
- )}
-
- {analysisReport && !isAnalyzing && (
-
- }
- title="Heuristic Analysis"
- titleTypographyProps={{ variant: 'subtitle1' }}
- />
-
- {analysisResult && (
-
-
-
-
-
-
-
-
- )}
-
- {analysisResult?.warnings.length ? (
-
- Warnings
-
- {analysisResult.warnings.map((warning) => (
-
- {warning}
-
- ))}
-
-
- ) : null}
-
-
- {analysisReport}
-
-
-
- )}
-
- {screenshotData && (
-
- Screenshot Preview
-
-
-
-
- )}
-
-
+
diff --git a/frontends/nextjs/src/components/misc/demos/dbal/ConnectionForm.tsx b/frontends/nextjs/src/components/misc/demos/dbal/ConnectionForm.tsx
new file mode 100644
index 000000000..0e8d2f960
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/dbal/ConnectionForm.tsx
@@ -0,0 +1,66 @@
+import { useState } from 'react'
+import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@/components/ui'
+
+interface ConnectionFormProps {
+ defaultUrl?: string
+ defaultApiKey?: string
+ isConnecting?: boolean
+ statusMessage?: string
+ onConnect?: (config: { endpoint: string; apiKey: string }) => void
+}
+
+export function ConnectionForm({
+ defaultUrl = '',
+ defaultApiKey = '',
+ isConnecting = false,
+ statusMessage,
+ onConnect,
+}: ConnectionFormProps) {
+ const [endpoint, setEndpoint] = useState(defaultUrl)
+ const [apiKey, setApiKey] = useState(defaultApiKey)
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault()
+ onConnect?.({ endpoint, apiKey })
+ }
+
+ return (
+
+
+ DBAL Connection
+ Configure the DBAL endpoint used by the demos
+
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/demos/dbal/LogsPanel.tsx b/frontends/nextjs/src/components/misc/demos/dbal/LogsPanel.tsx
new file mode 100644
index 000000000..331915b6f
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/dbal/LogsPanel.tsx
@@ -0,0 +1,31 @@
+import { Card, CardContent, CardHeader, CardTitle, ScrollArea } from '@/components/ui'
+
+interface LogsPanelProps {
+ logs: string[]
+ title?: string
+}
+
+export function LogsPanel({ logs, title = 'Activity' }: LogsPanelProps) {
+ return (
+
+
+ {title}
+
+
+
+
+ {logs.length === 0 ? (
+
No events yet
+ ) : (
+ logs.map((entry, index) => (
+
+ {entry}
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/demos/dbal/ResultPanel.tsx b/frontends/nextjs/src/components/misc/demos/dbal/ResultPanel.tsx
new file mode 100644
index 000000000..0c7f27d36
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/dbal/ResultPanel.tsx
@@ -0,0 +1,26 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
+
+interface ResultPanelProps {
+ title?: string
+ result: unknown
+ emptyLabel?: string
+}
+
+export function ResultPanel({ title = 'Latest Result', result, emptyLabel = 'No result yet' }: ResultPanelProps) {
+ return (
+
+
+ {title}
+
+
+ {result ? (
+
+ {JSON.stringify(result, null, 2)}
+
+ ) : (
+ {emptyLabel}
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/demos/irc/ChatWindow.tsx b/frontends/nextjs/src/components/misc/demos/irc/ChatWindow.tsx
new file mode 100644
index 000000000..2bf7af42f
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/irc/ChatWindow.tsx
@@ -0,0 +1,138 @@
+import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, ScrollArea } from '@/components/ui'
+import { Gear, PaperPlaneTilt, SignOut, Users } from '@phosphor-icons/react'
+import { UserList } from './UserList'
+import type { ChatMessage } from './types'
+
+interface ChatWindowProps {
+ channelName: string
+ messages: ChatMessage[]
+ formattedTimes: Record
+ onlineUsers: string[]
+ inputMessage: string
+ onInputChange: (value: string) => void
+ onSendMessage: () => void
+ onToggleSettings: () => void
+ showSettings: boolean
+ onClose?: () => void
+ onInputKeyPress?: (event: React.KeyboardEvent) => void
+}
+
+export function ChatWindow({
+ channelName,
+ messages,
+ formattedTimes,
+ onlineUsers,
+ inputMessage,
+ onInputChange,
+ onSendMessage,
+ onToggleSettings,
+ showSettings,
+ onClose,
+ onInputKeyPress,
+}: ChatWindowProps) {
+ const getMessageStyle = (message: ChatMessage) => {
+ if (message.type === 'system' || message.type === 'join' || message.type === 'leave' || message.type === 'command') {
+ return 'text-muted-foreground italic text-sm'
+ }
+ return ''
+ }
+
+ return (
+
+
+
+
+ #
+ {channelName}
+
+
+
+
+ {onlineUsers.length}
+
+
+
+
+ {onClose && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {messages.map((message) => (
+
+ {message.type === 'message' && (
+
+ {formattedTimes[message.id] || ''}
+ <{message.username}>
+ {message.message}
+
+ )}
+
+ {message.type === 'system' && message.username === 'System' && (
+
+ {formattedTimes[message.id] || ''}
+ *** {message.message}
+
+ )}
+
+ {message.type === 'system' && message.username !== 'System' && (
+
+ {formattedTimes[message.id] || ''}
+ * {message.username} {message.message}
+
+ )}
+
+ {(message.type === 'join' || message.type === 'leave') && (
+
+ {formattedTimes[message.id] || ''}
+
+ --> {message.message}
+
+
+ )}
+
+ {message.type === 'command' && (
+
+ {formattedTimes[message.id] || ''}
+ {message.message}
+
+ )}
+
+ ))}
+
+
+
+ {showSettings && (
+
+
Online Users
+
+
+ )}
+
+
+
+
+
onInputChange(event.target.value)}
+ onKeyPress={onInputKeyPress}
+ placeholder="Type a message... (/help for commands)"
+ className="flex-1 font-mono"
+ />
+
+
+
+
+
Press Enter to send. Type /help for commands.
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/demos/irc/UserList.tsx b/frontends/nextjs/src/components/misc/demos/irc/UserList.tsx
new file mode 100644
index 000000000..c6cf0a932
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/irc/UserList.tsx
@@ -0,0 +1,20 @@
+interface UserListProps {
+ users: string[]
+}
+
+export function UserList({ users }: UserListProps) {
+ if (users.length === 0) {
+ return No users online
+ }
+
+ return (
+
+ {users.map((username) => (
+
+ ))}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/demos/irc/hooks.ts b/frontends/nextjs/src/components/misc/demos/irc/hooks.ts
new file mode 100644
index 000000000..86c588024
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/irc/hooks.ts
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react'
+import type { ChatMessage } from './types'
+
+type TimestampFormatter = (timestamp: number) => Promise | string
+
+export function useChatInput(onSubmit: () => void) {
+ const [inputMessage, setInputMessage] = useState('')
+
+ const handleKeyPress = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault()
+ onSubmit()
+ }
+ }
+
+ return {
+ inputMessage,
+ setInputMessage,
+ handleKeyPress,
+ }
+}
+
+export function useFormattedTimes(messages: ChatMessage[] | undefined, formatTime: TimestampFormatter) {
+ const [formattedTimes, setFormattedTimes] = useState>({})
+
+ useEffect(() => {
+ let isMounted = true
+
+ const formatAllTimes = async () => {
+ if (!messages) {
+ setFormattedTimes({})
+ return
+ }
+
+ const entries = await Promise.all(
+ messages.map(async (message) => {
+ const formatted = await formatTime(message.timestamp)
+ return [message.id, formatted] as const
+ }),
+ )
+
+ if (isMounted) {
+ setFormattedTimes(Object.fromEntries(entries))
+ }
+ }
+
+ formatAllTimes()
+
+ return () => {
+ isMounted = false
+ }
+ }, [messages, formatTime])
+
+ return formattedTimes
+}
diff --git a/frontends/nextjs/src/components/misc/demos/irc/types.ts b/frontends/nextjs/src/components/misc/demos/irc/types.ts
new file mode 100644
index 000000000..c46671dc7
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/irc/types.ts
@@ -0,0 +1,10 @@
+export type ChatMessageType = 'message' | 'system' | 'join' | 'leave' | 'command'
+
+export interface ChatMessage {
+ id: string
+ username: string
+ userId: string
+ message: string
+ timestamp: number
+ type: ChatMessageType
+}
diff --git a/frontends/nextjs/src/components/misc/demos/screenshot-analyzer/ResultPanel.tsx b/frontends/nextjs/src/components/misc/demos/screenshot-analyzer/ResultPanel.tsx
new file mode 100644
index 000000000..8090f442f
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/screenshot-analyzer/ResultPanel.tsx
@@ -0,0 +1,56 @@
+import { Box, Card, CardContent, CardHeader, Chip, Stack, Typography } from '@mui/material'
+import { Visibility as EyeIcon } from '@mui/icons-material'
+import type { ScreenshotAnalysisResult } from '@/lib/screenshot/types'
+
+interface ResultPanelProps {
+ analysisReport: string
+ analysisResult: ScreenshotAnalysisResult | null
+}
+
+export function ResultPanel({ analysisReport, analysisResult }: ResultPanelProps) {
+ if (!analysisReport) return null
+
+ return (
+
+ } title="Heuristic Analysis" titleTypographyProps={{ variant: 'subtitle1' }} />
+
+ {analysisResult && (
+
+
+
+
+
+
+
+
+ )}
+
+ {analysisResult?.warnings.length ? (
+
+
+ Warnings
+
+
+ {analysisResult.warnings.map((warning) => (
+
+ {warning}
+
+ ))}
+
+
+ ) : null}
+
+
+ {analysisReport}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/demos/screenshot-analyzer/UploadSection.tsx b/frontends/nextjs/src/components/misc/demos/screenshot-analyzer/UploadSection.tsx
new file mode 100644
index 000000000..c768becdc
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/demos/screenshot-analyzer/UploadSection.tsx
@@ -0,0 +1,71 @@
+import { Box, Button, Card, CardContent, CardHeader, CircularProgress, Typography } from '@mui/material'
+import { CameraAlt as CameraIcon, Download as DownloadIcon, Refresh as RefreshIcon } from '@mui/icons-material'
+
+interface UploadSectionProps {
+ isCapturing: boolean
+ isAnalyzing: boolean
+ screenshotData: string | null
+ onCapture: () => void
+ onDownload: () => void
+ onReanalyze: () => void
+ previewTitle?: string
+}
+
+export function UploadSection({
+ isCapturing,
+ isAnalyzing,
+ screenshotData,
+ onCapture,
+ onDownload,
+ onReanalyze,
+ previewTitle = 'Screenshot Preview',
+}: UploadSectionProps) {
+ return (
+
+
+
+
+ }
+ sx={{ flex: 1 }}
+ >
+ {isCapturing ? 'Capturing...' : 'Capture & Analyze'}
+
+
+ {screenshotData && (
+ <>
+ }>
+ Download
+
+
+ }>
+ Re-analyze
+
+ >
+ )}
+
+
+ {isAnalyzing && (
+
+
+ Analyzing with heuristics...
+
+ )}
+
+ {screenshotData && (
+
+
+ {previewTitle}
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx b/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx
index 441bd2d0e..51a09ac13 100644
--- a/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx
+++ b/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx
@@ -1,101 +1,40 @@
-import { useEffect, useState } from 'react'
import { Stack } from '@mui/material'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import { formatWorkflowRunAnalysis, summarizeWorkflowRuns } from '@/lib/github/analyze-workflow-runs'
-import { toast } from 'sonner'
-import { useWorkflowRuns } from './hooks/useWorkflowRuns'
-import { useWorkflowLogAnalysis } from './hooks/useWorkflowLogAnalysis'
+import { useActionsFetcher } from './workflows/useActionsFetcher'
import { AnalysisPanel } from './views/AnalysisPanel'
import { RunDetails } from './views/RunDetails'
import { RunList } from './views/RunList'
export function GitHubActionsFetcher() {
- const [analysis, setAnalysis] = useState(null)
- const [isAnalyzing, setIsAnalyzing] = useState(false)
- const [activeTab, setActiveTab] = useState<'runs' | 'logs' | 'analysis'>(
- 'runs',
- )
-
const {
runs,
isLoading,
error,
needsAuth,
- repoInfo,
repoLabel,
lastFetched,
- secondsUntilRefresh,
autoRefreshEnabled,
+ secondsUntilRefresh,
toggleAutoRefresh,
+ downloadWorkflowData,
fetchRuns,
getStatusColor,
+ isLoadingLogs,
conclusion,
summaryTone,
- } = useWorkflowRuns()
-
- const {
- analyzeRunLogs,
- downloadRunLogs,
- isLoadingLogs,
- runJobs,
- runLogs,
selectedRunId,
- } = useWorkflowLogAnalysis({
- repoInfo,
- onAnalysisStart: () => setIsAnalyzing(true),
- onAnalysisComplete: (report) => {
- if (report) {
- setAnalysis(report)
- }
- setIsAnalyzing(false)
- },
- })
-
- const downloadWorkflowData = () => {
- if (!runs) return
-
- const jsonData = JSON.stringify(runs, null, 2)
- const blob = new Blob([jsonData], { type: 'application/json' })
- const url = URL.createObjectURL(blob)
- const anchor = document.createElement('a')
- anchor.href = url
- anchor.download = `github-actions-${new Date().toISOString()}.json`
- document.body.appendChild(anchor)
- anchor.click()
- document.body.removeChild(anchor)
- URL.revokeObjectURL(url)
- toast.success('Downloaded workflow data')
- }
-
- const analyzeWorkflows = async () => {
- if (!runs || runs.length === 0) {
- toast.error('No data to analyze')
- return
- }
-
- setIsAnalyzing(true)
- try {
- const summary = summarizeWorkflowRuns(runs)
- const report = formatWorkflowRunAnalysis(summary)
- setAnalysis(report)
- toast.success('Analysis complete')
- } catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'Analysis failed'
- toast.error(errorMessage)
- } finally {
- setIsAnalyzing(false)
- }
- }
-
- const handleAnalyzeLogs = () => analyzeRunLogs(runs)
-
- useEffect(() => {
- if (runLogs && activeTab === 'runs') {
- setActiveTab('logs')
- }
- }, [activeTab, runLogs])
+ runLogs,
+ runJobs,
+ analyzeLogs,
+ analyzeWorkflows,
+ downloadRunLogs,
+ analysis,
+ isAnalyzing,
+ activeTab,
+ setActiveTab,
+ } = useActionsFetcher()
return (
@@ -134,7 +73,7 @@ export function GitHubActionsFetcher() {
runLogs={runLogs}
runJobs={runJobs}
selectedRunId={selectedRunId}
- onAnalyzeLogs={handleAnalyzeLogs}
+ onAnalyzeLogs={analyzeLogs}
isAnalyzing={isAnalyzing}
/>
@@ -145,7 +84,7 @@ export function GitHubActionsFetcher() {
analysis={analysis}
isAnalyzing={isAnalyzing}
runLogs={runLogs}
- onAnalyzeLogs={handleAnalyzeLogs}
+ onAnalyzeLogs={analyzeLogs}
onAnalyzeWorkflows={analyzeWorkflows}
/>
diff --git a/frontends/nextjs/src/components/misc/github/views/RunList.tsx b/frontends/nextjs/src/components/misc/github/views/RunList.tsx
index 25b511d03..675400f34 100644
--- a/frontends/nextjs/src/components/misc/github/views/RunList.tsx
+++ b/frontends/nextjs/src/components/misc/github/views/RunList.tsx
@@ -1,14 +1,11 @@
-import { Box, Stack, Typography } from '@mui/material'
+import { Stack } from '@mui/material'
-import { CheckCircle as SuccessIcon } from '@mui/icons-material'
+import { Card, CardContent, CardHeader } from '@/components/ui'
-import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
-
-import type { WorkflowRun } from '../types'
+import { Filters } from './run-list/Filters'
import { RefreshControls } from './run-list/RefreshControls'
-import { RunItemCard } from './run-list/RunItemCard'
import { RunListAlerts } from './run-list/RunListAlerts'
-import { RunListEmptyState } from './run-list/RunListEmptyState'
+import { RunTable } from './run-list/Table'
import type { RunListProps } from './run-list/run-list.types'
export function RunList({
@@ -39,32 +36,7 @@ export function RunList({
alignItems={{ xs: 'flex-start', lg: 'center' }}
justifyContent="space-between"
>
-
-
- GitHub Actions Monitor
-
-
- Repository:{' '}
-
- {repoLabel}
-
-
- {lastFetched && (
-
- Last fetched: {lastFetched.toLocaleString()}
-
- )}
-
+
-
-
-
-
-
- Recent Workflow Runs
-
- {isLoading && }
-
- Latest GitHub Actions runs with status and controls
-
-
-
- {isLoading && !runs && (
-
-
-
-
-
- )}
-
- {runs && runs.length > 0 ? (
-
- {runs.map((run: WorkflowRun) => (
-
- ))}
-
- {
- if (!runs) return
- const jsonData = JSON.stringify(runs, null, 2)
- navigator.clipboard.writeText(jsonData)
- }}
- >
- Copy All as JSON
-
-
-
- ) : (
-
- )}
-
-
+
)
diff --git a/frontends/nextjs/src/components/misc/github/views/run-list/Filters.tsx b/frontends/nextjs/src/components/misc/github/views/run-list/Filters.tsx
new file mode 100644
index 000000000..40fa01bd1
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/github/views/run-list/Filters.tsx
@@ -0,0 +1,34 @@
+import { Box, Stack, Typography } from '@mui/material'
+
+import type { RunListProps } from './run-list.types'
+
+type FiltersProps = Pick
+
+export const Filters = ({ repoLabel, lastFetched }: FiltersProps) => (
+
+
+ GitHub Actions Monitor
+
+
+ Repository:{' '}
+
+ {repoLabel}
+
+
+ {lastFetched && (
+
+ Last fetched: {lastFetched.toLocaleString()}
+
+ )}
+
+)
diff --git a/frontends/nextjs/src/components/misc/github/views/run-list/RunItemCard.tsx b/frontends/nextjs/src/components/misc/github/views/run-list/RunRow.tsx
similarity index 92%
rename from frontends/nextjs/src/components/misc/github/views/run-list/RunItemCard.tsx
rename to frontends/nextjs/src/components/misc/github/views/run-list/RunRow.tsx
index 798877a59..6f53fc8fe 100644
--- a/frontends/nextjs/src/components/misc/github/views/run-list/RunItemCard.tsx
+++ b/frontends/nextjs/src/components/misc/github/views/run-list/RunRow.tsx
@@ -7,21 +7,22 @@ import type { WorkflowRun } from '../types'
import type { RunListProps } from './run-list.types'
import { spinSx } from './run-list.types'
-type RunItemCardProps = Pick<
+type RunRowProps = Pick<
RunListProps,
'getStatusColor' | 'onDownloadLogs' | 'isLoadingLogs' | 'selectedRunId'
> & {
run: WorkflowRun
}
-export const RunItemCard = ({
+export const RunRow = ({
run,
getStatusColor,
onDownloadLogs,
isLoadingLogs,
selectedRunId,
-}: RunItemCardProps) => {
+}: RunRowProps) => {
const statusIcon = getStatusColor(run.status, run.conclusion)
+ const isSelectedRun = isLoadingLogs && selectedRunId === run.id
return (
@@ -81,14 +82,14 @@ export const RunItemCard = ({
variant="outline"
size="sm"
onClick={() => onDownloadLogs(run.id, run.name)}
- disabled={isLoadingLogs && selectedRunId === run.id}
+ disabled={isSelectedRun}
startIcon={
- isLoadingLogs && selectedRunId === run.id
+ isSelectedRun
?
:
}
>
- {isLoadingLogs && selectedRunId === run.id ? 'Loading...' : 'Download Logs'}
+ {isSelectedRun ? 'Loading...' : 'Download Logs'}
+
+export const RunTable = ({
+ runs,
+ isLoading,
+ getStatusColor,
+ onDownloadLogs,
+ isLoadingLogs,
+ selectedRunId,
+}: RunTableProps) => {
+ const copyRunsToClipboard = () => {
+ if (!runs) return
+
+ const jsonData = JSON.stringify(runs, null, 2)
+ navigator.clipboard.writeText(jsonData)
+ }
+
+ return (
+
+
+
+
+
+ Recent Workflow Runs
+
+ {isLoading && }
+
+ Latest GitHub Actions runs with status and controls
+
+
+
+ {isLoading && !runs && (
+
+
+
+
+
+ )}
+
+ {runs && runs.length > 0 ? (
+
+ {runs.map((run) => (
+
+ ))}
+
+
+ Copy All as JSON
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRuns.ts b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRuns.ts
new file mode 100644
index 000000000..8513558ed
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRuns.ts
@@ -0,0 +1,12 @@
+import { useWorkflowRunsApi } from './useWorkflowRunsApi'
+import { useWorkflowRunsSelectors } from './useWorkflowRunsSelectors'
+
+export function useWorkflowRuns() {
+ const api = useWorkflowRunsApi()
+ const selectors = useWorkflowRunsSelectors(api.runs)
+
+ return {
+ ...api,
+ ...selectors,
+ }
+}
diff --git a/frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsApi.ts
similarity index 51%
rename from frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts
rename to frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsApi.ts
index 5cf9ede25..a6c7c9568 100644
--- a/frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts
+++ b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsApi.ts
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
-import { WorkflowRun, RepoInfo } from '../types'
+import { RepoInfo, WorkflowRun } from '../../types'
const DEFAULT_REPO_LABEL = 'johndoe6345789/metabuilder'
-export function useWorkflowRuns() {
+export function useWorkflowRunsApi() {
const [runs, setRuns] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
@@ -15,7 +15,10 @@ export function useWorkflowRuns() {
const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(30)
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
- const repoLabel = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL
+ const repoLabel = useMemo(
+ () => (repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL),
+ [repoInfo],
+ )
const fetchRuns = useCallback(async () => {
setIsLoading(true)
@@ -86,72 +89,6 @@ export function useWorkflowRuns() {
const toggleAutoRefresh = () => setAutoRefreshEnabled((prev) => !prev)
- const getStatusColor = (status: string, conclusion: string | null) => {
- if (status === 'completed') {
- if (conclusion === 'success') return 'success.main'
- if (conclusion === 'failure') return 'error.main'
- if (conclusion === 'cancelled') return 'text.secondary'
- }
- return 'warning.main'
- }
-
- const conclusion = useMemo(() => {
- if (!runs || runs.length === 0) return null
-
- const total = runs.length
- const completed = runs.filter(r => r.status === 'completed').length
- const successful = runs.filter(r => r.status === 'completed' && r.conclusion === 'success').length
- const failed = runs.filter(r => r.status === 'completed' && r.conclusion === 'failure').length
- const cancelled = runs.filter(r => r.status === 'completed' && r.conclusion === 'cancelled').length
- const inProgress = runs.filter(r => r.status !== 'completed').length
-
- const mostRecent = runs[0]
- const mostRecentTimestamp = new Date(mostRecent.updated_at).getTime()
- const timeThreshold = 5 * 60 * 1000
- const recentWorkflows = runs.filter((run) => {
- const runTimestamp = new Date(run.updated_at).getTime()
- return mostRecentTimestamp - runTimestamp <= timeThreshold
- })
-
- const mostRecentPassed = recentWorkflows.every(
- (run) => run.status === 'completed' && run.conclusion === 'success',
- )
- const mostRecentFailed = recentWorkflows.some(
- (run) => run.status === 'completed' && run.conclusion === 'failure',
- )
- const mostRecentRunning = recentWorkflows.some((run) => run.status !== 'completed')
-
- const successRate = total > 0 ? Math.round((successful / total) * 100) : 0
- let health: 'healthy' | 'warning' | 'critical' = 'healthy'
- if (failed / total > 0.3 || successRate < 60) {
- health = 'critical'
- } else if (failed > 0 || inProgress > 0) {
- health = 'warning'
- }
-
- return {
- total,
- completed,
- successful,
- failed,
- cancelled,
- inProgress,
- successRate,
- health,
- recentWorkflows,
- mostRecentPassed,
- mostRecentFailed,
- mostRecentRunning,
- }
- }, [runs])
-
- const summaryTone = useMemo(() => {
- if (!conclusion) return 'warning'
- if (conclusion.mostRecentPassed) return 'success'
- if (conclusion.mostRecentFailed) return 'error'
- return 'warning'
- }, [conclusion])
-
return {
runs,
isLoading,
@@ -164,8 +101,5 @@ export function useWorkflowRuns() {
autoRefreshEnabled,
toggleAutoRefresh,
fetchRuns,
- getStatusColor,
- conclusion,
- summaryTone,
}
}
diff --git a/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsSelectors.ts b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsSelectors.ts
new file mode 100644
index 000000000..388caf9cd
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsSelectors.ts
@@ -0,0 +1,97 @@
+import { useCallback, useMemo } from 'react'
+
+import { WorkflowRun } from '../../types'
+
+const TIME_THRESHOLD_MS = 5 * 60 * 1000
+
+type PipelineHealth = 'healthy' | 'warning' | 'critical'
+
+type PipelineSummary = {
+ total: number
+ completed: number
+ successful: number
+ failed: number
+ cancelled: number
+ inProgress: number
+ successRate: number
+ health: PipelineHealth
+ recentWorkflows: WorkflowRun[]
+ mostRecentPassed: boolean
+ mostRecentFailed: boolean
+ mostRecentRunning: boolean
+}
+
+type SummaryTone = 'success' | 'error' | 'warning'
+
+export const useWorkflowRunsSelectors = (runs: WorkflowRun[] | null) => {
+ const getStatusColor = useCallback((status: string, conclusion: string | null) => {
+ if (status === 'completed') {
+ if (conclusion === 'success') return 'success.main'
+ if (conclusion === 'failure') return 'error.main'
+ if (conclusion === 'cancelled') return 'text.secondary'
+ }
+ return 'warning.main'
+ }, [])
+
+ const conclusion = useMemo(() => {
+ if (!runs || runs.length === 0) return null
+
+ const total = runs.length
+ const completed = runs.filter(r => r.status === 'completed').length
+ const successful = runs.filter(r => r.status === 'completed' && r.conclusion === 'success').length
+ const failed = runs.filter(r => r.status === 'completed' && r.conclusion === 'failure').length
+ const cancelled = runs.filter(r => r.status === 'completed' && r.conclusion === 'cancelled').length
+ const inProgress = runs.filter(r => r.status !== 'completed').length
+
+ const mostRecent = runs[0]
+ const mostRecentTimestamp = new Date(mostRecent.updated_at).getTime()
+ const recentWorkflows = runs.filter((run) => {
+ const runTimestamp = new Date(run.updated_at).getTime()
+ return mostRecentTimestamp - runTimestamp <= TIME_THRESHOLD_MS
+ })
+
+ const mostRecentPassed = recentWorkflows.every(
+ (run) => run.status === 'completed' && run.conclusion === 'success',
+ )
+ const mostRecentFailed = recentWorkflows.some(
+ (run) => run.status === 'completed' && run.conclusion === 'failure',
+ )
+ const mostRecentRunning = recentWorkflows.some((run) => run.status !== 'completed')
+
+ const successRate = total > 0 ? Math.round((successful / total) * 100) : 0
+ let health: PipelineHealth = 'healthy'
+ if (failed / total > 0.3 || successRate < 60) {
+ health = 'critical'
+ } else if (failed > 0 || inProgress > 0) {
+ health = 'warning'
+ }
+
+ return {
+ total,
+ completed,
+ successful,
+ failed,
+ cancelled,
+ inProgress,
+ successRate,
+ health,
+ recentWorkflows,
+ mostRecentPassed,
+ mostRecentFailed,
+ mostRecentRunning,
+ }
+ }, [runs])
+
+ const summaryTone = useMemo(() => {
+ if (!conclusion) return 'warning'
+ if (conclusion.mostRecentPassed) return 'success'
+ if (conclusion.mostRecentFailed) return 'error'
+ return 'warning'
+ }, [conclusion])
+
+ return {
+ getStatusColor,
+ conclusion,
+ summaryTone,
+ }
+}
diff --git a/frontends/nextjs/src/components/misc/github/workflows/useActionsFetcher.ts b/frontends/nextjs/src/components/misc/github/workflows/useActionsFetcher.ts
new file mode 100644
index 000000000..2edae04af
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/github/workflows/useActionsFetcher.ts
@@ -0,0 +1,84 @@
+import { useCallback, useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+import { formatWorkflowRunAnalysis, summarizeWorkflowRuns } from '@/lib/github/analyze-workflow-runs'
+
+import { useWorkflowLogAnalysis } from '../hooks/useWorkflowLogAnalysis'
+import { useWorkflowRuns } from './hooks/useWorkflowRuns'
+
+export function useActionsFetcher() {
+ const [analysis, setAnalysis] = useState(null)
+ const [isAnalyzing, setIsAnalyzing] = useState(false)
+ const [activeTab, setActiveTab] = useState<'runs' | 'logs' | 'analysis'>('runs')
+
+ const workflowRuns = useWorkflowRuns()
+
+ const workflowLogAnalysis = useWorkflowLogAnalysis({
+ repoInfo: workflowRuns.repoInfo,
+ onAnalysisStart: () => setIsAnalyzing(true),
+ onAnalysisComplete: (report) => {
+ if (report) {
+ setAnalysis(report)
+ }
+ setIsAnalyzing(false)
+ },
+ })
+
+ const downloadWorkflowData = useCallback(() => {
+ if (!workflowRuns.runs) return
+
+ const jsonData = JSON.stringify(workflowRuns.runs, null, 2)
+ const blob = new Blob([jsonData], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = `github-actions-${new Date().toISOString()}.json`
+ document.body.appendChild(anchor)
+ anchor.click()
+ document.body.removeChild(anchor)
+ URL.revokeObjectURL(url)
+ toast.success('Downloaded workflow data')
+ }, [workflowRuns.runs])
+
+ const analyzeWorkflows = useCallback(async () => {
+ if (!workflowRuns.runs || workflowRuns.runs.length === 0) {
+ toast.error('No data to analyze')
+ return
+ }
+
+ setIsAnalyzing(true)
+ try {
+ const summary = summarizeWorkflowRuns(workflowRuns.runs)
+ const report = formatWorkflowRunAnalysis(summary)
+ setAnalysis(report)
+ toast.success('Analysis complete')
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Analysis failed'
+ toast.error(errorMessage)
+ } finally {
+ setIsAnalyzing(false)
+ }
+ }, [workflowRuns.runs])
+
+ const analyzeLogs = useCallback(() => {
+ workflowLogAnalysis.analyzeRunLogs(workflowRuns.runs)
+ }, [workflowLogAnalysis, workflowRuns.runs])
+
+ useEffect(() => {
+ if (workflowLogAnalysis.runLogs && activeTab === 'runs') {
+ setActiveTab('logs')
+ }
+ }, [activeTab, workflowLogAnalysis.runLogs])
+
+ return {
+ ...workflowRuns,
+ ...workflowLogAnalysis,
+ analysis,
+ isAnalyzing,
+ activeTab,
+ setActiveTab,
+ analyzeLogs,
+ analyzeWorkflows,
+ downloadWorkflowData,
+ }
+}
diff --git a/frontends/nextjs/src/components/misc/viewers/audit-log/Filters.tsx b/frontends/nextjs/src/components/misc/viewers/audit-log/Filters.tsx
new file mode 100644
index 000000000..7dc47373d
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/viewers/audit-log/Filters.tsx
@@ -0,0 +1,188 @@
+import { useMemo } from 'react'
+import { Badge, Button, Input, Label, Switch, ToggleGroup, ToggleGroupItem } from '@/components/ui'
+import type { OperationType, ResourceType } from '@/lib/security/secure-db/types'
+import { FunnelSimple, MagnifyingGlass, X } from '@phosphor-icons/react'
+
+interface AuditLogFiltersProps {
+ searchTerm: string
+ onSearchChange: (value: string) => void
+ selectedOperations: OperationType[]
+ onOperationsChange: (operations: OperationType[]) => void
+ selectedResources: ResourceType[]
+ onResourcesChange: (resources: ResourceType[]) => void
+ showFailuresOnly: boolean
+ onShowFailuresChange: (value: boolean) => void
+ availableOperations?: OperationType[]
+ availableResources?: ResourceType[]
+ onReset?: () => void
+}
+
+const DEFAULT_OPERATIONS: OperationType[] = ['CREATE', 'READ', 'UPDATE', 'DELETE']
+const DEFAULT_RESOURCES: ResourceType[] = [
+ 'user',
+ 'workflow',
+ 'luaScript',
+ 'pageConfig',
+ 'modelSchema',
+ 'comment',
+ 'componentNode',
+ 'componentConfig',
+ 'cssCategory',
+ 'dropdownConfig',
+ 'tenant',
+ 'powerTransfer',
+ 'smtpConfig',
+ 'credential'
+]
+
+export function AuditLogFilters({
+ searchTerm,
+ onSearchChange,
+ selectedOperations,
+ onOperationsChange,
+ selectedResources,
+ onResourcesChange,
+ showFailuresOnly,
+ onShowFailuresChange,
+ availableOperations,
+ availableResources,
+ onReset
+}: AuditLogFiltersProps) {
+ const operationOptions = availableOperations || DEFAULT_OPERATIONS
+ const resourceOptions = useMemo(
+ () => availableResources || DEFAULT_RESOURCES,
+ [availableResources]
+ )
+
+ return (
+
+
+
+ Filters
+
+
+
+
+
Search
+
+
+ onSearchChange(event.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+
+
Failures only
+
Show only unsuccessful operations
+
+
+
+
+
+
+
+ Operations
+ onOperationsChange(value as OperationType[])}
+ className="flex flex-wrap gap-2"
+ >
+ {operationOptions.map((operation) => (
+
+ {operation}
+
+ ))}
+
+
+
+
+
+ Resources
+ Select one or more
+
+
+ {resourceOptions.map((resource) => {
+ const isSelected = selectedResources.includes(resource)
+ return (
+
+ onResourcesChange(
+ isSelected
+ ? selectedResources.filter((value) => value !== resource)
+ : [...selectedResources, resource]
+ )
+ }
+ className="rounded-full"
+ >
+
+ {resource}
+
+
+ )
+ })}
+
+
+
+
+
+
+ {selectedOperations.length > 0 && (
+
+ Operations:
+ {selectedOperations.join(', ')}
+
+ )}
+ {selectedResources.length > 0 && (
+
+ Resources:
+ {selectedResources.join(', ')}
+
+ )}
+ {showFailuresOnly && (
+
+
+ Failures
+
+ )}
+ {selectedOperations.length === 0 &&
+ selectedResources.length === 0 &&
+ !showFailuresOnly && (
+ No filters applied
+ )}
+
+
+ {onReset && (
+
+
+ Clear all
+
+ )}
+
+
+ )
+}
+
+function WarningIcon() {
+ return
+}
diff --git a/frontends/nextjs/src/components/misc/viewers/audit-log/LogTable.tsx b/frontends/nextjs/src/components/misc/viewers/audit-log/LogTable.tsx
new file mode 100644
index 000000000..21cbbdae7
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/viewers/audit-log/LogTable.tsx
@@ -0,0 +1,124 @@
+import { Badge, Card, CardContent, CardHeader, CardTitle, ScrollArea, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
+import type { AuditLog, OperationType, ResourceType } from '@/lib/security/secure-db/types'
+import { ArrowDown, ArrowUp, ShieldCheck, User as UserIcon, WarningCircle } from '@phosphor-icons/react'
+
+interface LogTableProps {
+ logs: AuditLog[]
+ sortField?: keyof AuditLog | null
+ sortDirection?: 'asc' | 'desc'
+ onSortChange?: (field: keyof AuditLog) => void
+}
+
+const OPERATION_COLORS: Record = {
+ CREATE: 'bg-green-100 text-green-800',
+ READ: 'bg-blue-100 text-blue-800',
+ UPDATE: 'bg-yellow-100 text-yellow-800',
+ DELETE: 'bg-red-100 text-red-800'
+}
+
+const RESOURCE_ICONS: Partial> = {
+ user: ,
+ credential:
+}
+
+export function LogTable({ logs, sortField, sortDirection = 'asc', onSortChange }: LogTableProps) {
+ const handleSort = (field: keyof AuditLog) => {
+ onSortChange?.(field)
+ }
+
+ return (
+
+
+ Audit Log
+
+
+
+
+
+
+
+ User
+ Operation
+ Resource
+ Status
+ Details
+
+
+
+ {logs.length === 0 ? (
+
+
+ No audit events to display
+
+
+ ) : (
+ logs.map((log) => (
+
+
+ {new Date(log.timestamp).toLocaleString()}
+
+ {log.username}
+
+ {log.operation}
+
+
+ {RESOURCE_ICONS[log.resource] || }
+ {log.resource}
+
+
+ {log.success ? (
+
+
+ Success
+
+ ) : (
+
+
+ Failed
+
+ )}
+
+
+ {log.errorMessage || '—'}
+
+
+ ))
+ )}
+
+
+
+
+
+ )
+}
+
+interface SortableHeaderProps {
+ field: keyof AuditLog
+ label: string
+ sortField?: keyof AuditLog | null
+ sortDirection?: 'asc' | 'desc'
+ onSort?: (field: keyof AuditLog) => void
+}
+
+function SortableHeader({ field, label, sortField, sortDirection = 'asc', onSort }: SortableHeaderProps) {
+ const isActive = sortField === field
+ const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown
+
+ return (
+ onSort?.(field)}
+ >
+
+ {label}
+ {isActive && }
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/viewers/model-list/DetailsDrawer.tsx b/frontends/nextjs/src/components/misc/viewers/model-list/DetailsDrawer.tsx
new file mode 100644
index 000000000..07bc37865
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/viewers/model-list/DetailsDrawer.tsx
@@ -0,0 +1,73 @@
+import { Badge, Button, ScrollArea, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui'
+import type { ModelSchema } from '@/lib/schema-types'
+import { getFieldLabel } from '@/lib/schema-utils'
+
+interface DetailsDrawerProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ record: any | null
+ model: ModelSchema
+}
+
+export function DetailsDrawer({ open, onOpenChange, record, model }: DetailsDrawerProps) {
+ const renderValue = (fieldName: string) => {
+ const field = model.fields.find((item) => item.name === fieldName)
+ if (!field) return null
+ const value = record?.[fieldName]
+
+ if (value === null || value === undefined || value === '') {
+ return Not provided
+ }
+
+ switch (field.type) {
+ case 'boolean':
+ return value ? (
+
+ Yes
+
+ ) : (
+ No
+ )
+ case 'date':
+ case 'datetime':
+ return new Date(value).toLocaleString()
+ case 'json':
+ return {JSON.stringify(value, null, 2)}
+ default:
+ return {String(value)}
+ }
+ }
+
+ return (
+
+
+
+ {model.label || model.name} details
+ Review the full record and its attributes.
+
+
+
+
+
+
+ {model.fields.map((field) => (
+
+
{getFieldLabel(field)}
+
{renderValue(field.name)}
+ {field.helpText && (
+
{field.helpText}
+ )}
+
+ ))}
+
+
+
+
+
+ Close
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/viewers/model-list/ModelFilters.tsx b/frontends/nextjs/src/components/misc/viewers/model-list/ModelFilters.tsx
new file mode 100644
index 000000000..465f5605d
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/viewers/model-list/ModelFilters.tsx
@@ -0,0 +1,82 @@
+import { Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
+import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
+import { MagnifyingGlass } from '@phosphor-icons/react'
+
+interface ModelFiltersProps {
+ model: ModelSchema
+ filters: Record
+ searchTerm: string
+ onSearchChange: (value: string) => void
+ onFilterChange: (field: string, value: any) => void
+}
+
+function getFilterableFields(model: ModelSchema): FieldSchema[] {
+ if (model.listFilter) {
+ return model.fields.filter((field) => model.listFilter?.includes(field.name))
+ }
+ return model.fields.filter((field) => field.type === 'select' || field.type === 'boolean')
+}
+
+export function ModelFilters({ model, filters, searchTerm, onSearchChange, onFilterChange }: ModelFiltersProps) {
+ const filterFields = getFilterableFields(model)
+
+ return (
+
+
+
Search
+
+
+ onSearchChange(event.target.value)}
+ className="pl-9"
+ />
+
+
+
+ {filterFields.length > 0 && (
+
+ {filterFields.map((field) => (
+
+ {field.label || field.name}
+ {field.type === 'select' ? (
+ onFilterChange(field.name, value === '__all__' ? null : value)}
+ >
+
+
+
+
+ All
+ {field.choices?.map((choice) => (
+
+ {choice.label || choice.value}
+
+ ))}
+
+
+ ) : (
+ onFilterChange(field.name, value === 'true' ? true : value === 'false' ? false : null)}
+ >
+
+
+
+
+ All
+ Yes
+ No
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/misc/viewers/model-list/ModelTable.tsx b/frontends/nextjs/src/components/misc/viewers/model-list/ModelTable.tsx
new file mode 100644
index 000000000..8ecf94a36
--- /dev/null
+++ b/frontends/nextjs/src/components/misc/viewers/model-list/ModelTable.tsx
@@ -0,0 +1,148 @@
+import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
+import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
+import { getFieldLabel } from '@/lib/schema-utils'
+import { ArrowDown, ArrowUp, Pencil, Trash } from '@phosphor-icons/react'
+import { ReactNode } from 'react'
+
+interface ModelTableProps {
+ model: ModelSchema
+ records: any[]
+ displayFields: string[]
+ sortField?: string | null
+ sortDirection?: 'asc' | 'desc'
+ onSortChange?: (field: string) => void
+ onEdit?: (record: any) => void
+ onDelete?: (id: string) => void
+ onRowClick?: (record: any) => void
+ renderRelationValue?: (value: string, field: FieldSchema) => ReactNode
+}
+
+export function ModelTable({
+ model,
+ records,
+ displayFields,
+ sortField,
+ sortDirection = 'asc',
+ onSortChange,
+ onEdit,
+ onDelete,
+ onRowClick,
+ renderRelationValue
+}: ModelTableProps) {
+ const actionColumns = onEdit || onDelete ? 1 : 0
+
+ const renderCellValue = (record: any, fieldName: string) => {
+ const field = model.fields.find((item) => item.name === fieldName)
+ if (!field) return null
+
+ const value = record[fieldName]
+
+ if (value === null || value === undefined) {
+ return —
+ }
+
+ if (field.type === 'relation' && typeof value === 'string' && renderRelationValue) {
+ return renderRelationValue(value, field)
+ }
+
+ switch (field.type) {
+ case 'boolean':
+ return value ? Yes : No
+ case 'date':
+ case 'datetime':
+ return new Date(value).toLocaleString()
+ case 'json':
+ return {JSON.stringify(value)}
+ default:
+ return typeof value === 'string' && value.length > 60 ? `${value.slice(0, 60)}…` : String(value)
+ }
+ }
+
+ return (
+
+
+
+
+ {displayFields.map((fieldName) => {
+ const field = model.fields.find((item) => item.name === fieldName)
+ if (!field) return null
+ const isSortable = field.sortable !== false
+ const isActive = sortField === fieldName
+ const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown
+
+ return (
+ isSortable && onSortChange?.(fieldName)}
+ >
+
+
+ {getFieldLabel(field)}
+
+ {isSortable && isActive && }
+
+
+ )
+ })}
+ {(onEdit || onDelete) && Actions }
+
+
+
+ {records.length === 0 ? (
+
+
+ No records to display
+
+
+ ) : (
+ records.map((record) => (
+ onRowClick?.(record)}
+ >
+ {displayFields.map((fieldName) => (
+
+ {renderCellValue(record, fieldName)}
+
+ ))}
+ {(onEdit || onDelete) && (
+
+
+ {onEdit && (
+
{
+ event.stopPropagation()
+ onEdit(record)
+ }}
+ >
+
+
+ )}
+ {onDelete && (
+
{
+ event.stopPropagation()
+ onDelete(record.id)
+ }}
+ >
+
+
+ )}
+
+
+ )}
+
+ ))
+ )}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE.tsx b/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE.tsx
index ea2e93964..9b5505273 100644
--- a/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE.tsx
+++ b/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE.tsx
@@ -1,246 +1,58 @@
-import { useEffect, useMemo, useState } from 'react'
-import { Card, CardContent } from '@/components/ui'
import { toast } from 'sonner'
-import { useKV } from '@github/spark/hooks'
-import type { FileNode } from '@/lib/nerd-mode-ide'
-import {
- appendExportPath,
- buildZipFromFileTree,
- fileTreeOperations,
- getPackageTemplateById,
- getPackageTemplates,
-} from '@/lib/nerd-mode-ide'
+import { Card, CardContent } from '@/components/ui'
import { GitConfigDialog } from '../dialogs/GitConfigDialog'
-import { NerdModeEditorPanel } from '../panels/NerdModeEditorPanel'
-import { NerdModeIDEFileExplorer } from '../components/NerdModeIDEFileExplorer'
import { NerdModeIDEHeader } from '../components/NerdModeIDEHeader'
import { NewItemDialog } from '../dialogs/NewItemDialog'
import { TemplateDialog } from '../dialogs/TemplateDialog'
-import type { GitConfig, TestResult } from './types'
+import { EditorPane } from './NerdModeIDE/EditorPane'
+import { Sidebar } from './NerdModeIDE/Sidebar'
+import { useNerdIdeState } from './NerdModeIDE/useNerdIdeState'
interface NerdModeIDEProps {
className?: string
}
export function NerdModeIDE({ className }: NerdModeIDEProps) {
- const templates = useMemo(() => getPackageTemplates(), [])
- const defaultTemplate = templates[0]
-
- const [fileTree, setFileTree] = useKV('nerd-mode-file-tree', defaultTemplate.tree)
- const [workspaceName, setWorkspaceName] = useKV('nerd-mode-workspace', defaultTemplate.rootName)
- const [selectedFileId, setSelectedFileId] = useState(null)
- const [activeFolderId, setActiveFolderId] = useState(fileTree?.[0]?.id ?? null)
- const [fileContent, setFileContent] = useState('')
- const [gitConfig, setGitConfig] = useKV('nerd-mode-git-config', null)
- const [showGitDialog, setShowGitDialog] = useState(false)
- const [showNewItemDialog, setShowNewItemDialog] = useState(false)
- const [showTemplateDialog, setShowTemplateDialog] = useState(false)
- const [newItemName, setNewItemName] = useState('')
- const [newItemType, setNewItemType] = useState<'file' | 'folder'>('file')
- const [testResults, setTestResults] = useState([])
- const [consoleOutput, setConsoleOutput] = useState([])
- const [isRunning, setIsRunning] = useState(false)
- const [gitCommitMessage, setGitCommitMessage] = useState('')
-
- const selectedFile = useMemo(() => {
- if (!fileTree || !selectedFileId) return null
- return fileTreeOperations.findNodeById(fileTree, selectedFileId)
- }, [fileTree, selectedFileId])
-
- useEffect(() => {
- if (!fileTree) return
- if (!selectedFileId) {
- const firstFile = fileTreeOperations.findFirstFile(fileTree)
- if (firstFile) {
- setSelectedFileId(firstFile.id)
- }
- return
- }
-
- const fileNode = fileTreeOperations.findNodeById(fileTree, selectedFileId)
- if (fileNode?.type === 'file') {
- setFileContent(fileNode.content ?? '')
- }
- }, [fileTree, selectedFileId])
-
- const handleToggleFolder = (nodeId: string) => {
- if (!fileTree) return
- const node = fileTreeOperations.findNodeById(fileTree, nodeId)
- if (node?.type === 'folder') {
- setFileTree(fileTreeOperations.updateNode(fileTree, nodeId, { expanded: !node.expanded }))
- }
- }
-
- const handleSaveFile = () => {
- if (!fileTree || !selectedFileId) return
- setFileTree(fileTreeOperations.updateNode(fileTree, selectedFileId, { content: fileContent }))
- toast.success(`Saved ${selectedFile?.name || 'file'}`)
- }
-
- const handleDeleteFile = () => {
- if (!fileTree || !selectedFileId) return
- setFileTree(fileTreeOperations.deleteNode(fileTree, selectedFileId))
- setSelectedFileId(null)
- setFileContent('')
- toast.success(`Deleted ${selectedFile?.name || 'file'}`)
- }
-
- const handleCreateItem = () => {
- if (!newItemName.trim() || !fileTree) {
- toast.error('Please enter a name')
- return
- }
-
- const parentNode = activeFolderId
- ? fileTreeOperations.findNodeById(fileTree, activeFolderId)
- : null
-
- const exportPath = parentNode?.exportPath
- ? appendExportPath(parentNode.exportPath, newItemName)
- : undefined
-
- const newNode = newItemType === 'file'
- ? fileTreeOperations.createFileNode({ name: newItemName, exportPath })
- : fileTreeOperations.createFolderNode({ name: newItemName, exportPath, expanded: true })
-
- setFileTree(fileTreeOperations.appendNode(fileTree, activeFolderId, newNode))
- setNewItemName('')
- setShowNewItemDialog(false)
-
- if (newNode.type === 'file') {
- setSelectedFileId(newNode.id)
- setFileContent(newNode.content ?? '')
- }
-
- toast.success(`Created ${newNode.name}`)
- }
-
- const handleRunCode = () => {
- setIsRunning(true)
- setConsoleOutput((current) => [...current, `> Running ${selectedFile?.name || 'code'}...`])
-
- setTimeout(() => {
- setConsoleOutput((current) => [
- ...current,
- 'OK Code executed successfully',
- '> Output: Hello from MetaBuilder IDE!',
- ])
- setIsRunning(false)
- toast.success('Code executed')
- }, 1000)
- }
-
- const handleRunTests = () => {
- setConsoleOutput((current) => [...current, '> Running test suite...'])
-
- const mockTests: TestResult[] = [
- { name: 'Feed component renders', status: 'passed', duration: 45 },
- { name: 'Package export bundles files', status: 'passed', duration: 123 },
- { name: 'Lua manifest loads', status: 'passed', duration: 234 },
- { name: 'Permissions hook', status: 'passed', duration: 67 },
- ]
-
- setTimeout(() => {
- setTestResults(mockTests)
- setConsoleOutput((current) => [
- ...current,
- `OK ${mockTests.filter((test) => test.status === 'passed').length} tests passed`,
- `FAIL ${mockTests.filter((test) => test.status === 'failed').length} tests failed`,
- ])
- toast.success('Tests completed')
- }, 1500)
- }
-
- const handleGitPush = () => {
- if (!gitConfig) {
- toast.error('Please configure Git first')
- setShowGitDialog(true)
- return
- }
-
- if (!gitCommitMessage.trim()) {
- toast.error('Please enter a commit message')
- return
- }
-
- setConsoleOutput((current) => [
- ...current,
- `> git add .`,
- `> git commit -m "${gitCommitMessage}"`,
- `> git push origin ${gitConfig.branch}`,
- ])
-
- setTimeout(() => {
- setConsoleOutput((current) => [
- ...current,
- `OK Pushed to ${gitConfig.provider} (${gitConfig.repoUrl})`,
- ])
- setGitCommitMessage('')
- toast.success('Changes pushed to repository')
- }, 1000)
- }
-
- const handleGitPull = () => {
- if (!gitConfig) {
- toast.error('Please configure Git first')
- setShowGitDialog(true)
- return
- }
-
- setConsoleOutput((current) => [...current, `> git pull origin ${gitConfig.branch}`])
-
- setTimeout(() => {
- setConsoleOutput((current) => [
- ...current,
- `OK Pulled latest changes from ${gitConfig.branch}`,
- ])
- toast.success('Repository updated')
- }, 1000)
- }
-
- const handleExportZip = async () => {
- if (!fileTree || fileTree.length === 0) {
- toast.error('No files to export')
- return
- }
-
- try {
- const blob = await buildZipFromFileTree(fileTree)
- const url = URL.createObjectURL(blob)
- const anchor = document.createElement('a')
- anchor.href = url
- anchor.download = `${workspaceName || 'workspace'}.zip`
- anchor.click()
- URL.revokeObjectURL(url)
- toast.success('Zip exported')
- } catch (error) {
- toast.error('Failed to export zip')
- }
- }
-
- const handleTemplateSelect = (templateId: string) => {
- const template = getPackageTemplateById(templateId)
- if (!template) return
-
- setFileTree(template.tree)
- setWorkspaceName(template.rootName)
- setActiveFolderId(template.tree[0]?.id ?? null)
- const firstFile = fileTreeOperations.findFirstFile(template.tree)
- setSelectedFileId(firstFile?.id ?? null)
- setFileContent(firstFile?.content ?? '')
- setShowTemplateDialog(false)
- toast.success(`Loaded ${template.name}`)
- }
-
- const handleUpdateGitConfig = (updates: Partial) => {
- setGitConfig((current) => ({
- provider: current?.provider ?? 'github',
- repoUrl: current?.repoUrl ?? '',
- branch: current?.branch ?? 'main',
- token: current?.token ?? '',
- ...updates,
- }))
- }
+ const {
+ activeFolderId,
+ consoleOutput,
+ fileContent,
+ fileTree,
+ gitCommitMessage,
+ gitConfig,
+ isRunning,
+ newItemName,
+ newItemType,
+ selectedFile,
+ selectedFileId,
+ showGitDialog,
+ showNewItemDialog,
+ showTemplateDialog,
+ templates,
+ testResults,
+ workspaceName,
+ handleCreateItem,
+ handleDeleteFile,
+ handleExportZip,
+ handleGitPull,
+ handleGitPush,
+ handleRunCode,
+ handleRunTests,
+ handleSaveFile,
+ handleTemplateSelect,
+ handleToggleFolder,
+ handleUpdateGitConfig,
+ setActiveFolderId,
+ setConsoleOutput,
+ setFileContent,
+ setGitCommitMessage,
+ setNewItemName,
+ setNewItemType,
+ setSelectedFileId,
+ setShowGitDialog,
+ setShowNewItemDialog,
+ setShowTemplateDialog,
+ } = useNerdIdeState()
return (
@@ -254,7 +66,7 @@ export function NerdModeIDE({ className }: NerdModeIDEProps) {
/>
-
-
void
+ onRunCode: () => void
+ onSaveFile: () => void
+ onDeleteFile: () => void
+ onClearConsole: () => void
+ onRunTests: () => void
+ onCommitMessageChange: (value: string) => void
+ onGitPush: () => void
+ onGitPull: () => void
+ onOpenGitConfig: () => void
+}
+
+export function EditorPane(props: EditorPaneProps) {
+ return
+}
diff --git a/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE/Sidebar.tsx b/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE/Sidebar.tsx
new file mode 100644
index 000000000..b48fe4faf
--- /dev/null
+++ b/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE/Sidebar.tsx
@@ -0,0 +1,31 @@
+import type { FileNode } from '@/lib/nerd-mode-ide'
+import { NerdModeIDEFileExplorer } from '../../components/NerdModeIDEFileExplorer'
+
+interface SidebarProps {
+ nodes: FileNode[]
+ selectedFileId: string | null
+ activeFolderId: string | null
+ onToggleFolder: (nodeId: string) => void
+ onSelectFile: (nodeId: string) => void
+ onSelectFolder: (nodeId: string | null) => void
+}
+
+export function Sidebar({
+ nodes,
+ selectedFileId,
+ activeFolderId,
+ onToggleFolder,
+ onSelectFile,
+ onSelectFolder,
+}: SidebarProps) {
+ return (
+
+ )
+}
diff --git a/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE/useNerdIdeState.ts b/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE/useNerdIdeState.ts
new file mode 100644
index 000000000..55d58a1c1
--- /dev/null
+++ b/frontends/nextjs/src/components/nerd-mode-ide/core/NerdModeIDE/useNerdIdeState.ts
@@ -0,0 +1,274 @@
+import { useEffect, useMemo, useState } from 'react'
+import { toast } from 'sonner'
+import { useKV } from '@github/spark/hooks'
+import type { FileNode } from '@/lib/nerd-mode-ide'
+import {
+ appendExportPath,
+ buildZipFromFileTree,
+ fileTreeOperations,
+ getPackageTemplateById,
+ getPackageTemplates,
+} from '@/lib/nerd-mode-ide'
+import type { GitConfig, TestResult } from '../types'
+
+export function useNerdIdeState() {
+ const templates = useMemo(() => getPackageTemplates(), [])
+ const defaultTemplate = templates[0]
+
+ const [fileTree, setFileTree] = useKV('nerd-mode-file-tree', defaultTemplate.tree)
+ const [workspaceName, setWorkspaceName] = useKV('nerd-mode-workspace', defaultTemplate.rootName)
+ const [selectedFileId, setSelectedFileId] = useState(null)
+ const [activeFolderId, setActiveFolderId] = useState(fileTree?.[0]?.id ?? null)
+ const [fileContent, setFileContent] = useState('')
+ const [gitConfig, setGitConfig] = useKV('nerd-mode-git-config', null)
+ const [showGitDialog, setShowGitDialog] = useState(false)
+ const [showNewItemDialog, setShowNewItemDialog] = useState(false)
+ const [showTemplateDialog, setShowTemplateDialog] = useState(false)
+ const [newItemName, setNewItemName] = useState('')
+ const [newItemType, setNewItemType] = useState<'file' | 'folder'>('file')
+ const [testResults, setTestResults] = useState([])
+ const [consoleOutput, setConsoleOutput] = useState([])
+ const [isRunning, setIsRunning] = useState(false)
+ const [gitCommitMessage, setGitCommitMessage] = useState('')
+
+ const selectedFile = useMemo(() => {
+ if (!fileTree || !selectedFileId) return null
+ return fileTreeOperations.findNodeById(fileTree, selectedFileId)
+ }, [fileTree, selectedFileId])
+
+ useEffect(() => {
+ if (!fileTree) return
+ if (!selectedFileId) {
+ const firstFile = fileTreeOperations.findFirstFile(fileTree)
+ if (firstFile) {
+ setSelectedFileId(firstFile.id)
+ }
+ return
+ }
+
+ const fileNode = fileTreeOperations.findNodeById(fileTree, selectedFileId)
+ if (fileNode?.type === 'file') {
+ setFileContent(fileNode.content ?? '')
+ }
+ }, [fileTree, selectedFileId])
+
+ const handleToggleFolder = (nodeId: string) => {
+ if (!fileTree) return
+ const node = fileTreeOperations.findNodeById(fileTree, nodeId)
+ if (node?.type === 'folder') {
+ setFileTree(fileTreeOperations.updateNode(fileTree, nodeId, { expanded: !node.expanded }))
+ }
+ }
+
+ const handleSaveFile = () => {
+ if (!fileTree || !selectedFileId) return
+ setFileTree(fileTreeOperations.updateNode(fileTree, selectedFileId, { content: fileContent }))
+ toast.success(`Saved ${selectedFile?.name || 'file'}`)
+ }
+
+ const handleDeleteFile = () => {
+ if (!fileTree || !selectedFileId) return
+ setFileTree(fileTreeOperations.deleteNode(fileTree, selectedFileId))
+ setSelectedFileId(null)
+ setFileContent('')
+ toast.success(`Deleted ${selectedFile?.name || 'file'}`)
+ }
+
+ const handleCreateItem = () => {
+ if (!newItemName.trim() || !fileTree) {
+ toast.error('Please enter a name')
+ return
+ }
+
+ const parentNode = activeFolderId
+ ? fileTreeOperations.findNodeById(fileTree, activeFolderId)
+ : null
+
+ const exportPath = parentNode?.exportPath
+ ? appendExportPath(parentNode.exportPath, newItemName)
+ : undefined
+
+ const newNode = newItemType === 'file'
+ ? fileTreeOperations.createFileNode({ name: newItemName, exportPath })
+ : fileTreeOperations.createFolderNode({ name: newItemName, exportPath, expanded: true })
+
+ setFileTree(fileTreeOperations.appendNode(fileTree, activeFolderId, newNode))
+ setNewItemName('')
+ setShowNewItemDialog(false)
+
+ if (newNode.type === 'file') {
+ setSelectedFileId(newNode.id)
+ setFileContent(newNode.content ?? '')
+ }
+
+ toast.success(`Created ${newNode.name}`)
+ }
+
+ const handleRunCode = () => {
+ setIsRunning(true)
+ setConsoleOutput((current) => [...current, `> Running ${selectedFile?.name || 'code'}...`])
+
+ setTimeout(() => {
+ setConsoleOutput((current) => [
+ ...current,
+ 'OK Code executed successfully',
+ '> Output: Hello from MetaBuilder IDE!',
+ ])
+ setIsRunning(false)
+ toast.success('Code executed')
+ }, 1000)
+ }
+
+ const handleRunTests = () => {
+ setConsoleOutput((current) => [...current, '> Running test suite...'])
+
+ const mockTests: TestResult[] = [
+ { name: 'Feed component renders', status: 'passed', duration: 45 },
+ { name: 'Package export bundles files', status: 'passed', duration: 123 },
+ { name: 'Lua manifest loads', status: 'passed', duration: 234 },
+ { name: 'Permissions hook', status: 'passed', duration: 67 },
+ ]
+
+ setTimeout(() => {
+ setTestResults(mockTests)
+ setConsoleOutput((current) => [
+ ...current,
+ `OK ${mockTests.filter((test) => test.status === 'passed').length} tests passed`,
+ `FAIL ${mockTests.filter((test) => test.status === 'failed').length} tests failed`,
+ ])
+ toast.success('Tests completed')
+ }, 1500)
+ }
+
+ const handleGitPush = () => {
+ if (!gitConfig) {
+ toast.error('Please configure Git first')
+ setShowGitDialog(true)
+ return
+ }
+
+ if (!gitCommitMessage.trim()) {
+ toast.error('Please enter a commit message')
+ return
+ }
+
+ setConsoleOutput((current) => [
+ ...current,
+ `> git add .`,
+ `> git commit -m "${gitCommitMessage}"`,
+ `> git push origin ${gitConfig.branch}`,
+ ])
+
+ setTimeout(() => {
+ setConsoleOutput((current) => [
+ ...current,
+ `OK Pushed to ${gitConfig.provider} (${gitConfig.repoUrl})`,
+ ])
+ setGitCommitMessage('')
+ toast.success('Changes pushed to repository')
+ }, 1000)
+ }
+
+ const handleGitPull = () => {
+ if (!gitConfig) {
+ toast.error('Please configure Git first')
+ setShowGitDialog(true)
+ return
+ }
+
+ setConsoleOutput((current) => [...current, `> git pull origin ${gitConfig.branch}`])
+
+ setTimeout(() => {
+ setConsoleOutput((current) => [
+ ...current,
+ `OK Pulled latest changes from ${gitConfig.branch}`,
+ ])
+ toast.success('Repository updated')
+ }, 1000)
+ }
+
+ const handleExportZip = async () => {
+ if (!fileTree || fileTree.length === 0) {
+ toast.error('No files to export')
+ return
+ }
+
+ try {
+ const blob = await buildZipFromFileTree(fileTree)
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = `${workspaceName || 'workspace'}.zip`
+ anchor.click()
+ URL.revokeObjectURL(url)
+ toast.success('Zip exported')
+ } catch (error) {
+ toast.error('Failed to export zip')
+ }
+ }
+
+ const handleTemplateSelect = (templateId: string) => {
+ const template = getPackageTemplateById(templateId)
+ if (!template) return
+
+ setFileTree(template.tree)
+ setWorkspaceName(template.rootName)
+ setActiveFolderId(template.tree[0]?.id ?? null)
+ const firstFile = fileTreeOperations.findFirstFile(template.tree)
+ setSelectedFileId(firstFile?.id ?? null)
+ setFileContent(firstFile?.content ?? '')
+ setShowTemplateDialog(false)
+ toast.success(`Loaded ${template.name}`)
+ }
+
+ const handleUpdateGitConfig = (updates: Partial) => {
+ setGitConfig((current) => ({
+ provider: current?.provider ?? 'github',
+ repoUrl: current?.repoUrl ?? '',
+ branch: current?.branch ?? 'main',
+ token: current?.token ?? '',
+ ...updates,
+ }))
+ }
+
+ return {
+ activeFolderId,
+ consoleOutput,
+ fileContent,
+ fileTree,
+ gitCommitMessage,
+ gitConfig,
+ isRunning,
+ newItemName,
+ newItemType,
+ selectedFile,
+ selectedFileId,
+ showGitDialog,
+ showNewItemDialog,
+ showTemplateDialog,
+ templates,
+ testResults,
+ workspaceName,
+ handleCreateItem,
+ handleDeleteFile,
+ handleExportZip,
+ handleGitPull,
+ handleGitPush,
+ handleRunCode,
+ handleRunTests,
+ handleSaveFile,
+ handleTemplateSelect,
+ handleToggleFolder,
+ handleUpdateGitConfig,
+ setActiveFolderId,
+ setConsoleOutput,
+ setFileContent,
+ setGitCommitMessage,
+ setNewItemName,
+ setNewItemType,
+ setSelectedFileId,
+ setShowGitDialog,
+ setShowNewItemDialog,
+ setShowTemplateDialog,
+ }
+}
diff --git a/frontends/nextjs/src/components/organisms/security/ActionButtons.tsx b/frontends/nextjs/src/components/organisms/security/ActionButtons.tsx
new file mode 100644
index 000000000..e0a5f441a
--- /dev/null
+++ b/frontends/nextjs/src/components/organisms/security/ActionButtons.tsx
@@ -0,0 +1,42 @@
+import { Button } from '@/components/ui'
+import { DialogFooter } from '@/components/ui'
+import type { SecurityScanResult } from '@/lib/security/scanner/security-scanner'
+
+interface ActionButtonsProps {
+ scanResult: SecurityScanResult
+ onCancel: () => void
+ onProceed?: () => void
+ showProceedButton?: boolean
+}
+
+export function ActionButtons({
+ scanResult,
+ onCancel,
+ onProceed,
+ showProceedButton = false
+}: ActionButtonsProps) {
+ const disableProceed = scanResult.severity === 'critical'
+
+ return (
+
+
+ {scanResult.safe ? 'Close' : 'Cancel'}
+
+
+ {!scanResult.safe && showProceedButton && (
+
+ {scanResult.severity === 'critical' ? 'Force Proceed (Not Recommended)' : 'Proceed Anyway'}
+
+ )}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/organisms/security/SecurityMessage.tsx b/frontends/nextjs/src/components/organisms/security/SecurityMessage.tsx
new file mode 100644
index 000000000..3091dc661
--- /dev/null
+++ b/frontends/nextjs/src/components/organisms/security/SecurityMessage.tsx
@@ -0,0 +1,171 @@
+import { Alert, AlertDescription } from '@/components/ui'
+import { Badge } from '@/components/ui'
+import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
+import { ScrollArea } from '@/components/ui'
+import { Separator } from '@/components/ui'
+import { ShieldWarning, Warning, Info, CheckCircle } from '@phosphor-icons/react'
+import type { SecurityIssue, SecurityScanResult } from '@/lib/security/scanner/security-scanner'
+import { getSeverityColor, getSeverityIcon } from '@/lib/security/scanner/security-scanner'
+
+interface SecurityMessageProps {
+ scanResult: SecurityScanResult
+ codeType?: string
+}
+
+const severityOrder = ['critical', 'high', 'medium', 'low'] as const
+
+const getSeverityBadgeVariant = (severity: string) => {
+ switch (severity) {
+ case 'critical':
+ case 'high':
+ return 'destructive'
+ case 'medium':
+ return 'default'
+ case 'low':
+ return 'secondary'
+ default:
+ return 'outline'
+ }
+}
+
+const getIcon = (severity: string) => {
+ switch (severity) {
+ case 'critical':
+ case 'high':
+ return
+ case 'medium':
+ return
+ case 'low':
+ return
+ default:
+ return
+ }
+}
+
+const getTitle = (severity: string, safe: boolean) => {
+ if (safe) return 'Code Security Check Passed'
+
+ switch (severity) {
+ case 'critical':
+ return 'CRITICAL SECURITY THREAT DETECTED'
+ case 'high':
+ return 'High-Risk Security Issues Detected'
+ case 'medium':
+ return 'Security Warnings Detected'
+ case 'low':
+ return 'Minor Security Concerns'
+ default:
+ return 'Security Scan Complete'
+ }
+}
+
+const getDescription = (scanResult: SecurityScanResult, codeType: string) => {
+ if (scanResult.safe) {
+ return `Your ${codeType} has been scanned and appears to be safe.`
+ }
+
+ const { issues } = scanResult
+ return `Your ${codeType} contains ${issues.length} security ${issues.length === 1 ? 'issue' : 'issues'} that require attention.`
+}
+
+export function SecurityMessage({ scanResult, codeType = 'code' }: SecurityMessageProps) {
+ const groupedIssues = scanResult.issues.reduce((acc, issue) => {
+ if (!acc[issue.severity]) {
+ acc[issue.severity] = []
+ }
+ acc[issue.severity].push(issue)
+ return acc
+ }, {} as Record)
+
+ return (
+ <>
+
+
+ {getIcon(scanResult.severity)}
+
+ {getTitle(scanResult.severity, scanResult.safe)}
+
+ {getDescription(scanResult, codeType)}
+
+
+
+
+
+
+ {scanResult.safe ? (
+
+
+
+ No security issues detected. Your code follows security best practices.
+
+
+ ) : (
+
+ {severityOrder.map(severity => {
+ const issues = groupedIssues[severity]
+ if (!issues || issues.length === 0) return null
+
+ return (
+
+
+
+ {getSeverityIcon(severity)} {severity}
+
+
+ {issues.length} {issues.length === 1 ? 'issue' : 'issues'}
+
+
+
+
+ {issues.map((issue, idx) => (
+
+
+
+
+
{issue.message}
+ {issue.line && (
+
+ Line {issue.line}
+
+ )}
+
+
+ {issue.pattern}
+
+
+ {issue.recommendation && (
+ <>
+
+
+
Recommendation:
+
{issue.recommendation}
+
+ >
+ )}
+
+
+ ))}
+
+
+ )
+ })}
+
+ {(scanResult.severity === 'critical' || scanResult.severity === 'high') && (
+
+
+
+ Security Alert
+
+ {scanResult.severity === 'critical'
+ ? 'This code contains CRITICAL security vulnerabilities that could compromise system security, steal data, or execute malicious actions. It is strongly recommended NOT to proceed.'
+ : 'This code contains HIGH-RISK security issues that could lead to vulnerabilities or unexpected behavior. Carefully review and fix these issues before proceeding.'}
+
+
+
+ )}
+
+ )}
+
+ >
+ )
+}
diff --git a/frontends/nextjs/src/components/organisms/security/SecurityWarningDialog.tsx b/frontends/nextjs/src/components/organisms/security/SecurityWarningDialog.tsx
index d84d4015e..2e1129fe4 100644
--- a/frontends/nextjs/src/components/organisms/security/SecurityWarningDialog.tsx
+++ b/frontends/nextjs/src/components/organisms/security/SecurityWarningDialog.tsx
@@ -1,32 +1,19 @@
/**
* SecurityWarningDialog - Organism Component
- *
+ *
* This component is categorized as an organism (not a molecule) because:
* 1. It contains complex data processing (groups security issues by severity)
* 2. It implements security-specific business rules (severity ordering, badge variants)
* 3. It's a feature-specific component for security scanning results
* 4. It exceeds the recommended 150 LOC guideline for molecules (235 LOC)
- *
+ *
* See: docs/analysis/molecule-organism-audit.md for full categorization analysis
*/
-import { useState } from 'react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui'
-import { Button } from '@/components/ui'
-import { Alert, AlertDescription } from '@/components/ui'
-import { ScrollArea } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { Separator } from '@/components/ui'
-import { ShieldWarning, Warning, Info, CheckCircle } from '@phosphor-icons/react'
-import type { SecurityScanResult, SecurityIssue } from '@/lib/security/scanner/security-scanner'
-import { getSeverityColor, getSeverityIcon } from '@/lib/security/scanner/security-scanner'
+import { Dialog, DialogContent } from '@/components/ui'
+import type { SecurityScanResult } from '@/lib/security/scanner/security-scanner'
+import { ActionButtons } from './ActionButtons'
+import { SecurityMessage } from './SecurityMessage'
interface SecurityWarningDialogProps {
open: boolean
@@ -47,200 +34,34 @@ export function SecurityWarningDialog({
codeType = 'code',
showProceedButton = false
}: SecurityWarningDialogProps) {
- const [acknowledged, setAcknowledged] = useState(false)
+ const closeDialog = () => {
+ onOpenChange(false)
+ }
const handleProceed = () => {
if (onProceed) {
onProceed()
}
- setAcknowledged(false)
- onOpenChange(false)
+ closeDialog()
}
const handleCancel = () => {
if (onCancel) {
onCancel()
}
- setAcknowledged(false)
- onOpenChange(false)
+ closeDialog()
}
- const getSeverityBadgeVariant = (severity: string) => {
- switch (severity) {
- case 'critical':
- return 'destructive'
- case 'high':
- return 'destructive'
- case 'medium':
- return 'default'
- case 'low':
- return 'secondary'
- default:
- return 'outline'
- }
- }
-
- const getIcon = () => {
- switch (scanResult.severity) {
- case 'critical':
- case 'high':
- return
- case 'medium':
- return
- case 'low':
- return
- default:
- return
- }
- }
-
- const getTitle = () => {
- if (scanResult.safe) {
- return 'Code Security Check Passed'
- }
- switch (scanResult.severity) {
- case 'critical':
- return 'CRITICAL SECURITY THREAT DETECTED'
- case 'high':
- return 'High-Risk Security Issues Detected'
- case 'medium':
- return 'Security Warnings Detected'
- case 'low':
- return 'Minor Security Concerns'
- default:
- return 'Security Scan Complete'
- }
- }
-
- const getDescription = () => {
- if (scanResult.safe) {
- return `Your ${codeType} has been scanned and appears to be safe.`
- }
- return `Your ${codeType} contains ${scanResult.issues.length} security ${scanResult.issues.length === 1 ? 'issue' : 'issues'} that require attention.`
- }
-
- const groupedIssues = scanResult.issues.reduce((acc, issue) => {
- if (!acc[issue.severity]) {
- acc[issue.severity] = []
- }
- acc[issue.severity].push(issue)
- return acc
- }, {} as Record)
-
- const severityOrder = ['critical', 'high', 'medium', 'low']
-
return (
-
-
- {getIcon()}
-
- {getTitle()}
-
- {getDescription()}
-
-
-
-
-
-
- {scanResult.safe ? (
-
-
-
- No security issues detected. Your code follows security best practices.
-
-
- ) : (
-
- {severityOrder.map(severity => {
- const issues = groupedIssues[severity]
- if (!issues || issues.length === 0) return null
-
- return (
-
-
-
- {getSeverityIcon(severity)} {severity}
-
-
- {issues.length} {issues.length === 1 ? 'issue' : 'issues'}
-
-
-
-
- {issues.map((issue, idx) => (
-
-
-
-
-
{issue.message}
- {issue.line && (
-
- Line {issue.line}
-
- )}
-
-
- {issue.pattern}
-
-
- {issue.recommendation && (
- <>
-
-
-
Recommendation:
-
{issue.recommendation}
-
- >
- )}
-
-
- ))}
-
-
- )
- })}
-
- {(scanResult.severity === 'critical' || scanResult.severity === 'high') && (
-
-
-
- Security Alert
-
- {scanResult.severity === 'critical'
- ? 'This code contains CRITICAL security vulnerabilities that could compromise system security, steal data, or execute malicious actions. It is strongly recommended NOT to proceed.'
- : 'This code contains HIGH-RISK security issues that could lead to vulnerabilities or unexpected behavior. Carefully review and fix these issues before proceeding.'
- }
-
-
-
- )}
-
- )}
-
-
-
-
- {scanResult.safe ? 'Close' : 'Cancel'}
-
-
- {!scanResult.safe && showProceedButton && (
-
- {scanResult.severity === 'critical' ? 'Force Proceed (Not Recommended)' : 'Proceed Anyway'}
-
- )}
-
+
+
)
diff --git a/frontends/nextjs/src/components/rendering/PropertyInspector.tsx b/frontends/nextjs/src/components/rendering/PropertyInspector.tsx
index 801ac2f4c..baeced0a5 100644
--- a/frontends/nextjs/src/components/rendering/PropertyInspector.tsx
+++ b/frontends/nextjs/src/components/rendering/PropertyInspector.tsx
@@ -1,16 +1,11 @@
import { useState, useEffect } from 'react'
-import { ScrollArea } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Label } from '@/components/ui'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
-import { Button } from '@/components/ui'
-import { Separator } from '@/components/ui'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
-import type { ComponentInstance } from '@/lib/builder-types'
+import { Separator, Button } from '@/components/ui'
+import type { ComponentInstance } from '@/lib/types/builder-types'
import { componentCatalog } from '@/lib/component-catalog'
-import { Code, PaintBrush, Trash, Palette } from '@phosphor-icons/react'
+import { Trash } from '@phosphor-icons/react'
import { CssClassBuilder } from '@/components/CssClassBuilder'
import { Database, DropdownConfig } from '@/lib/database'
+import { PropertyPanels } from './components/PropertyPanels'
interface PropertyInspectorProps {
component: ComponentInstance | null
@@ -67,131 +62,14 @@ export function PropertyInspector({ component, onUpdate, onDelete, onCodeEdit }:
Component Properties
-
-
-
-
- Props
-
-
-
- Code
-
-
-
-
-
-
- {componentDef?.propSchema.map(propDef => {
- const dynamicDropdown = propDef.type === 'dynamic-select'
- ? dynamicDropdowns.find(d => d.name === propDef.dynamicSource)
- : null
-
- return (
-
-
{propDef.label}
-
- {propDef.name === 'className' ? (
-
-
handlePropChange(propDef.name, e.target.value)}
- className="flex-1 font-mono text-xs"
- />
-
openCssBuilder(propDef.name)}
- >
-
-
-
- ) : propDef.type === 'string' ? (
-
handlePropChange(propDef.name, e.target.value)}
- />
- ) : propDef.type === 'number' ? (
-
handlePropChange(propDef.name, Number(e.target.value))}
- />
- ) : propDef.type === 'boolean' ? (
-
handlePropChange(propDef.name, value === 'true')}
- >
-
-
-
-
- True
- False
-
-
- ) : propDef.type === 'select' && propDef.options ? (
-
handlePropChange(propDef.name, value)}
- >
-
-
-
-
- {propDef.options.map(option => (
-
- {option.label}
-
- ))}
-
-
- ) : propDef.type === 'dynamic-select' && dynamicDropdown ? (
-
handlePropChange(propDef.name, value)}
- >
-
-
-
-
- {dynamicDropdown.options.map(option => (
-
- {option.label}
-
- ))}
-
-
- ) : null}
-
- {propDef.description && (
-
{propDef.description}
- )}
-
- )
- })}
-
- {(!componentDef?.propSchema || componentDef.propSchema.length === 0) && (
-
This component has no configurable properties.
- )}
-
-
-
-
-
-
-
-
-
- Add custom JavaScript code for this component
-
-
- Open Code Editor
-
-
-
-
-
+
diff --git a/frontends/nextjs/src/components/rendering/RenderComponent.tsx b/frontends/nextjs/src/components/rendering/RenderComponent.tsx
index 2cf420926..8b23fa0aa 100644
--- a/frontends/nextjs/src/components/rendering/RenderComponent.tsx
+++ b/frontends/nextjs/src/components/rendering/RenderComponent.tsx
@@ -1,32 +1,16 @@
-import type { ComponentInstance } from '@/lib/builder-types'
-import { Button } from '@/components/ui'
-import { Input } from '@/components/ui'
-import { Textarea } from '@/components/ui'
-import { Label } from '@/components/ui'
-import { Badge } from '@/components/ui'
-import { Card } from '@/components/ui'
-import { Switch } from '@/components/ui'
-import { Checkbox } from '@/components/ui'
-import { Separator } from '@/components/ui'
-import { Alert } from '@/components/ui'
-import { Progress } from '@/components/ui'
-import { Slider } from '@/components/ui'
-import { Avatar, AvatarFallback } from '@/components/ui'
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
-import { IRCWebchatDeclarative } from '@/components/IRCWebchatDeclarative'
-import { NotificationSummaryCard } from '@/components/NotificationSummaryCard'
+import type React from 'react'
+import type { ComponentInstance } from '@/lib/types/builder-types'
import type { User } from '@/lib/level-types'
-import { getDeclarativeRenderer } from '@/lib/declarative-component-renderer'
+import { RenderNode } from './components/RenderNode'
interface RenderComponentProps {
component: ComponentInstance
isSelected: boolean
onSelect: (id: string) => void
user?: User
- contextData?: Record
}
-export function RenderComponent({ component, isSelected, onSelect, user, contextData }: RenderComponentProps) {
+export function RenderComponent({ component, isSelected, onSelect, user }: RenderComponentProps) {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
onSelect(component.id)
@@ -41,191 +25,19 @@ export function RenderComponent({ component, isSelected, onSelect, user, context
isSelected={isSelected}
onSelect={onSelect}
user={user}
- contextData={contextData}
/>
))
}
const wrapperClass = `relative ${isSelected ? 'ring-2 ring-accent ring-offset-2' : 'hover:ring-1 hover:ring-accent/50'} transition-all cursor-pointer`
- const renderComponentByType = () => {
- const { type, props } = component
- const renderer = getDeclarativeRenderer()
-
- if (renderer.hasComponentConfig(type)) {
- if (type === 'IRCWebchat' && user) {
- return (
-
- )
- }
-
- return (
-
- Declarative Component: {type}
-
- This is a package-defined component
-
-
- )
- }
-
- switch (type) {
- case 'Container':
- return (
-
- {renderChildren()}
-
- )
-
- case 'Flex':
- return (
-
- {renderChildren()}
-
- )
-
- case 'Grid':
- return (
-
- {renderChildren()}
-
- )
-
- case 'Stack':
- return (
-
- {renderChildren()}
-
- )
-
- case 'Card':
- return (
-
- {renderChildren()}
-
- )
-
- case 'NotificationSummary':
- return (
-
- )
-
- case 'Button':
- return (
-
- {props.children || 'Button'}
-
- )
-
- case 'Input':
- return (
-
- )
-
- case 'Textarea':
- return (
-
- )
-
- case 'Label':
- return {props.children || 'Label'}
-
- case 'Heading': {
- const level = props.level || '1'
- const className = props.className
- const text = props.children || 'Heading'
-
- if (level === '1') return {text}
- if (level === '2') return {text}
- if (level === '3') return {text}
- if (level === '4') return {text}
- return {text}
- }
-
- case 'Text':
- return (
-
- {props.children || 'Text'}
-
- )
-
- case 'Badge':
- return (
-
- {props.children || 'Badge'}
-
- )
-
- case 'Switch':
- return
-
- case 'Checkbox':
- return
-
- case 'Separator':
- return
-
- case 'Alert':
- return (
-
- {renderChildren()}
-
- )
-
- case 'Progress':
- return
-
- case 'Slider':
- return
-
- case 'Avatar':
- return (
-
- U
-
- )
-
- case 'Table':
- return (
-
-
-
- Column 1
- Column 2
-
-
-
-
- Data 1
- Data 2
-
-
-
- )
-
- default:
- return Unknown Component: {type}
- }
- }
-
return (
- {renderComponentByType()}
+
)
}
diff --git a/frontends/nextjs/src/components/rendering/components/FieldTypes.tsx b/frontends/nextjs/src/components/rendering/components/FieldTypes.tsx
new file mode 100644
index 000000000..2e53c0431
--- /dev/null
+++ b/frontends/nextjs/src/components/rendering/components/FieldTypes.tsx
@@ -0,0 +1,114 @@
+import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
+import type { PropDefinition } from '@/lib/types/builder-types'
+import type { DropdownConfig } from '@/lib/database'
+import { Palette } from '@phosphor-icons/react'
+
+interface FieldTypesProps {
+ propDef: PropDefinition
+ value: any
+ onChange: (value: any) => void
+ dynamicDropdown?: DropdownConfig | null
+ onOpenCssBuilder?: () => void
+}
+
+export function FieldTypes({ propDef, value, onChange, dynamicDropdown, onOpenCssBuilder }: FieldTypesProps) {
+ const renderInputByType = () => {
+ if (propDef.name === 'className') {
+ return (
+
+
onChange(e.target.value)}
+ className="flex-1 font-mono text-xs"
+ />
+ {onOpenCssBuilder && (
+
+
+
+ )}
+
+ )
+ }
+
+ switch (propDef.type) {
+ case 'string':
+ return (
+ onChange(e.target.value)}
+ />
+ )
+ case 'number':
+ return (
+ onChange(Number(e.target.value))}
+ />
+ )
+ case 'boolean':
+ return (
+ onChange(val === 'true')}
+ >
+
+
+
+
+ True
+ False
+
+
+ )
+ case 'select':
+ return (
+ onChange(val)}
+ >
+
+
+
+
+ {propDef.options?.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+ )
+ case 'dynamic-select':
+ return (
+ onChange(val)}
+ >
+
+
+
+
+ {dynamicDropdown?.options.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+ )
+ default:
+ return null
+ }
+ }
+
+ return (
+
+
{propDef.label}
+ {renderInputByType()}
+ {propDef.description && (
+
{propDef.description}
+ )}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/rendering/components/PropertyPanels.tsx b/frontends/nextjs/src/components/rendering/components/PropertyPanels.tsx
new file mode 100644
index 000000000..f7452d992
--- /dev/null
+++ b/frontends/nextjs/src/components/rendering/components/PropertyPanels.tsx
@@ -0,0 +1,80 @@
+import { ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger, Button } from '@/components/ui'
+import type { ComponentDefinition, ComponentInstance } from '@/lib/types/builder-types'
+import type { DropdownConfig } from '@/lib/database'
+import { Code, PaintBrush } from '@phosphor-icons/react'
+import { FieldTypes } from './FieldTypes'
+
+interface PropertyPanelsProps {
+ component: ComponentInstance
+ componentDef?: ComponentDefinition
+ dynamicDropdowns: DropdownConfig[]
+ onPropChange: (propName: string, value: any) => void
+ onCodeEdit: () => void
+ onOpenCssBuilder: (propName: string) => void
+}
+
+export function PropertyPanels({
+ component,
+ componentDef,
+ dynamicDropdowns,
+ onPropChange,
+ onCodeEdit,
+ onOpenCssBuilder,
+}: PropertyPanelsProps) {
+ return (
+
+
+
+
+ Props
+
+
+
+ Code
+
+
+
+
+
+
+ {componentDef?.propSchema?.length ? (
+ componentDef.propSchema.map(propDef => {
+ const dynamicDropdown =
+ propDef.type === 'dynamic-select'
+ ? dynamicDropdowns.find(d => d.name === propDef.dynamicSource)
+ : null
+
+ return (
+
onPropChange(propDef.name, value)}
+ dynamicDropdown={dynamicDropdown}
+ onOpenCssBuilder={() => onOpenCssBuilder(propDef.name)}
+ />
+ )
+ })
+ ) : (
+ This component has no configurable properties.
+ )}
+
+
+
+
+
+
+
+
+
+ Add custom JavaScript code for this component
+
+
+ Open Code Editor
+
+
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/components/rendering/components/RenderNode.tsx b/frontends/nextjs/src/components/rendering/components/RenderNode.tsx
new file mode 100644
index 000000000..a2ea3c18a
--- /dev/null
+++ b/frontends/nextjs/src/components/rendering/components/RenderNode.tsx
@@ -0,0 +1,188 @@
+import type React from 'react'
+import type { ComponentInstance } from '@/lib/types/builder-types'
+import { Button, Input, Textarea, Label, Badge, Card, Switch, Checkbox, Separator, Alert, Progress, Slider, Avatar, AvatarFallback, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
+import { IRCWebchatDeclarative } from '@/components/IRCWebchatDeclarative'
+import { NotificationSummaryCard } from '@/components/NotificationSummaryCard'
+import type { User } from '@/lib/level-types'
+import { getDeclarativeRenderer } from '@/lib/declarative-component-renderer'
+
+interface RenderNodeProps {
+ component: ComponentInstance
+ renderChildren: () => React.ReactNode
+ user?: User
+}
+
+export function RenderNode({ component, renderChildren, user }: RenderNodeProps) {
+ const { type, props } = component
+ const renderer = getDeclarativeRenderer()
+
+ if (renderer.hasComponentConfig(type)) {
+ if (type === 'IRCWebchat' && user) {
+ return (
+
+ )
+ }
+
+ return (
+
+ Declarative Component: {type}
+
+ This is a package-defined component
+
+
+ )
+ }
+
+ switch (type) {
+ case 'Container':
+ return (
+
+ {renderChildren()}
+
+ )
+
+ case 'Flex':
+ return (
+
+ {renderChildren()}
+
+ )
+
+ case 'Grid':
+ return (
+
+ {renderChildren()}
+
+ )
+
+ case 'Stack':
+ return (
+
+ {renderChildren()}
+
+ )
+
+ case 'Card':
+ return (
+
+ {renderChildren()}
+
+ )
+
+ case 'NotificationSummary':
+ return (
+
+ )
+
+ case 'Button':
+ return (
+
+ {props.children || 'Button'}
+
+ )
+
+ case 'Input':
+ return (
+
+ )
+
+ case 'Textarea':
+ return (
+
+ )
+
+ case 'Label':
+ return {props.children || 'Label'}
+
+ case 'Heading': {
+ const level = props.level || '1'
+ const className = props.className
+ const text = props.children || 'Heading'
+
+ if (level === '1') return {text}
+ if (level === '2') return {text}
+ if (level === '3') return {text}
+ if (level === '4') return {text}
+ return {text}
+ }
+
+ case 'Text':
+ return (
+
+ {props.children || 'Text'}
+
+ )
+
+ case 'Badge':
+ return (
+
+ {props.children || 'Badge'}
+
+ )
+
+ case 'Switch':
+ return
+
+ case 'Checkbox':
+ return
+
+ case 'Separator':
+ return
+
+ case 'Alert':
+ return (
+
+ {renderChildren()}
+
+ )
+
+ case 'Progress':
+ return
+
+ case 'Slider':
+ return
+
+ case 'Avatar':
+ return (
+
+ U
+
+ )
+
+ case 'Table':
+ return (
+
+
+
+ Column 1
+ Column 2
+
+
+
+
+ Data 1
+ Data 2
+
+
+
+ )
+
+ default:
+ return Unknown Component: {type}
+ }
+}
diff --git a/frontends/nextjs/src/components/schema/level4/Tabs.tsx b/frontends/nextjs/src/components/schema/level4/Tabs.tsx
new file mode 100644
index 000000000..71fe309c7
--- /dev/null
+++ b/frontends/nextjs/src/components/schema/level4/Tabs.tsx
@@ -0,0 +1,186 @@
+import { Button, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
+import { Input, Label } from '@/components/ui'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui'
+import { ValidationPanel } from '@/components/schema/level4/ValidationPanel'
+import type { FieldSchema, FieldType, ModelSchema } from '@/lib/schema-types'
+import { Plus, Trash } from '@phosphor-icons/react'
+
+interface SchemaTabsProps {
+ currentModel: ModelSchema
+ onUpdateModel: (updates: Partial) => 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,
+ }
+}
diff --git a/frontends/nextjs/src/components/ui/core.ts b/frontends/nextjs/src/components/ui/core.ts
new file mode 100644
index 000000000..c590037e1
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/core.ts
@@ -0,0 +1,87 @@
+// Core UI exports: foundational atoms and common building blocks
+export * from './atoms'
+
+// Common molecules
+export {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+ Tabs,
+ TabsList,
+ TabsTrigger,
+ TabsContent,
+ Alert,
+ AlertTitle,
+ AlertDescription,
+ type AlertVariant,
+ type AlertProps,
+ Accordion,
+ AccordionItem,
+ AccordionTrigger,
+ AccordionContent,
+ RadioGroup,
+ RadioGroupItem,
+ ToggleGroup,
+ ToggleGroupItem,
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+ NavItem,
+ type NavItemProps,
+ NavLink,
+ type NavLinkProps,
+ NavGroup,
+ type NavGroupProps,
+} from './molecules'
+
+// Navigation and command organisms
+export {
+ Navigation,
+ NavigationMenu,
+ NavigationList,
+ NavigationItem,
+ NavigationTrigger,
+ NavigationContent,
+ NavigationLink,
+ NavigationBrand,
+ NavigationSeparator,
+ NavigationSpacer,
+ NavigationMobileToggle,
+ useNavigationDropdown,
+ Sidebar,
+ SidebarHeader,
+ SidebarContent,
+ SidebarFooter,
+ SidebarNav,
+ SidebarSection,
+ SidebarSeparator,
+ SidebarToggle,
+ Command,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandSeparator,
+ CommandShortcut,
+ useCommandShortcut,
+} from './organisms'
+
+export type { SidebarItem, SidebarProps } from './organisms'
diff --git a/frontends/nextjs/src/components/ui/data.ts b/frontends/nextjs/src/components/ui/data.ts
new file mode 100644
index 000000000..db7736da2
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/data.ts
@@ -0,0 +1,28 @@
+// Data-centric UI exports
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableRow,
+ TableHead,
+ TableCell,
+ TableCaption,
+ Form,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ useFormField,
+ Pagination,
+ SimplePagination,
+ TablePagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationEllipsis,
+ PaginationPrevious,
+ PaginationNext,
+} from './organisms'
diff --git a/frontends/nextjs/src/components/ui/index.ts b/frontends/nextjs/src/components/ui/index.ts
index 7100dd9fa..bda704572 100644
--- a/frontends/nextjs/src/components/ui/index.ts
+++ b/frontends/nextjs/src/components/ui/index.ts
@@ -1,263 +1,9 @@
-// UI Components - Atomic Design Structure
-// This file re-exports all UI components organized by atomic design pattern
+// Central UI barrel exports
+export * from './core'
+export * from './overlay'
+export * from './data'
-// ============================================================================
-// ATOMS - Basic building blocks (buttons, inputs, labels, etc.)
-// ============================================================================
-export {
- // Button
- Button,
- type ButtonProps,
- type ButtonVariant,
- type ButtonSize,
- // Input
- Input,
- type InputProps,
- // Textarea
- Textarea,
- type TextareaProps,
- // Label
- Label,
- type LabelProps,
- // Checkbox
- Checkbox,
- type CheckboxProps,
- // Switch
- Switch,
- type SwitchProps,
- // Badge
- Badge,
- type BadgeProps,
- type BadgeVariant,
- // Avatar
- Avatar,
- AvatarImage,
- AvatarFallback,
- type AvatarProps,
- // Separator
- Separator,
- type SeparatorProps,
- // Skeleton
- Skeleton,
- type SkeletonProps,
- // Progress
- Progress,
- type ProgressProps,
- // Slider
- Slider,
- type SliderProps,
- // Toggle
- Toggle,
- type ToggleProps,
- type ToggleVariant,
- type ToggleSize,
- // ScrollArea
- ScrollArea,
- ScrollBar,
- type ScrollAreaProps,
- // NEW ATOMS (available in God Tier panel via @/components/ui)
- // Radio
- Radio,
- type RadioProps,
- // Icon
- Icon,
- type IconProps,
- type IconName,
- type IconSize,
- // Link
- Link,
- type LinkProps,
- // Text
- Text,
- type TextProps,
- type TextVariant,
- type TextWeight,
- type TextAlign,
- // TextArea (multi-line)
- TextArea,
- type TextAreaProps,
- // AtomSelect (simple dropdown)
- AtomSelect,
- type SelectProps as AtomSelectProps,
- type SelectOption,
-} from './atoms'
-
-// ============================================================================
-// MOLECULES - Simple groups of atoms (cards, dialogs, selects, etc.)
-// ============================================================================
-export {
- // Card
- Card,
- CardHeader,
- CardContent,
- CardFooter,
- CardTitle,
- CardDescription,
- // Dialog
- Dialog,
- DialogTrigger,
- DialogContent,
- DialogHeader,
- DialogFooter,
- DialogTitle,
- DialogDescription,
- DialogClose,
- DialogOverlay,
- DialogPortal,
- // Select
- Select,
- SelectTrigger,
- SelectContent,
- SelectItem,
- SelectGroup,
- SelectLabel,
- SelectSeparator,
- SelectValue,
- SelectScrollDownButton,
- SelectScrollUpButton,
- // Tabs
- Tabs,
- TabsList,
- TabsTrigger,
- TabsContent,
- // Tooltip
- Tooltip,
- TooltipTrigger,
- TooltipContent,
- TooltipProvider,
- SimpleTooltip,
- // Alert
- Alert,
- AlertTitle,
- AlertDescription,
- type AlertVariant,
- type AlertProps,
- // Accordion
- Accordion,
- AccordionItem,
- AccordionTrigger,
- AccordionContent,
- // DropdownMenu
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuLabel,
- DropdownMenuGroup,
- DropdownMenuCheckboxItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubTrigger,
- DropdownMenuSubContent,
- DropdownMenuShortcut,
- DropdownMenuPortal,
- // RadioGroup
- RadioGroup,
- RadioGroupItem,
- // Popover
- Popover,
- PopoverTrigger,
- PopoverContent,
- PopoverAnchor,
- // ToggleGroup
- ToggleGroup,
- ToggleGroupItem,
- // Breadcrumb
- Breadcrumb,
- BreadcrumbList,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbPage,
- BreadcrumbSeparator,
- BreadcrumbEllipsis,
-} from './molecules'
-
-// ============================================================================
-// ORGANISMS - Complex components (tables, forms, navigation, etc.)
-// ============================================================================
-export {
- // Table
- Table,
- TableHeader,
- TableBody,
- TableFooter,
- TableRow,
- TableHead,
- TableCell,
- TableCaption,
- // Form
- Form,
- FormField,
- FormItem,
- FormLabel,
- FormControl,
- FormDescription,
- FormMessage,
- useFormField,
- // Sheet (Drawer)
- Sheet,
- SheetTrigger,
- SheetContent,
- SheetHeader,
- SheetFooter,
- SheetTitle,
- SheetDescription,
- SheetClose,
- // Sidebar
- Sidebar,
- SidebarHeader,
- SidebarContent,
- SidebarFooter,
- SidebarNav,
- SidebarSection,
- SidebarSeparator,
- SidebarToggle,
- // Command Palette
- Command,
- CommandInput,
- CommandList,
- CommandEmpty,
- CommandGroup,
- CommandItem,
- CommandSeparator,
- CommandShortcut,
- useCommandShortcut,
- // Pagination
- Pagination,
- SimplePagination,
- TablePagination,
- PaginationContent,
- PaginationItem,
- PaginationLink,
- PaginationEllipsis,
- PaginationPrevious,
- PaginationNext,
- // Navigation
- Navigation,
- NavigationMenu,
- NavigationList,
- NavigationItem,
- NavigationTrigger,
- NavigationContent,
- NavigationLink,
- NavigationBrand,
- NavigationSeparator,
- NavigationSpacer,
- NavigationMobileToggle,
- useNavigationDropdown,
- // Alert Dialog
- AlertDialog,
- AlertDialogTrigger,
- AlertDialogContent,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogCancel,
- AlertDialogAction,
-} from './organisms'
-
-// Re-export types
-export type { SidebarItem, SidebarProps } from './organisms'
+// Preserve category-level entrypoints
+export * from './atoms'
+export * from './molecules'
+export * from './organisms'
diff --git a/frontends/nextjs/src/components/ui/molecules/index.ts b/frontends/nextjs/src/components/ui/molecules/index.ts
index 64b1d6336..3cb99d39e 100644
--- a/frontends/nextjs/src/components/ui/molecules/index.ts
+++ b/frontends/nextjs/src/components/ui/molecules/index.ts
@@ -14,6 +14,7 @@ export {
DialogTitle,
DialogTrigger,
} from './overlay/Dialog'
+export { DialogSection, DialogSections } from './overlay/Dialog/Sections'
export {
Select,
SelectContent,
@@ -47,6 +48,8 @@ export {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from './overlay/DropdownMenu'
+export { MenuItem, type MenuItemProps } from './overlay/DropdownMenu/MenuItem'
+export { useDropdownState } from './overlay/useDropdownState'
export { RadioGroup, RadioGroupItem } from './selection/RadioGroup'
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } from './overlay/Popover'
export { ToggleGroup, ToggleGroupItem } from './selection/ToggleGroup'
diff --git a/frontends/nextjs/src/components/ui/molecules/overlay/Dialog.tsx b/frontends/nextjs/src/components/ui/molecules/overlay/Dialog.tsx
index b1ba9106a..6404f85f0 100644
--- a/frontends/nextjs/src/components/ui/molecules/overlay/Dialog.tsx
+++ b/frontends/nextjs/src/components/ui/molecules/overlay/Dialog.tsx
@@ -1,17 +1,11 @@
'use client'
import { forwardRef, ReactNode } from 'react'
-import {
- Dialog as MuiDialog,
- DialogTitle as MuiDialogTitle,
- DialogContent as MuiDialogContent,
- DialogActions,
- DialogProps as MuiDialogProps,
- IconButton,
- Typography,
- Box,
-} from '@mui/material'
+import { Dialog as MuiDialog, DialogTitle as MuiDialogTitle, DialogProps as MuiDialogProps, IconButton, Typography, Box } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
+import { DialogContent, type DialogContentProps } from './dialog/Body'
+import { DialogFooter, type DialogFooterProps } from './dialog/Footer'
+import { DialogHeader, type DialogHeaderProps } from './dialog/Header'
export interface DialogProps extends Omit {
onOpenChange?: (open: boolean) => void
@@ -30,7 +24,7 @@ const Dialog = forwardRef(
{children}
)
- }
+ },
)
Dialog.displayName = 'Dialog'
@@ -47,7 +41,7 @@ const DialogTrigger = forwardRef(
{children}
)
- }
+ },
)
DialogTrigger.displayName = 'DialogTrigger'
@@ -83,79 +77,23 @@ const DialogClose = forwardRef(
)
- }
+ },
)
DialogClose.displayName = 'DialogClose'
-interface DialogContentProps {
- children: ReactNode
- className?: string
- onClose?: () => void
- showCloseButton?: boolean
-}
-
-const DialogContent = forwardRef(
- ({ children, showCloseButton = true, onClose, ...props }, ref) => {
- return (
-
- {showCloseButton && onClose && (
-
-
-
- )}
- {children}
-
- )
- }
-)
-DialogContent.displayName = 'DialogContent'
-
-interface DialogHeaderProps {
- children: ReactNode
- className?: string
-}
-
-const DialogHeader = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-DialogHeader.displayName = 'DialogHeader'
-
-interface DialogFooterProps {
- children: ReactNode
- className?: string
-}
-
-const DialogFooter = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-DialogFooter.displayName = 'DialogFooter'
-
interface DialogTitleProps {
children: ReactNode
className?: string
}
-const DialogTitle = forwardRef(
- ({ children, ...props }, ref) => {
- return {children}
- }
-)
+const DialogTitle = forwardRef((props, ref) => {
+ const { children, ...rest } = props
+ return (
+
+ {children}
+
+ )
+})
DialogTitle.displayName = 'DialogTitle'
interface DialogDescriptionProps {
@@ -163,15 +101,14 @@ interface DialogDescriptionProps {
className?: string
}
-const DialogDescription = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
+const DialogDescription = forwardRef((props, ref) => {
+ const { children, ...rest } = props
+ return (
+
+ {children}
+
+ )
+})
DialogDescription.displayName = 'DialogDescription'
export {
@@ -186,3 +123,5 @@ export {
DialogTitle,
DialogTrigger,
}
+
+export type { DialogContentProps, DialogFooterProps, DialogHeaderProps }
diff --git a/frontends/nextjs/src/components/ui/molecules/overlay/Dialog/Sections.tsx b/frontends/nextjs/src/components/ui/molecules/overlay/Dialog/Sections.tsx
new file mode 100644
index 000000000..5cb3694c1
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/molecules/overlay/Dialog/Sections.tsx
@@ -0,0 +1,34 @@
+'use client'
+
+import { ReactNode } from 'react'
+import { Box, Divider, Stack } from '@mui/material'
+
+interface DialogSectionsProps {
+ children: ReactNode
+ spacing?: number
+ divided?: boolean
+}
+
+const DialogSections = ({ children, spacing = 3, divided = false }: DialogSectionsProps) => {
+ return (
+ : undefined}>
+ {children}
+
+ )
+}
+DialogSections.displayName = 'DialogSections'
+
+interface DialogSectionProps {
+ children: ReactNode
+}
+
+const DialogSection = ({ children }: DialogSectionProps) => {
+ return (
+
+ {children}
+
+ )
+}
+DialogSection.displayName = 'DialogSection'
+
+export { DialogSection, DialogSections }
diff --git a/frontends/nextjs/src/components/ui/molecules/overlay/DropdownMenu/MenuItem.tsx b/frontends/nextjs/src/components/ui/molecules/overlay/DropdownMenu/MenuItem.tsx
new file mode 100644
index 000000000..d97c202a9
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/molecules/overlay/DropdownMenu/MenuItem.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
+import { Box, Typography } from '@mui/material'
+
+import { DropdownMenuItem } from '../DropdownMenu'
+
+type DropdownMenuItemComponentProps = ComponentPropsWithoutRef
+
+interface MenuItemProps extends DropdownMenuItemComponentProps {
+ description?: ReactNode
+ detail?: ReactNode
+}
+
+const MenuItem = forwardRef(
+ ({ children, description, detail, ...props }, ref) => {
+ return (
+
+
+
+ {children}
+ {description && (
+
+ {description}
+
+ )}
+
+ {detail && (
+
+ {detail}
+
+ )}
+
+
+ )
+ }
+)
+MenuItem.displayName = 'MenuItem'
+
+export type { MenuItemProps }
+export { MenuItem }
diff --git a/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Body.tsx b/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Body.tsx
new file mode 100644
index 000000000..4110abf26
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Body.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { forwardRef, ReactNode } from 'react'
+import { DialogContent as MuiDialogContent, IconButton } from '@mui/material'
+import CloseIcon from '@mui/icons-material/Close'
+
+export interface DialogBodyProps {
+ children: ReactNode
+ className?: string
+ onClose?: () => void
+ showCloseButton?: boolean
+}
+
+const DialogBody = forwardRef(
+ ({ children, showCloseButton = true, onClose, ...props }, ref) => {
+ return (
+
+ {showCloseButton && onClose && (
+
+
+
+ )}
+ {children}
+
+ )
+ },
+)
+DialogBody.displayName = 'DialogBody'
+
+export type DialogContentProps = DialogBodyProps
+const DialogContent = DialogBody
+
+export { DialogBody, DialogContent }
diff --git a/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Footer.tsx b/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Footer.tsx
new file mode 100644
index 000000000..b68366c9b
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Footer.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { forwardRef, ReactNode } from 'react'
+import { DialogActions as MuiDialogActions } from '@mui/material'
+
+export interface DialogFooterProps {
+ children: ReactNode
+ className?: string
+}
+
+const DialogFooter = forwardRef((props, ref) => {
+ const { children, ...rest } = props
+ return (
+
+ {children}
+
+ )
+})
+DialogFooter.displayName = 'DialogFooter'
+
+export { DialogFooter }
diff --git a/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Header.tsx b/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Header.tsx
new file mode 100644
index 000000000..36c0f1b75
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/molecules/overlay/dialog/Header.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { forwardRef, ReactNode } from 'react'
+import { Box } from '@mui/material'
+
+export interface DialogHeaderProps {
+ children: ReactNode
+ className?: string
+}
+
+const DialogHeader = forwardRef((props, ref) => {
+ const { children, ...rest } = props
+ return (
+
+ {children}
+
+ )
+})
+DialogHeader.displayName = 'DialogHeader'
+
+export { DialogHeader }
diff --git a/frontends/nextjs/src/components/ui/molecules/overlay/useDropdownState.ts b/frontends/nextjs/src/components/ui/molecules/overlay/useDropdownState.ts
new file mode 100644
index 000000000..f769e857a
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/molecules/overlay/useDropdownState.ts
@@ -0,0 +1,21 @@
+'use client'
+
+import { useCallback, useState, type MouseEvent } from 'react'
+
+function useDropdownState() {
+ const [anchorEl, setAnchorEl] = useState(null)
+
+ const open = Boolean(anchorEl)
+
+ const handleOpen = useCallback((event: MouseEvent) => {
+ setAnchorEl(event.currentTarget)
+ }, [])
+
+ const handleClose = useCallback(() => {
+ setAnchorEl(null)
+ }, [])
+
+ return { anchorEl, open, setAnchorEl, handleOpen, handleClose }
+}
+
+export { useDropdownState }
diff --git a/frontends/nextjs/src/components/ui/navigation/dropdown-menu.ts b/frontends/nextjs/src/components/ui/navigation/dropdown-menu.ts
index 99a3d8e99..4366baf84 100644
--- a/frontends/nextjs/src/components/ui/navigation/dropdown-menu.ts
+++ b/frontends/nextjs/src/components/ui/navigation/dropdown-menu.ts
@@ -15,4 +15,7 @@ export {
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
+ MenuItem,
+ type MenuItemProps,
+ useDropdownState,
} from './molecules/DropdownMenu'
diff --git a/frontends/nextjs/src/components/ui/navigation/molecules/DropdownMenu/index.ts b/frontends/nextjs/src/components/ui/navigation/molecules/DropdownMenu/index.ts
new file mode 100644
index 000000000..c64b6e7bd
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/navigation/molecules/DropdownMenu/index.ts
@@ -0,0 +1,19 @@
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+} from '../../../molecules/overlay/DropdownMenu'
+export { MenuItem, type MenuItemProps } from '../../../molecules/overlay/DropdownMenu/MenuItem'
+export { useDropdownState } from '../../../molecules/overlay/useDropdownState'
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/AlertDialog.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/AlertDialog.tsx
index 270072d29..e2a904553 100644
--- a/frontends/nextjs/src/components/ui/organisms/dialogs/AlertDialog.tsx
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/AlertDialog.tsx
@@ -1,24 +1,16 @@
-// TODO: Split this file (268 LOC) into smaller organisms (<150 LOC each)
'use client'
import { forwardRef, ReactNode } from 'react'
-import {
- Dialog,
- DialogTitle,
- DialogContent,
- DialogContentText,
- DialogActions,
- Button,
- IconButton,
- Typography,
-} from '@mui/material'
-import CloseIcon from '@mui/icons-material/Close'
-import WarningAmberIcon from '@mui/icons-material/WarningAmber'
-import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
-import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
-import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
+import { Dialog } from '@mui/material'
+
+import {
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from './alert/Content'
+import { AlertDialogAction, AlertDialogCancel, AlertDialogFooter } from './alert/Actions'
-// AlertDialog Root
interface AlertDialogProps {
open: boolean
onClose?: () => void
@@ -52,7 +44,6 @@ const AlertDialog = forwardRef(
)
AlertDialog.displayName = 'AlertDialog'
-// AlertDialogTrigger - element that opens dialog
interface AlertDialogTriggerProps {
children: ReactNode
onClick?: () => void
@@ -70,200 +61,14 @@ const AlertDialogTrigger = forwardRef(
)
AlertDialogTrigger.displayName = 'AlertDialogTrigger'
-// AlertDialogContent - wrapper for dialog content
-interface AlertDialogContentProps {
- children: ReactNode
- showCloseButton?: boolean
- onClose?: () => void
- className?: string
-}
-
-const AlertDialogContent = forwardRef(
- ({ children, showCloseButton = false, onClose, className, ...props }, ref) => {
- return (
- <>
- {showCloseButton && onClose && (
-
-
-
- )}
- {children}
- >
- )
- }
-)
-AlertDialogContent.displayName = 'AlertDialogContent'
-
-// AlertDialogHeader
-interface AlertDialogHeaderProps {
- children: ReactNode
- icon?: 'warning' | 'error' | 'info' | 'success' | ReactNode
-}
-
-const AlertDialogHeader = forwardRef(
- ({ children, icon, ...props }, ref) => {
- const getIcon = () => {
- if (!icon) return null
- if (typeof icon !== 'string') return icon
-
- const iconMap = {
- warning: ,
- error: ,
- info: ,
- success: ,
- }
- return iconMap[icon]
- }
-
- const iconElement = getIcon()
-
- return (
-
- {iconElement}
- {children}
-
- )
- }
-)
-AlertDialogHeader.displayName = 'AlertDialogHeader'
-
-// AlertDialogTitle
-interface AlertDialogTitleProps {
- children: ReactNode
- className?: string
-}
-
-const AlertDialogTitle = forwardRef(
- ({ children, className, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-AlertDialogTitle.displayName = 'AlertDialogTitle'
-
-// AlertDialogDescription
-interface AlertDialogDescriptionProps {
- children: ReactNode
- className?: string
-}
-
-const AlertDialogDescription = forwardRef(
- ({ children, className, ...props }, ref) => {
- return (
-
-
- {children}
-
-
- )
- }
-)
-AlertDialogDescription.displayName = 'AlertDialogDescription'
-
-// AlertDialogFooter
-interface AlertDialogFooterProps {
- children: ReactNode
-}
-
-const AlertDialogFooter = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-AlertDialogFooter.displayName = 'AlertDialogFooter'
-
-// AlertDialogCancel
-interface AlertDialogCancelProps {
- children?: ReactNode
- onClick?: () => void
- className?: string
-}
-
-const AlertDialogCancel = forwardRef(
- ({ children = 'Cancel', onClick, className, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-AlertDialogCancel.displayName = 'AlertDialogCancel'
-
-// AlertDialogAction
-interface AlertDialogActionProps {
- children?: ReactNode
- onClick?: () => void
- color?: 'primary' | 'error' | 'warning' | 'success' | 'info'
- variant?: 'text' | 'outlined' | 'contained'
- autoFocus?: boolean
- className?: string
-}
-
-const AlertDialogAction = forwardRef(
- ({ children = 'Confirm', onClick, color = 'primary', variant = 'contained', autoFocus = true, className, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-AlertDialogAction.displayName = 'AlertDialogAction'
-
export {
AlertDialog,
- AlertDialogTrigger,
+ AlertDialogAction,
+ AlertDialogCancel,
AlertDialogContent,
- AlertDialogHeader,
- AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
- AlertDialogCancel,
- AlertDialogAction,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
}
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/Command.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/Command.tsx
index 7005d4e05..a812c86f2 100644
--- a/frontends/nextjs/src/components/ui/organisms/dialogs/Command.tsx
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/Command.tsx
@@ -1,3 +1,6 @@
'use client'
export * from './command'
+export * from './Command/Palette'
+export * from './Command/Results'
+export * from './Command/useCommandState'
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/Command/Palette.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/Command/Palette.tsx
new file mode 100644
index 000000000..d275cb4ab
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/Command/Palette.tsx
@@ -0,0 +1,38 @@
+'use client'
+
+import { ReactNode } from 'react'
+
+import { CommandDialog } from '../command'
+
+interface CommandPaletteProps {
+ children: ReactNode
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ placeholder?: string
+ search: string
+ onSearchChange: (value: string) => void
+}
+
+const CommandPalette = ({
+ children,
+ open,
+ onOpenChange,
+ placeholder = 'Type a command or search...',
+ search,
+ onSearchChange,
+}: CommandPaletteProps) => {
+ return (
+ onOpenChange(false)}>
+
+ {children}
+
+ )
+}
+
+export { CommandPalette }
+export type { CommandPaletteProps }
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/Command/Results.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/Command/Results.tsx
new file mode 100644
index 000000000..4e72f24b6
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/Command/Results.tsx
@@ -0,0 +1,56 @@
+'use client'
+
+import { ReactNode } from 'react'
+import { Box, Typography } from '@mui/material'
+
+import { CommandDialog } from '../command'
+import type { CommandGroup, CommandItem } from '../command/command.types'
+
+interface CommandResultsProps {
+ groups: CommandGroup[]
+ emptyMessage?: ReactNode
+ onSelect?: (item: CommandItem) => void
+}
+
+const CommandResults = ({ groups, emptyMessage = 'No results found.', onSelect }: CommandResultsProps) => {
+ const hasResults = groups.some((group) => group.items.length > 0)
+
+ return (
+
+ {hasResults ? (
+ groups.map((group, index) => (
+
+ {group.items.map((item) => (
+ {
+ item.onSelect?.()
+ onSelect?.(item)
+ }}
+ >
+
+
+ {item.label}
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+
+ ))}
+
+ ))
+ ) : (
+ {emptyMessage}
+ )}
+
+ )
+}
+
+export { CommandResults }
+export type { CommandResultsProps }
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/Command/useCommandState.ts b/frontends/nextjs/src/components/ui/organisms/dialogs/Command/useCommandState.ts
new file mode 100644
index 000000000..796bfa395
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/Command/useCommandState.ts
@@ -0,0 +1,59 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+
+import type { CommandGroup, CommandItem } from '../command/command.types'
+
+interface UseCommandStateOptions {
+ groups: CommandGroup[]
+ defaultOpen?: boolean
+}
+
+interface UseCommandStateResult {
+ filteredGroups: CommandGroup[]
+ open: boolean
+ search: string
+ setOpen: (open: boolean) => void
+ setSearch: (value: string) => void
+ close: () => void
+}
+
+const filterItems = (items: CommandItem[], query: string) => {
+ if (!query) return items
+ const lowered = query.toLowerCase()
+ return items.filter((item) => {
+ const labelMatch = item.label.toLowerCase().includes(lowered)
+ const keywordsMatch = item.keywords?.some((keyword) => keyword.toLowerCase().includes(lowered))
+ const descriptionMatch = item.description?.toLowerCase().includes(lowered)
+
+ return labelMatch || keywordsMatch || descriptionMatch
+ })
+}
+
+const useCommandState = ({ groups, defaultOpen = false }: UseCommandStateOptions): UseCommandStateResult => {
+ const [open, setOpen] = useState(defaultOpen)
+ const [search, setSearch] = useState('')
+
+ const filteredGroups = useMemo(() => {
+ if (!search) return groups
+
+ return groups
+ .map((group) => ({
+ ...group,
+ items: filterItems(group.items, search),
+ }))
+ .filter((group) => group.items.length > 0)
+ }, [groups, search])
+
+ return {
+ filteredGroups,
+ open,
+ search,
+ setOpen,
+ setSearch,
+ close: () => setOpen(false),
+ }
+}
+
+export { useCommandState }
+export type { UseCommandStateOptions, UseCommandStateResult }
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet.tsx
index 0ea3c093e..cf4bdeba3 100644
--- a/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet.tsx
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet.tsx
@@ -1,255 +1,4 @@
-// TODO: Split this file (254 LOC) into smaller organisms (<150 LOC each)
'use client'
-import { forwardRef, ReactNode } from 'react'
-import {
- Drawer,
- DrawerProps,
- Box,
- IconButton,
- Typography,
-} from '@mui/material'
-import CloseIcon from '@mui/icons-material/Close'
-
-// Sheet (Drawer) Root Component
-interface SheetProps extends Omit {
- children: ReactNode
- side?: 'left' | 'right' | 'top' | 'bottom'
- onOpenChange?: (open: boolean) => void
-}
-
-const Sheet = forwardRef(
- ({ children, side = 'right', open, onClose, onOpenChange, ...props }, ref) => {
- const handleClose = (_event: React.SyntheticEvent | object, reason: 'backdropClick' | 'escapeKeyDown') => {
- if (typeof onClose === 'function') {
- onClose(_event, reason)
- }
- onOpenChange?.(false)
- }
- return (
-
- {children}
-
- )
- }
-)
-Sheet.displayName = 'Sheet'
-
-// SheetTrigger - returns children with onClick handler
-interface SheetTriggerProps {
- children: ReactNode
- onClick?: () => void
- asChild?: boolean
-}
-
-const SheetTrigger = forwardRef(
- ({ children, onClick, asChild, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-SheetTrigger.displayName = 'SheetTrigger'
-
-// SheetContent
-interface SheetContentProps {
- children: ReactNode
- side?: 'left' | 'right' | 'top' | 'bottom'
- onClose?: () => void
- showCloseButton?: boolean
- className?: string
-}
-
-const SheetContent = forwardRef(
- ({ children, side = 'right', onClose, showCloseButton = true, ...props }, ref) => {
- const isHorizontal = side === 'left' || side === 'right'
-
- return (
-
- {showCloseButton && (
-
-
-
- )}
- {children}
-
- )
- }
-)
-SheetContent.displayName = 'SheetContent'
-
-// SheetHeader
-interface SheetHeaderProps {
- children: ReactNode
- className?: string
-}
-
-const SheetHeader = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-SheetHeader.displayName = 'SheetHeader'
-
-// SheetFooter
-interface SheetFooterProps {
- children: ReactNode
- className?: string
-}
-
-const SheetFooter = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-SheetFooter.displayName = 'SheetFooter'
-
-// SheetTitle
-interface SheetTitleProps {
- children: ReactNode
- className?: string
-}
-
-const SheetTitle = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-SheetTitle.displayName = 'SheetTitle'
-
-// SheetDescription
-interface SheetDescriptionProps {
- children: ReactNode
- className?: string
-}
-
-const SheetDescription = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-SheetDescription.displayName = 'SheetDescription'
-
-// SheetClose - button to close sheet
-interface SheetCloseProps {
- children: ReactNode
- onClick?: () => void
- asChild?: boolean
-}
-
-const SheetClose = forwardRef(
- ({ children, onClick, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
-)
-SheetClose.displayName = 'SheetClose'
-
-export {
- Sheet,
- SheetTrigger,
- SheetContent,
- SheetHeader,
- SheetFooter,
- SheetTitle,
- SheetDescription,
- SheetClose,
-}
+export { Sheet, SheetClose, SheetContent, SheetTrigger } from './Sheet/Drawer'
+export { SheetDescription, SheetFooter, SheetHeader, SheetTitle } from './Sheet/Header'
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet/Drawer.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet/Drawer.tsx
new file mode 100644
index 000000000..0f2f83f09
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet/Drawer.tsx
@@ -0,0 +1,129 @@
+'use client'
+
+import { forwardRef, ReactNode, SyntheticEvent } from 'react'
+import { Box, Drawer, DrawerProps, IconButton } from '@mui/material'
+import CloseIcon from '@mui/icons-material/Close'
+
+interface SheetProps extends Omit {
+ children: ReactNode
+ side?: 'left' | 'right' | 'top' | 'bottom'
+ onOpenChange?: (open: boolean) => void
+}
+
+const Sheet = forwardRef(
+ ({ children, side = 'right', open, onClose, onOpenChange, ...props }, ref) => {
+ const handleClose = (_event: SyntheticEvent | object, reason: 'backdropClick' | 'escapeKeyDown') => {
+ if (typeof onClose === 'function') {
+ onClose(_event, reason)
+ }
+ onOpenChange?.(false)
+ }
+ return (
+
+ {children}
+
+ )
+ }
+)
+Sheet.displayName = 'Sheet'
+
+interface SheetTriggerProps {
+ children: ReactNode
+ onClick?: () => void
+ asChild?: boolean
+}
+
+const SheetTrigger = forwardRef(
+ ({ children, onClick, asChild, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+SheetTrigger.displayName = 'SheetTrigger'
+
+interface SheetContentProps {
+ children: ReactNode
+ side?: 'left' | 'right' | 'top' | 'bottom'
+ onClose?: () => void
+ showCloseButton?: boolean
+ className?: string
+}
+
+const SheetContent = forwardRef(
+ ({ children, side = 'right', onClose, showCloseButton = true, ...props }, ref) => {
+ const isHorizontal = side === 'left' || side === 'right'
+
+ return (
+
+ {showCloseButton && (
+
+
+
+ )}
+ {children}
+
+ )
+ }
+)
+SheetContent.displayName = 'SheetContent'
+
+interface SheetCloseProps {
+ children: ReactNode
+ onClick?: () => void
+ asChild?: boolean
+}
+
+const SheetClose = forwardRef(
+ ({ children, onClick, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+SheetClose.displayName = 'SheetClose'
+
+export { Sheet, SheetClose, SheetContent, SheetTrigger }
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet/Header.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet/Header.tsx
new file mode 100644
index 000000000..09d954bd5
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/Sheet/Header.tsx
@@ -0,0 +1,105 @@
+'use client'
+
+import { forwardRef, ReactNode } from 'react'
+import { Box, Typography } from '@mui/material'
+
+interface SheetHeaderProps {
+ children: ReactNode
+ className?: string
+}
+
+const SheetHeader = forwardRef(
+ ({ children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+SheetHeader.displayName = 'SheetHeader'
+
+interface SheetFooterProps {
+ children: ReactNode
+ className?: string
+}
+
+const SheetFooter = forwardRef(
+ ({ children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+SheetFooter.displayName = 'SheetFooter'
+
+interface SheetTitleProps {
+ children: ReactNode
+ className?: string
+}
+
+const SheetTitle = forwardRef(
+ ({ children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+SheetTitle.displayName = 'SheetTitle'
+
+interface SheetDescriptionProps {
+ children: ReactNode
+ className?: string
+}
+
+const SheetDescription = forwardRef(
+ ({ children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+SheetDescription.displayName = 'SheetDescription'
+
+export { SheetDescription, SheetFooter, SheetHeader, SheetTitle }
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/alert/Actions.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/alert/Actions.tsx
new file mode 100644
index 000000000..6670952cb
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/alert/Actions.tsx
@@ -0,0 +1,69 @@
+'use client'
+
+import { forwardRef, ReactNode } from 'react'
+import { Button, DialogActions } from '@mui/material'
+
+interface AlertDialogFooterProps {
+ children: ReactNode
+}
+
+const AlertDialogFooter = forwardRef(
+ ({ children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+AlertDialogFooter.displayName = 'AlertDialogFooter'
+
+interface AlertDialogCancelProps {
+ children?: ReactNode
+ onClick?: () => void
+ className?: string
+}
+
+const AlertDialogCancel = forwardRef(
+ ({ children = 'Cancel', onClick, className, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+AlertDialogCancel.displayName = 'AlertDialogCancel'
+
+interface AlertDialogActionProps {
+ children?: ReactNode
+ onClick?: () => void
+ color?: 'primary' | 'error' | 'warning' | 'success' | 'info'
+ variant?: 'text' | 'outlined' | 'contained'
+ autoFocus?: boolean
+ className?: string
+}
+
+const AlertDialogAction = forwardRef(
+ (
+ { children = 'Confirm', onClick, color = 'primary', variant = 'contained', autoFocus = true, className, ...props },
+ ref
+ ) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+AlertDialogAction.displayName = 'AlertDialogAction'
+
+export { AlertDialogAction, AlertDialogCancel, AlertDialogFooter }
diff --git a/frontends/nextjs/src/components/ui/organisms/dialogs/alert/Content.tsx b/frontends/nextjs/src/components/ui/organisms/dialogs/alert/Content.tsx
new file mode 100644
index 000000000..ca1261344
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/dialogs/alert/Content.tsx
@@ -0,0 +1,145 @@
+'use client'
+
+import { forwardRef, ReactNode } from 'react'
+import {
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ IconButton,
+ Typography,
+} from '@mui/material'
+import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
+import CloseIcon from '@mui/icons-material/Close'
+import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
+import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
+import WarningAmberIcon from '@mui/icons-material/WarningAmber'
+
+interface AlertDialogContentProps {
+ children: ReactNode
+ showCloseButton?: boolean
+ onClose?: () => void
+ className?: string
+}
+
+const AlertDialogContent = ({
+ children,
+ showCloseButton = false,
+ onClose,
+ className,
+ ...props
+}: AlertDialogContentProps) => {
+ return (
+ <>
+ {showCloseButton && onClose && (
+
+
+
+ )}
+ {children}
+ >
+ )
+}
+AlertDialogContent.displayName = 'AlertDialogContent'
+
+interface AlertDialogHeaderProps {
+ children: ReactNode
+ icon?: 'warning' | 'error' | 'info' | 'success' | ReactNode
+}
+
+const AlertDialogHeader = forwardRef(
+ ({ children, icon, ...props }, ref) => {
+ const getIcon = () => {
+ if (!icon) return null
+ if (typeof icon !== 'string') return icon
+
+ const iconMap = {
+ warning: ,
+ error: ,
+ info: ,
+ success: ,
+ }
+ return iconMap[icon]
+ }
+
+ const iconElement = getIcon()
+
+ return (
+
+ {iconElement}
+ {children}
+
+ )
+ }
+)
+AlertDialogHeader.displayName = 'AlertDialogHeader'
+
+interface AlertDialogTitleProps {
+ children: ReactNode
+ className?: string
+}
+
+const AlertDialogTitle = forwardRef(
+ ({ children, className, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+AlertDialogTitle.displayName = 'AlertDialogTitle'
+
+interface AlertDialogDescriptionProps {
+ children: ReactNode
+ className?: string
+}
+
+const AlertDialogDescription = forwardRef(
+ ({ children, className, ...props }, ref) => {
+ return (
+
+
+ {children}
+
+
+ )
+ }
+)
+AlertDialogDescription.displayName = 'AlertDialogDescription'
+
+export {
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+}
diff --git a/frontends/nextjs/src/components/ui/organisms/navigation/MenuItemList.tsx b/frontends/nextjs/src/components/ui/organisms/navigation/MenuItemList.tsx
new file mode 100644
index 000000000..29b5007b5
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/navigation/MenuItemList.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import { forwardRef, ReactNode, useState } from 'react'
+import {
+ Box,
+ Collapse,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+} from '@mui/material'
+import ExpandLess from '@mui/icons-material/ExpandLess'
+import ExpandMore from '@mui/icons-material/ExpandMore'
+
+interface SidebarItem {
+ label: string
+ icon?: ReactNode
+ href?: string
+ onClick?: () => void
+ children?: SidebarItem[]
+ badge?: ReactNode
+ disabled?: boolean
+}
+
+interface MenuItemListProps {
+ items: SidebarItem[]
+ dense?: boolean
+}
+
+const MenuItemList = forwardRef(
+ ({ items, dense = false, ...props }, ref) => {
+ const [openItems, setOpenItems] = useState>(new Set())
+
+ const toggleItem = (label: string) => {
+ setOpenItems(prev => {
+ const next = new Set(prev)
+ if (next.has(label)) {
+ next.delete(label)
+ } else {
+ next.add(label)
+ }
+ return next
+ })
+ }
+
+ const renderItem = (item: SidebarItem, depth: number = 0) => {
+ const hasChildren = item.children && item.children.length > 0
+ const isOpen = openItems.has(item.label)
+
+ return (
+
+
+ {
+ if (hasChildren) {
+ toggleItem(item.label)
+ } else if (item.onClick) {
+ item.onClick()
+ }
+ }}
+ disabled={item.disabled}
+ sx={{
+ pl: 2 + depth * 2,
+ minHeight: dense ? 40 : 48,
+ }}
+ >
+ {item.icon && (
+
+ {item.icon}
+
+ )}
+
+ {item.badge && (
+
+ {item.badge}
+
+ )}
+ {hasChildren && (isOpen ? : )}
+
+
+ {hasChildren && (
+
+
+ {item.children!.map(child => renderItem(child, depth + 1))}
+
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {items.map(item => renderItem(item))}
+
+ )
+ }
+)
+MenuItemList.displayName = 'MenuItemList'
+
+export { MenuItemList }
+export type { MenuItemListProps, SidebarItem }
diff --git a/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar.tsx b/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar.tsx
index bd9bdb0a8..760030fbe 100644
--- a/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar.tsx
+++ b/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar.tsx
@@ -1,37 +1,18 @@
-// TODO: Split this file (309 LOC) into smaller organisms (<150 LOC each)
'use client'
-import { forwardRef, ReactNode, useState } from 'react'
+import { forwardRef, ReactNode } from 'react'
import {
Box,
Drawer,
- List,
- ListItem,
- ListItemButton,
- ListItemIcon,
- ListItemText,
- Collapse,
- Divider,
IconButton,
- useTheme,
useMediaQuery,
- Typography,
+ useTheme,
} from '@mui/material'
import MenuIcon from '@mui/icons-material/Menu'
-import ExpandLess from '@mui/icons-material/ExpandLess'
-import ExpandMore from '@mui/icons-material/ExpandMore'
-import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
-// Types
-interface SidebarItem {
- label: string
- icon?: ReactNode
- href?: string
- onClick?: () => void
- children?: SidebarItem[]
- badge?: ReactNode
- disabled?: boolean
-}
+import { MenuItemList, type MenuItemListProps, type SidebarItem } from './MenuItemList'
+import { SidebarHeader, type SidebarHeaderProps } from './Sidebar/Header'
+import { SidebarSection, SidebarSeparator } from './Sidebar/NavSections'
interface SidebarProps {
children?: ReactNode
@@ -42,7 +23,6 @@ interface SidebarProps {
anchor?: 'left' | 'right'
}
-// Sidebar Root
const Sidebar = forwardRef(
({ children, open = true, onClose, width = 280, variant = 'permanent', anchor = 'left', ...props }, ref) => {
const theme = useTheme()
@@ -76,227 +56,66 @@ const Sidebar = forwardRef(
)
Sidebar.displayName = 'Sidebar'
-// SidebarHeader
-interface SidebarHeaderProps {
- children?: ReactNode
- onClose?: () => void
- showCloseButton?: boolean
-}
-
-const SidebarHeader = forwardRef(
- ({ children, onClose, showCloseButton = false, ...props }, ref) => {
- return (
-
- {children}
- {showCloseButton && onClose && (
-
-
-
- )}
-
- )
- }
-)
-SidebarHeader.displayName = 'SidebarHeader'
-
-// SidebarContent
interface SidebarContentProps {
children: ReactNode
}
const SidebarContent = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
+ ({ children, ...props }, ref) => (
+
+ {children}
+
+ )
)
SidebarContent.displayName = 'SidebarContent'
-// SidebarFooter
interface SidebarFooterProps {
children: ReactNode
}
const SidebarFooter = forwardRef(
- ({ children, ...props }, ref) => {
- return (
-
- {children}
-
- )
- }
+ ({ children, ...props }, ref) => (
+
+ {children}
+
+ )
)
SidebarFooter.displayName = 'SidebarFooter'
-// SidebarNav
-interface SidebarNavProps {
- items: SidebarItem[]
- dense?: boolean
-}
-
-const SidebarNav = forwardRef(
- ({ items, dense = false, ...props }, ref) => {
- const [openItems, setOpenItems] = useState>(new Set())
-
- const toggleItem = (label: string) => {
- setOpenItems(prev => {
- const next = new Set(prev)
- if (next.has(label)) {
- next.delete(label)
- } else {
- next.add(label)
- }
- return next
- })
- }
-
- const renderItem = (item: SidebarItem, depth: number = 0) => {
- const hasChildren = item.children && item.children.length > 0
- const isOpen = openItems.has(item.label)
-
- return (
-
-
- {
- if (hasChildren) {
- toggleItem(item.label)
- } else if (item.onClick) {
- item.onClick()
- }
- }}
- disabled={item.disabled}
- sx={{
- pl: 2 + depth * 2,
- minHeight: dense ? 40 : 48,
- }}
- >
- {item.icon && (
-
- {item.icon}
-
- )}
-
- {item.badge && (
-
- {item.badge}
-
- )}
- {hasChildren && (isOpen ? : )}
-
-
- {hasChildren && (
-
-
- {item.children!.map(child => renderItem(child, depth + 1))}
-
-
- )}
-
- )
- }
-
- return (
-
- {items.map(item => renderItem(item))}
-
- )
- }
-)
-SidebarNav.displayName = 'SidebarNav'
-
-// SidebarSection
-interface SidebarSectionProps {
- title?: string
- children: ReactNode
-}
-
-const SidebarSection = forwardRef(
- ({ title, children, ...props }, ref) => {
- return (
-
- {title && (
-
- {title}
-
- )}
- {children}
-
- )
- }
-)
-SidebarSection.displayName = 'SidebarSection'
-
-// SidebarSeparator
-const SidebarSeparator = forwardRef>(
- (props, ref) => {
- return
- }
-)
-SidebarSeparator.displayName = 'SidebarSeparator'
-
-// SidebarToggle - trigger to open sidebar on mobile
interface SidebarToggleProps {
onClick: () => void
}
const SidebarToggle = forwardRef(
- ({ onClick, ...props }, ref) => {
- return (
-
-
-
- )
- }
+ ({ onClick, ...props }, ref) => (
+
+
+
+ )
)
SidebarToggle.displayName = 'SidebarToggle'
+const SidebarNav = forwardRef((props, ref) => (
+
+))
+SidebarNav.displayName = 'SidebarNav'
+
export {
Sidebar,
SidebarHeader,
@@ -307,4 +126,4 @@ export {
SidebarSeparator,
SidebarToggle,
}
-export type { SidebarItem, SidebarProps }
+export type { SidebarItem, SidebarProps, SidebarHeaderProps }
diff --git a/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar/Header.tsx b/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar/Header.tsx
new file mode 100644
index 000000000..cfe0fe23d
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar/Header.tsx
@@ -0,0 +1,38 @@
+import { forwardRef, ReactNode } from 'react'
+import { Box, IconButton } from '@mui/material'
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
+
+interface SidebarHeaderProps {
+ children?: ReactNode
+ onClose?: () => void
+ showCloseButton?: boolean
+}
+
+const SidebarHeader = forwardRef(
+ ({ children, onClose, showCloseButton = false, ...props }, ref) => {
+ return (
+
+ {children}
+ {showCloseButton && onClose && (
+
+
+
+ )}
+
+ )
+ }
+)
+SidebarHeader.displayName = 'SidebarHeader'
+
+export { SidebarHeader }
+export type { SidebarHeaderProps }
diff --git a/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar/NavSections.tsx b/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar/NavSections.tsx
new file mode 100644
index 000000000..8e05ba355
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/organisms/navigation/Sidebar/NavSections.tsx
@@ -0,0 +1,44 @@
+import { forwardRef, ReactNode } from 'react'
+import { Box, Divider, Typography } from '@mui/material'
+
+interface SidebarSectionProps {
+ title?: string
+ children: ReactNode
+}
+
+const SidebarSection = forwardRef(
+ ({ title, children, ...props }, ref) => {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+ {children}
+
+ )
+ }
+)
+SidebarSection.displayName = 'SidebarSection'
+
+const SidebarSeparator = forwardRef>(
+ (props, ref) => {
+ return
+ }
+)
+SidebarSeparator.displayName = 'SidebarSeparator'
+
+export { SidebarSection, SidebarSeparator }
+export type { SidebarSectionProps }
diff --git a/frontends/nextjs/src/components/ui/overlay.ts b/frontends/nextjs/src/components/ui/overlay.ts
new file mode 100644
index 000000000..1eb7af331
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/overlay.ts
@@ -0,0 +1,57 @@
+// Overlay-focused UI exports
+export {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+ DialogClose,
+ DialogOverlay,
+ DialogPortal,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+ TooltipProvider,
+ SimpleTooltip,
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuLabel,
+ DropdownMenuGroup,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+ DropdownMenuShortcut,
+ DropdownMenuPortal,
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+ PopoverAnchor,
+} from './molecules'
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+ SheetClose,
+ AlertDialog,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+ AlertDialogAction,
+} from './organisms'
diff --git a/frontends/nextjs/src/components/ui/sonner.tsx b/frontends/nextjs/src/components/ui/sonner.tsx
index 746a8da84..9ee4c8d07 100644
--- a/frontends/nextjs/src/components/ui/sonner.tsx
+++ b/frontends/nextjs/src/components/ui/sonner.tsx
@@ -2,95 +2,68 @@
/**
* Sonner-compatible toast API using MUI Snackbar
- * Provides a drop-in replacement for the 'sonner' package
- *
+ * Provides a drop-in replacement for the 'sonner' package.
+ *
* Usage:
- * import { toast } from '@/components/ui/sonner'
- * toast.success('Saved!')
- * toast.error('Failed to save')
- * toast('Default message')
+ *
+ * ```tsx
+ * import { Toaster, toast } from '@/components/ui/sonner'
+ *
+ * function App() {
+ * return (
+ * <>
+ * toast('Default message')}>Show default toast
+ * toast.success('Saved!')}>Show success toast
+ * toast.error('Failed to save')}>Show error toast
+ *
+ * >
+ * )
+ * }
+ * ```
*/
-import React, { createContext, useContext, useCallback, useState, useEffect } from 'react'
-import { Snackbar, Alert, type AlertColor, Box } from '@mui/material'
+import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
-// Types
-export interface ToastOptions {
- description?: string
- duration?: number
- position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
- id?: string | number
-}
+import { ToastContainer, type ToastHandlers } from './sonner/ToastContainer'
+import {
+ DEFAULT_DURATION,
+ type Toast,
+ type ToastOptions,
+ type ToastType,
+} from './sonner/config'
-interface Toast {
- id: string | number
- message: string
- description?: string
- type: AlertColor | 'default'
- duration: number
-}
+const ToastContext = createContext({
+ addToast: () => {},
+ removeToast: () => {},
+})
-interface ToastContextValue {
- addToast: (toast: Toast) => void
- removeToast: (id?: string | number) => void
-}
-
-// Context
-const ToastContext = createContext(null)
-
-// Global toast queue for when called outside provider
let globalAddToast: ((toast: Toast) => void) | null = null
let globalRemoveToast: ((id?: string | number) => void) | null = null
let toastIdCounter = 0
const generateId = () => `toast-${++toastIdCounter}`
-/**
- * Creates a toast message
- */
-const createToast = (message: string, type: AlertColor | 'default', options?: ToastOptions): Toast => ({
+const createToast = (message: string, type: ToastType, options?: ToastOptions): Toast => ({
id: options?.id ?? generateId(),
message,
description: options?.description,
type,
- duration: options?.duration ?? 4000,
+ duration: options?.duration ?? DEFAULT_DURATION,
})
-/**
- * Toast API - Sonner-compatible interface
- */
+const enqueueToast = (toast: Toast) => {
+ globalAddToast?.(toast)
+ return toast.id
+}
+
export const toast = Object.assign(
- (message: string, options?: ToastOptions) => {
- const t = createToast(message, 'default', options)
- globalAddToast?.(t)
- return t.id
- },
+ (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'default', options)),
{
- success: (message: string, options?: ToastOptions) => {
- const t = createToast(message, 'success', options)
- globalAddToast?.(t)
- return t.id
- },
- error: (message: string, options?: ToastOptions) => {
- const t = createToast(message, 'error', options)
- globalAddToast?.(t)
- return t.id
- },
- warning: (message: string, options?: ToastOptions) => {
- const t = createToast(message, 'warning', options)
- globalAddToast?.(t)
- return t.id
- },
- info: (message: string, options?: ToastOptions) => {
- const t = createToast(message, 'info', options)
- globalAddToast?.(t)
- return t.id
- },
- loading: (message: string, options?: ToastOptions) => {
- const t = createToast(message, 'info', { ...options, duration: 0 })
- globalAddToast?.(t)
- return t.id
- },
+ success: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'success', options)),
+ error: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'error', options)),
+ warning: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'warning', options)),
+ info: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'info', options)),
+ loading: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'info', { ...options, duration: 0 })),
dismiss: (id?: string | number) => {
globalRemoveToast?.(id)
},
@@ -107,16 +80,12 @@ export const toast = Object.assign(
try {
const result = await promise
toast.dismiss(id)
- const successMessage = typeof messages.success === 'function'
- ? messages.success(result)
- : messages.success
+ const successMessage = typeof messages.success === 'function' ? messages.success(result) : messages.success
toast.success(successMessage, options)
return result
} catch (error) {
toast.dismiss(id)
- const errorMessage = typeof messages.error === 'function'
- ? messages.error(error)
- : messages.error
+ const errorMessage = typeof messages.error === 'function' ? messages.error(error) : messages.error
toast.error(errorMessage, options)
throw error
}
@@ -124,11 +93,7 @@ export const toast = Object.assign(
}
)
-/**
- * Toaster component - Renders toast notifications
- * Place this at the root of your app (in layout.tsx)
- */
-export function Toaster({
+export function Toaster({
position = 'bottom-right',
richColors = false,
expand = false,
@@ -139,89 +104,40 @@ export function Toaster({
expand?: boolean
closeButton?: boolean
}) {
- const [toasts, setToasts] = useState([])
+ const [handlers, setHandlers] = useState({
+ addToast: () => {},
+ removeToast: () => {},
+ })
- const addToast = useCallback((toast: Toast) => {
- setToasts(prev => [...prev, toast])
- }, [])
-
- const removeToast = useCallback((id?: string | number) => {
- if (typeof id === 'undefined') {
- setToasts([])
- return
- }
- setToasts(prev => prev.filter(t => t.id !== id))
- }, [])
-
- // Register global handler
useEffect(() => {
- globalAddToast = addToast
- globalRemoveToast = removeToast
return () => {
globalAddToast = null
globalRemoveToast = null
}
- }, [addToast, removeToast])
+ }, [])
- // Map position to MUI anchor origin
- const getAnchorOrigin = () => {
- const vertical = position?.startsWith('top') ? 'top' : 'bottom'
- const horizontal = position?.includes('left') ? 'left' : position?.includes('center') ? 'center' : 'right'
- return { vertical, horizontal } as const
- }
+ const registerHandlers = useCallback((nextHandlers: ToastHandlers) => {
+ setHandlers(nextHandlers)
+ globalAddToast = nextHandlers.addToast
+ globalRemoveToast = nextHandlers.removeToast
+ }, [])
return (
-
-
- {toasts.map((t, index) => (
- removeToast(t.id)}
- anchorOrigin={getAnchorOrigin()}
- sx={{
- position: 'relative',
- mb: 1,
- }}
- >
- removeToast(t.id) : undefined}
- sx={{ width: '100%', minWidth: 300 }}
- >
- {t.message}
- {t.description && (
-
- {t.description}
-
- )}
-
-
- ))}
-
+
+
)
}
-/**
- * Hook to access toast context
- */
export function useToast() {
const context = useContext(ToastContext)
- return {
- toast,
- ...(context ?? { addToast: () => {}, removeToast: () => {} }),
- }
+ return { toast, ...context }
}
-// Default export for sonner compatibility
export default toast
diff --git a/frontends/nextjs/src/components/ui/sonner/ToastContainer.tsx b/frontends/nextjs/src/components/ui/sonner/ToastContainer.tsx
new file mode 100644
index 000000000..904626cd2
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/sonner/ToastContainer.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { Alert, Box, Snackbar } from '@mui/material'
+
+import {
+ anchorFromPosition,
+ containerPlacement,
+ type Toast,
+ type ToastOptions,
+} from './config'
+
+export interface ToastHandlers {
+ addToast: (toast: Toast) => void
+ removeToast: (id?: string | number) => void
+}
+
+interface ToastContainerProps extends ToastOptions {
+ richColors?: boolean
+ expand?: boolean
+ closeButton?: boolean
+ onRegister?: (handlers: ToastHandlers) => void
+}
+
+export function ToastContainer({
+ position = 'bottom-right',
+ richColors = false,
+ expand = false,
+ closeButton = false,
+ onRegister,
+}: ToastContainerProps) {
+ const [toasts, setToasts] = useState([])
+
+ const addToast = useCallback((toast: Toast) => {
+ setToasts(prev => [...prev, toast])
+ }, [])
+
+ const removeToast = useCallback((id?: string | number) => {
+ if (typeof id === 'undefined') {
+ setToasts([])
+ return
+ }
+ setToasts(prev => prev.filter(t => t.id !== id))
+ }, [])
+
+ useEffect(() => {
+ onRegister?.({ addToast, removeToast })
+ }, [addToast, onRegister, removeToast])
+
+ const anchorOrigin = useMemo(() => anchorFromPosition(position), [position])
+ const containerPosition = useMemo(
+ () => containerPlacement(position),
+ [position]
+ )
+
+ return (
+
+ {toasts.map(t => (
+ removeToast(t.id)}
+ anchorOrigin={anchorOrigin}
+ sx={{
+ position: 'relative',
+ mb: 1,
+ }}
+ >
+ removeToast(t.id) : undefined}
+ sx={{ width: expand ? '100%' : 'auto', minWidth: 300 }}
+ >
+ {t.message}
+ {t.description && (
+
+ {t.description}
+
+ )}
+
+
+ ))}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/ui/sonner/config.ts b/frontends/nextjs/src/components/ui/sonner/config.ts
new file mode 100644
index 000000000..ea74c1242
--- /dev/null
+++ b/frontends/nextjs/src/components/ui/sonner/config.ts
@@ -0,0 +1,47 @@
+import { type AlertColor } from '@mui/material'
+
+export type ToastPosition =
+ | 'top-left'
+ | 'top-center'
+ | 'top-right'
+ | 'bottom-left'
+ | 'bottom-center'
+ | 'bottom-right'
+
+export interface ToastOptions {
+ description?: string
+ duration?: number
+ position?: ToastPosition
+ id?: string | number
+}
+
+export type ToastType = AlertColor | 'default'
+
+export interface Toast {
+ id: string | number
+ message: string
+ description?: string
+ type: ToastType
+ duration: number
+}
+
+export const DEFAULT_DURATION = 4000
+
+export const anchorFromPosition = (position?: ToastPosition) => {
+ const vertical = position?.startsWith('top') ? 'top' : 'bottom'
+ const horizontal = position?.includes('left')
+ ? 'left'
+ : position?.includes('center')
+ ? 'center'
+ : 'right'
+ return { vertical, horizontal } as const
+}
+
+export const containerPlacement = (position?: ToastPosition) => ({
+ ...(position?.includes('top') ? { top: 16 } : { bottom: 16 }),
+ ...(position?.includes('left')
+ ? { left: 16 }
+ : position?.includes('center')
+ ? { left: '50%', transform: 'translateX(-50%)' }
+ : { right: 16 }),
+})
diff --git a/frontends/nextjs/src/data/form/FieldGroup.tsx b/frontends/nextjs/src/data/form/FieldGroup.tsx
new file mode 100644
index 000000000..67ad752ca
--- /dev/null
+++ b/frontends/nextjs/src/data/form/FieldGroup.tsx
@@ -0,0 +1,50 @@
+import type { ReactNode } from 'react'
+import { Box, Stack, Typography } from '@mui/material'
+
+interface FieldGroupProps {
+ title: string
+ description?: ReactNode
+ actions?: ReactNode
+ children: ReactNode
+ spacing?: number
+}
+
+export function FieldGroup({
+ title,
+ description,
+ actions,
+ children,
+ spacing = 2,
+}: FieldGroupProps) {
+ return (
+
+
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ {actions ? (
+ {actions}
+ ) : null}
+
+
+ {children}
+
+ )
+}
diff --git a/frontends/nextjs/src/data/form/ValidationSummary.tsx b/frontends/nextjs/src/data/form/ValidationSummary.tsx
new file mode 100644
index 000000000..57eb90278
--- /dev/null
+++ b/frontends/nextjs/src/data/form/ValidationSummary.tsx
@@ -0,0 +1,53 @@
+import type { ReactNode } from 'react'
+import { Alert, AlertTitle, List, ListItem, ListItemText } from '@mui/material'
+
+/**
+ * Props for {@link ValidationSummary}.
+ *
+ * @property errors List of validation errors to display. Each entry can be a plain
+ * string message or a ReactNode for fully custom rendering (e.g. including links
+ * or emphasized text). The list is rendered as items in a bulleted list.
+ * @property title Optional title rendered inside the error alert header. This
+ * is only shown when {@link ValidationSummaryProps.showTitle | showTitle} is
+ * `true`. Defaults to `"Please fix the following"`.
+ * @property showTitle When `true` (default), renders an {@link AlertTitle} using
+ * the provided `title`. Set to `false` when you want to suppress the header and
+ * only show the list of errors.
+ */
+interface ValidationSummaryProps {
+ errors: Array
+ title?: string
+ showTitle?: boolean
+}
+
+/**
+ * Displays a standardized validation error summary as an error {@link Alert}
+ * containing an optional title and a bulleted list of errors.
+ *
+ * Renders nothing when the `errors` array is empty.
+ */
+export function ValidationSummary({
+ errors,
+ title = 'Please fix the following',
+ showTitle = true,
+}: ValidationSummaryProps) {
+ if (!errors.length) return null
+
+ return (
+
+ {showTitle ? {title} : null}
+
+ {errors.map((error, index) => (
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/data/table/Body.tsx b/frontends/nextjs/src/data/table/Body.tsx
new file mode 100644
index 000000000..80225d390
--- /dev/null
+++ b/frontends/nextjs/src/data/table/Body.tsx
@@ -0,0 +1,59 @@
+import type { ReactNode } from 'react'
+import { TableBody, TableCell, TableRow } from '@mui/material'
+
+import { EmptyState } from './EmptyState'
+import type { DataTableColumn } from './types'
+
+interface BodyProps {
+ columns: Array>
+ rows: T[]
+ getRowId?: (row: T, rowIndex: number) => string | number
+ renderActions?: (row: T) => ReactNode
+ onRowClick?: (row: T) => void
+ emptyMessage?: string
+}
+
+export function Body({
+ columns,
+ rows,
+ getRowId,
+ renderActions,
+ onRowClick,
+ emptyMessage = 'No records found',
+}: BodyProps) {
+ const colSpan = columns.length + (renderActions ? 1 : 0)
+
+ return (
+
+ {rows.length === 0 ? (
+
+ ) : (
+ rows.map((row, rowIndex) => {
+ const rowId = getRowId ? getRowId(row, rowIndex) : rowIndex
+ const handleClick = onRowClick ? () => onRowClick(row) : undefined
+
+ return (
+
+ {columns.map((column) => {
+ const content = column.render ? column.render(row, rowIndex) : (row as Record)[column.key]
+
+ return (
+
+ {content ?? '—'}
+
+ )
+ })}
+
+ {renderActions ? {renderActions(row)} : null}
+
+ )
+ })
+ )}
+
+ )
+}
diff --git a/frontends/nextjs/src/data/table/EmptyState.tsx b/frontends/nextjs/src/data/table/EmptyState.tsx
new file mode 100644
index 000000000..b79247aaa
--- /dev/null
+++ b/frontends/nextjs/src/data/table/EmptyState.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react'
+import { Stack, TableCell, TableRow, Typography } from '@mui/material'
+
+/**
+ * Props for the {@link EmptyState} table row.
+ *
+ * This is typically used when a data-driven table has no rows to display and
+ * you want to show a friendly message, optionally with a follow-up action.
+ *
+ * @property colSpan - Number of table columns the empty-state cell should span.
+ * @property message - Optional message shown to explain that there is no data.
+ * @property action - Optional call-to-action content (for example, a "Create"
+ * button or "Reload" button) rendered below the message.
+ */
+interface EmptyStateProps {
+ colSpan: number
+ message?: string
+ action?: ReactNode
+}
+
+/**
+ * Renders a full-width table row indicating that there is currently no data
+ * to display for the surrounding table.
+ *
+ * Use this component as the only row in a table body when the data source is
+ * empty. You can pass an optional `action` to surface primary actions such as
+ * creating a new item or retrying a failed load.
+ */
+export function EmptyState({ colSpan, message = 'No data to display', action }: EmptyStateProps) {
+ return (
+
+
+
+ {message}
+ {action ? {action} : null}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/data/table/Header.tsx b/frontends/nextjs/src/data/table/Header.tsx
new file mode 100644
index 000000000..a7c9f6602
--- /dev/null
+++ b/frontends/nextjs/src/data/table/Header.tsx
@@ -0,0 +1,52 @@
+import { TableCell, TableHead, TableRow, Typography } from '@mui/material'
+
+import type { DataTableColumn } from './types'
+
+interface HeaderProps {
+ columns: Array>
+ actionsHeader?: string
+}
+
+export function Header({ columns, actionsHeader }: HeaderProps) {
+ return (
+
+
+ {columns.map((column) => (
+
+
+ {column.label}
+
+
+ ))}
+
+ {actionsHeader ? (
+
+
+ {actionsHeader}
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/data/table/types.ts b/frontends/nextjs/src/data/table/types.ts
new file mode 100644
index 000000000..d0b94318f
--- /dev/null
+++ b/frontends/nextjs/src/data/table/types.ts
@@ -0,0 +1,11 @@
+import type { ReactNode } from 'react'
+import type { TableCellProps } from '@mui/material'
+
+export interface DataTableColumn {
+ key: string
+ label: string
+ align?: TableCellProps['align']
+ width?: number | string
+ render?: (row: T, rowIndex: number) => ReactNode
+ sx?: TableCellProps['sx']
+}
diff --git a/frontends/nextjs/src/hooks/__tests__/useAuth.roles.test.ts b/frontends/nextjs/src/hooks/__tests__/useAuth.roles.test.ts
new file mode 100644
index 000000000..336565286
--- /dev/null
+++ b/frontends/nextjs/src/hooks/__tests__/useAuth.roles.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { renderHook, act, waitFor } from '@testing-library/react'
+import type { User } from '@/lib/level-types'
+import { useAuth } from '@/hooks/useAuth'
+import { fetchSession } from '@/lib/auth/api/fetch-session'
+import { login as loginRequest } from '@/lib/auth/api/login'
+import { logout as logoutRequest } from '@/lib/auth/api/logout'
+
+vi.mock('@/lib/auth/api/fetch-session', () => ({
+ fetchSession: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/api/login', () => ({
+ login: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/api/logout', () => ({
+ logout: vi.fn(),
+}))
+
+const mockFetchSession = vi.mocked(fetchSession)
+const mockLogin = vi.mocked(loginRequest)
+const mockLogout = vi.mocked(logoutRequest)
+
+const createUser = (overrides?: Partial): User => ({
+ id: 'user_1',
+ username: 'alice',
+ email: 'alice@example.com',
+ role: 'user',
+ createdAt: 1000,
+ tenantId: undefined,
+ profilePicture: undefined,
+ bio: undefined,
+ isInstanceOwner: false,
+ ...overrides,
+})
+
+const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false)
+ })
+}
+
+const resetAuthStore = async () => {
+ const { result, unmount } = renderHook(() => useAuth())
+ await waitForIdle(result)
+ await act(async () => {
+ await result.current.logout()
+ })
+ await waitForIdle(result)
+ unmount()
+}
+
+describe('useAuth role mapping', () => {
+ beforeEach(async () => {
+ mockFetchSession.mockReset()
+ mockLogin.mockReset()
+ mockLogout.mockReset()
+ mockFetchSession.mockResolvedValue(null)
+ mockLogout.mockResolvedValue(undefined)
+
+ await resetAuthStore()
+ })
+
+ it.each([
+ { role: 'public', expectedLevel: 1 },
+ { role: 'user', expectedLevel: 2 },
+ { role: 'admin', expectedLevel: 4 },
+ { role: 'supergod', expectedLevel: 6 },
+ { role: 'unknown', expectedLevel: 0 },
+ ])('applies level for role "$role"', async ({ role, expectedLevel }) => {
+ const { result, unmount } = renderHook(() => useAuth())
+
+ mockLogin.mockResolvedValue(createUser({ role }))
+
+ await waitForIdle(result)
+ await act(async () => {
+ await result.current.login('alice@example.com', 'password')
+ })
+
+ expect(result.current.user?.level).toBe(expectedLevel)
+
+ unmount()
+ })
+
+ it('maps refreshed session roles to levels', async () => {
+ const { result, unmount } = renderHook(() => useAuth())
+
+ mockFetchSession.mockResolvedValue(createUser({ role: 'moderator' }))
+
+ await act(async () => {
+ await result.current.refresh()
+ })
+ await waitForIdle(result)
+
+ expect(result.current.user?.level).toBe(3)
+
+ unmount()
+ })
+})
diff --git a/frontends/nextjs/src/hooks/useAuth.test.ts b/frontends/nextjs/src/hooks/__tests__/useAuth.session.test.ts
similarity index 77%
rename from frontends/nextjs/src/hooks/useAuth.test.ts
rename to frontends/nextjs/src/hooks/__tests__/useAuth.session.test.ts
index 66531dcd9..ffb955ac2 100644
--- a/frontends/nextjs/src/hooks/useAuth.test.ts
+++ b/frontends/nextjs/src/hooks/__tests__/useAuth.session.test.ts
@@ -1,6 +1,11 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { User } from '@/lib/level-types'
+import { useAuth } from '@/hooks/useAuth'
+import { fetchSession } from '@/lib/auth/api/fetch-session'
+import { login as loginRequest } from '@/lib/auth/api/login'
+import { register as registerRequest } from '@/lib/auth/api/register'
+import { logout as logoutRequest } from '@/lib/auth/api/logout'
vi.mock('@/lib/auth/api/fetch-session', () => ({
fetchSession: vi.fn(),
@@ -18,12 +23,6 @@ vi.mock('@/lib/auth/api/logout', () => ({
logout: vi.fn(),
}))
-import { useAuth } from '@/hooks/useAuth'
-import { fetchSession } from '@/lib/auth/api/fetch-session'
-import { login as loginRequest } from '@/lib/auth/api/login'
-import { register as registerRequest } from '@/lib/auth/api/register'
-import { logout as logoutRequest } from '@/lib/auth/api/logout'
-
const mockFetchSession = vi.mocked(fetchSession)
const mockLogin = vi.mocked(loginRequest)
const mockRegister = vi.mocked(registerRequest)
@@ -48,7 +47,17 @@ const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
})
}
-describe('useAuth', () => {
+const resetAuthStore = async () => {
+ const { result, unmount } = renderHook(() => useAuth())
+ await waitForIdle(result)
+ await act(async () => {
+ await result.current.logout()
+ })
+ await waitForIdle(result)
+ unmount()
+}
+
+describe('useAuth session flows', () => {
beforeEach(async () => {
mockFetchSession.mockReset()
mockLogin.mockReset()
@@ -57,16 +66,10 @@ describe('useAuth', () => {
mockFetchSession.mockResolvedValue(null)
mockLogout.mockResolvedValue(undefined)
- const { result, unmount } = renderHook(() => useAuth())
- await waitForIdle(result)
- await act(async () => {
- await result.current.logout()
- })
- await waitForIdle(result)
- unmount()
+ await resetAuthStore()
})
- it('should start unauthenticated after session check', async () => {
+ it('starts unauthenticated after session check', async () => {
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
@@ -77,28 +80,20 @@ describe('useAuth', () => {
unmount()
})
- it.each([
- { email: 'alice@example.com', expectedName: 'alice' },
- { email: 'bob.smith@corp.io', expectedName: 'bob.smith' },
- ])('should authenticate $email', async ({ email, expectedName }) => {
+ it('authenticates on login', async () => {
const { result, unmount } = renderHook(() => useAuth())
- mockLogin.mockResolvedValue(createUser({
- id: 'user_1',
- username: expectedName,
- email,
- }))
+ mockLogin.mockResolvedValue(createUser())
await waitForIdle(result)
await act(async () => {
- await result.current.login(email, 'password')
+ await result.current.login('alice@example.com', 'password')
})
expect(result.current.user).toMatchObject({
id: 'user_1',
- email,
- name: expectedName,
- username: expectedName,
+ email: 'alice@example.com',
+ username: 'alice',
level: 2,
})
expect(result.current.isAuthenticated).toBe(true)
@@ -106,7 +101,7 @@ describe('useAuth', () => {
unmount()
})
- it('should clear user on logout', async () => {
+ it('clears user on logout', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockLogin.mockResolvedValue(createUser())
@@ -126,14 +121,16 @@ describe('useAuth', () => {
unmount()
})
- it('should register and authenticate', async () => {
+ it('registers and authenticates', async () => {
const { result, unmount } = renderHook(() => useAuth())
- mockRegister.mockResolvedValue(createUser({
- id: 'user_2',
- username: 'newbie',
- email: 'newbie@example.com',
- }))
+ mockRegister.mockResolvedValue(
+ createUser({
+ id: 'user_2',
+ username: 'newbie',
+ email: 'newbie@example.com',
+ })
+ )
await waitForIdle(result)
await act(async () => {
@@ -143,7 +140,6 @@ describe('useAuth', () => {
expect(result.current.user).toMatchObject({
id: 'user_2',
email: 'newbie@example.com',
- name: 'newbie',
username: 'newbie',
level: 2,
})
@@ -152,7 +148,7 @@ describe('useAuth', () => {
unmount()
})
- it('should sync state across hooks', async () => {
+ it('syncs state across hooks', async () => {
const first = renderHook(() => useAuth())
const second = renderHook(() => useAuth())
diff --git a/frontends/nextjs/src/hooks/data/useKV.test.ts b/frontends/nextjs/src/hooks/data/__tests__/useKV.store.test.ts
similarity index 54%
rename from frontends/nextjs/src/hooks/data/useKV.test.ts
rename to frontends/nextjs/src/hooks/data/__tests__/useKV.store.test.ts
index d842a5c57..1334e1226 100644
--- a/frontends/nextjs/src/hooks/data/useKV.test.ts
+++ b/frontends/nextjs/src/hooks/data/__tests__/useKV.store.test.ts
@@ -1,54 +1,34 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
-import { useKV } from '@/hooks/useKV'
+import { useKV } from '@/hooks/data/useKV'
-describe('useKV', () => {
- const STORAGE_PREFIX = 'mb_kv:'
- let store: Record
+const STORAGE_PREFIX = 'mb_kv:'
+let store: Record
+const setupLocalStorage = (): void => {
+ store = {}
+ vi.stubGlobal('localStorage', {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key]
+ }),
+ clear: vi.fn(() => {
+ Object.keys(store).forEach(k => delete store[k])
+ }),
+ length: 0,
+ key: vi.fn(() => null),
+ })
+}
+
+describe('useKV storage', () => {
beforeEach(() => {
- // Mock localStorage
- store = {}
- vi.stubGlobal('localStorage', {
- getItem: vi.fn((key: string) => store[key] ?? null),
- setItem: vi.fn((key: string, value: string) => { store[key] = value }),
- removeItem: vi.fn((key: string) => { delete store[key] }),
- clear: vi.fn(() => { Object.keys(store).forEach(k => delete store[k]) }),
- length: 0,
- key: vi.fn(() => null),
- })
+ setupLocalStorage()
})
- it.each([
- { key: 'user_name', defaultValue: 'John', description: 'string value' },
- { key: 'user_count', defaultValue: 0, description: 'number value' },
- { key: 'is_active', defaultValue: true, description: 'boolean value' },
- { key: 'user_data', defaultValue: { id: 1, name: 'John' }, description: 'object value' },
- ])('should initialize hook with $description', ({ key, defaultValue }) => {
- const { result } = renderHook(() => useKV(key, defaultValue))
- const [value] = result.current
-
- expect(value).toBe(defaultValue)
- })
-
- it('should initialize with undefined when no default value provided', () => {
- const { result } = renderHook(() => useKV('empty_key'))
- const [value] = result.current
-
- expect(value).toBeUndefined()
- })
-
- it('should load value from localStorage when available', async () => {
- localStorage.setItem(`${STORAGE_PREFIX}stored_key`, JSON.stringify('stored'))
-
- const { result } = renderHook(() => useKV('stored_key', 'default'))
-
- await waitFor(() => {
- expect(result.current[0]).toBe('stored')
- })
- })
-
- it('should migrate legacy localStorage entries to namespaced keys', () => {
+ it('migrates legacy localStorage entries to namespaced keys', () => {
localStorage.setItem('legacy_key', JSON.stringify('legacy'))
const { result } = renderHook(() => useKV('legacy_key', 'default'))
@@ -58,7 +38,7 @@ describe('useKV', () => {
expect(localStorage.getItem('legacy_key')).toBeNull()
})
- it('should update value when using updater function', async () => {
+ it('updates value when using updater function', async () => {
const { result } = renderHook(() => useKV('counter', 0))
const [, updateValue] = result.current
@@ -71,9 +51,8 @@ describe('useKV', () => {
expect(newValue).toBe(1)
})
- it('should update value when providing direct value', async () => {
+ it('updates value when providing direct value', async () => {
const { result } = renderHook(() => useKV('name', 'John'))
-
const [, updateValue] = result.current
await act(async () => {
@@ -84,7 +63,7 @@ describe('useKV', () => {
expect(newValue).toBe('Jane')
})
- it('should handle complex object updates', async () => {
+ it('handles complex object updates', async () => {
const initialObject = { id: 1, name: 'John', email: 'john@example.com' }
const { result } = renderHook(() => useKV('user', initialObject))
@@ -101,7 +80,7 @@ describe('useKV', () => {
expect(newValue).toEqual({ id: 1, name: 'Jane', email: 'john@example.com' })
})
- it('should handle array updates', async () => {
+ it('handles array updates', async () => {
const initialArray = [1, 2, 3]
const { result } = renderHook(() => useKV('items', initialArray))
@@ -115,7 +94,7 @@ describe('useKV', () => {
expect(newValue).toEqual([1, 2, 3, 4])
})
- it('should maintain separate state for different keys', async () => {
+ it('maintains separate state for different keys', () => {
const { result: result1 } = renderHook(() => useKV('key1', 'value1'))
const { result: result2 } = renderHook(() => useKV('key2', 'value2'))
@@ -126,7 +105,7 @@ describe('useKV', () => {
expect(value2).toBe('value2')
})
- it('should persist updates across multiple hooks with same key', async () => {
+ it('persists updates across multiple hooks with same key', async () => {
const { result: firstHook } = renderHook(() => useKV('shared_key', 'initial'))
const [, updateValue] = firstHook.current
@@ -134,14 +113,13 @@ describe('useKV', () => {
await updateValue('updated')
})
- // Create a new hook with the same key
const { result: secondHook } = renderHook(() => useKV('shared_key', 'initial'))
const [value] = secondHook.current
expect(value).toBe('updated')
})
- it('should sync updates across mounted hooks with same key', async () => {
+ it('syncs updates across mounted hooks with same key', async () => {
const { result: firstHook } = renderHook(() => useKV('sync_key', 'initial'))
const { result: secondHook } = renderHook(() => useKV('sync_key', 'initial'))
@@ -154,19 +132,7 @@ describe('useKV', () => {
})
})
- it.each([
- { initialValue: null, key: 'falsy_key_null', description: 'null value' },
- { initialValue: false, key: 'falsy_key_false', description: 'false boolean' },
- { initialValue: 0, key: 'falsy_key_zero', description: 'zero number' },
- { initialValue: '', key: 'falsy_key_empty', description: 'empty string' },
- ])('should handle falsy $description correctly', ({ initialValue, key }) => {
- const { result } = renderHook(() => useKV(key, initialValue))
- const [value] = result.current
-
- expect(value).toBe(initialValue)
- })
-
- it('should handle rapid updates correctly', async () => {
+ it('handles rapid updates correctly', async () => {
const { result } = renderHook(() => useKV('rapid_key', 0))
const [, updateValue] = result.current
@@ -183,7 +149,7 @@ describe('useKV', () => {
expect(finalValue).toBeGreaterThanOrEqual(1)
})
- it('should persist updates to localStorage', async () => {
+ it('persists updates to localStorage', async () => {
const { result } = renderHook(() => useKV('persist_key', 'initial'))
const [, updateValue] = result.current
diff --git a/frontends/nextjs/src/hooks/data/__tests__/useKV.validation.test.ts b/frontends/nextjs/src/hooks/data/__tests__/useKV.validation.test.ts
new file mode 100644
index 000000000..19b6f25a5
--- /dev/null
+++ b/frontends/nextjs/src/hooks/data/__tests__/useKV.validation.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useKV } from '@/hooks/data/useKV'
+
+const STORAGE_PREFIX = 'mb_kv:'
+let store: Record
+
+const setupLocalStorage = (): void => {
+ store = {}
+ vi.stubGlobal('localStorage', {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key]
+ }),
+ clear: vi.fn(() => {
+ Object.keys(store).forEach(k => delete store[k])
+ }),
+ length: 0,
+ key: vi.fn(() => null),
+ })
+}
+
+describe('useKV validation', () => {
+ beforeEach(() => {
+ setupLocalStorage()
+ })
+
+ it.each([
+ { key: 'user_name', defaultValue: 'John', description: 'string value' },
+ { key: 'user_count', defaultValue: 0, description: 'number value' },
+ { key: 'is_active', defaultValue: true, description: 'boolean value' },
+ { key: 'user_data', defaultValue: { id: 1, name: 'John' }, description: 'object value' },
+ ])('initializes with $description', ({ key, defaultValue }) => {
+ const { result } = renderHook(() => useKV(key, defaultValue))
+ const [value] = result.current
+
+ expect(value).toBe(defaultValue)
+ })
+
+ it('initializes with undefined when no default value provided', () => {
+ const { result } = renderHook(() => useKV('empty_key'))
+ const [value] = result.current
+
+ expect(value).toBeUndefined()
+ })
+
+ it.each([
+ { initialValue: null, key: 'falsy_key_null', description: 'null value' },
+ { initialValue: false, key: 'falsy_key_false', description: 'false boolean' },
+ { initialValue: 0, key: 'falsy_key_zero', description: 'zero number' },
+ { initialValue: '', key: 'falsy_key_empty', description: 'empty string' },
+ ])('handles $description correctly', ({ initialValue, key }) => {
+ const { result } = renderHook(() => useKV(key, initialValue))
+ const [value] = result.current
+
+ expect(value).toBe(initialValue)
+ })
+
+ it('loads value from localStorage when available', () => {
+ localStorage.setItem(`${STORAGE_PREFIX}stored_key`, JSON.stringify('stored'))
+
+ const { result } = renderHook(() => useKV('stored_key', 'default'))
+ const [value] = result.current
+
+ expect(value).toBe('stored')
+ })
+})
diff --git a/frontends/nextjs/src/hooks/ui/state/__tests__/useAutoRefresh.error-handling.test.ts b/frontends/nextjs/src/hooks/ui/state/__tests__/useAutoRefresh.error-handling.test.ts
new file mode 100644
index 000000000..1d9f1b6db
--- /dev/null
+++ b/frontends/nextjs/src/hooks/ui/state/__tests__/useAutoRefresh.error-handling.test.ts
@@ -0,0 +1,74 @@
+/**
+ * Auto-refresh error-handling tests
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { useAutoRefresh } from '../useAutoRefresh'
+
+describe('useAutoRefresh error handling', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('stops refresh when disabled after being enabled', () => {
+ const onRefresh = vi.fn().mockResolvedValue(undefined)
+
+ const { result } = renderHook(() =>
+ useAutoRefresh({
+ intervalMs: 5000,
+ onRefresh,
+ enabled: true,
+ })
+ )
+
+ act(() => {
+ vi.advanceTimersByTime(2500)
+ })
+
+ act(() => {
+ result.current.setEnabled(false)
+ })
+
+ act(() => {
+ vi.advanceTimersByTime(10000)
+ })
+
+ expect(onRefresh).not.toHaveBeenCalled()
+ })
+
+ it('continues scheduling refreshes after onRefresh errors', () => {
+ const erroringRefresh = vi.fn().mockImplementation(() =>
+ Promise.reject(new Error('refresh failed')).catch(() => {})
+ )
+
+ const { result } = renderHook(() =>
+ useAutoRefresh({
+ intervalMs: 2000,
+ onRefresh: erroringRefresh,
+ enabled: true,
+ })
+ )
+
+ expect(result.current.secondsUntilNextRefresh).toBe(2)
+
+ act(() => {
+ vi.advanceTimersByTime(2000)
+ })
+ expect(erroringRefresh).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+ expect(result.current.secondsUntilNextRefresh).toBe(1)
+
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+ expect(result.current.secondsUntilNextRefresh).toBe(2)
+ expect(erroringRefresh).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/frontends/nextjs/src/hooks/ui/state/useAutoRefresh.test.ts b/frontends/nextjs/src/hooks/ui/state/__tests__/useAutoRefresh.polling.test.ts
similarity index 71%
rename from frontends/nextjs/src/hooks/ui/state/useAutoRefresh.test.ts
rename to frontends/nextjs/src/hooks/ui/state/__tests__/useAutoRefresh.polling.test.ts
index c61dda058..13c26ff29 100644
--- a/frontends/nextjs/src/hooks/ui/state/useAutoRefresh.test.ts
+++ b/frontends/nextjs/src/hooks/ui/state/__tests__/useAutoRefresh.polling.test.ts
@@ -1,13 +1,11 @@
/**
- * Tests for useAutoRefresh hook - Auto-refresh polling management
- * Following parameterized test pattern per project conventions
+ * Auto-refresh polling tests
*/
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
-import { useAutoRefresh } from './useAutoRefresh'
+import { useAutoRefresh } from '../useAutoRefresh'
-describe('useAutoRefresh', () => {
+describe('useAutoRefresh polling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
@@ -21,7 +19,7 @@ describe('useAutoRefresh', () => {
{ enabled: false, expectAutoRefreshing: false },
{ enabled: true, expectAutoRefreshing: true },
{ enabled: undefined, expectAutoRefreshing: false },
- ])('should initialize with enabled=$enabled -> isAutoRefreshing=$expectAutoRefreshing', ({ enabled, expectAutoRefreshing }) => {
+ ])('initializes with enabled=$enabled -> isAutoRefreshing=$expectAutoRefreshing', ({ enabled, expectAutoRefreshing }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -39,7 +37,7 @@ describe('useAutoRefresh', () => {
{ intervalMs: 30000, expectedSeconds: 30 },
{ intervalMs: 60000, expectedSeconds: 60 },
{ intervalMs: 5000, expectedSeconds: 5 },
- ])('should set secondsUntilNextRefresh from intervalMs=$intervalMs', ({ intervalMs, expectedSeconds }) => {
+ ])('sets secondsUntilNextRefresh from intervalMs=$intervalMs', ({ intervalMs, expectedSeconds }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -55,7 +53,7 @@ describe('useAutoRefresh', () => {
})
describe('toggleAutoRefresh', () => {
- it('should toggle from disabled to enabled', () => {
+ it('toggles from disabled to enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -75,7 +73,7 @@ describe('useAutoRefresh', () => {
expect(result.current.isAutoRefreshing).toBe(true)
})
- it('should toggle from enabled to disabled', () => {
+ it('toggles from enabled to disabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -102,7 +100,7 @@ describe('useAutoRefresh', () => {
{ initial: true, setTo: false, expected: false },
{ initial: false, setTo: false, expected: false },
{ initial: true, setTo: true, expected: true },
- ])('should set from $initial to $setTo', ({ initial, setTo, expected }) => {
+ ])('sets from $initial to $setTo', ({ initial, setTo, expected }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -122,7 +120,7 @@ describe('useAutoRefresh', () => {
})
describe('refresh timing', () => {
- it('should call onRefresh after intervalMs when enabled', async () => {
+ it('calls onRefresh after intervalMs when enabled', async () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -133,23 +131,20 @@ describe('useAutoRefresh', () => {
})
)
- // Not called initially
expect(onRefresh).not.toHaveBeenCalled()
- // Advance to just before interval
act(() => {
vi.advanceTimersByTime(4999)
})
expect(onRefresh).not.toHaveBeenCalled()
- // Advance past interval
act(() => {
vi.advanceTimersByTime(1)
})
expect(onRefresh).toHaveBeenCalledTimes(1)
})
- it('should not call onRefresh when disabled', () => {
+ it('does not call onRefresh when disabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -167,7 +162,7 @@ describe('useAutoRefresh', () => {
expect(onRefresh).not.toHaveBeenCalled()
})
- it('should call onRefresh multiple times at interval', () => {
+ it('calls onRefresh multiple times at interval', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -179,7 +174,7 @@ describe('useAutoRefresh', () => {
)
act(() => {
- vi.advanceTimersByTime(15000) // 3 intervals
+ vi.advanceTimersByTime(15000)
})
expect(onRefresh).toHaveBeenCalledTimes(3)
@@ -187,7 +182,7 @@ describe('useAutoRefresh', () => {
})
describe('countdown', () => {
- it('should decrement countdown every second when enabled', () => {
+ it('decrements countdown every second when enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -211,7 +206,7 @@ describe('useAutoRefresh', () => {
expect(result.current.secondsUntilNextRefresh).toBe(3)
})
- it('should reset countdown after reaching zero', () => {
+ it('resets countdown after reaching zero', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -224,45 +219,11 @@ describe('useAutoRefresh', () => {
expect(result.current.secondsUntilNextRefresh).toBe(3)
- // Advance 3 seconds to reach zero
act(() => {
vi.advanceTimersByTime(3000)
})
- // Should reset to initial value
expect(result.current.secondsUntilNextRefresh).toBe(3)
})
})
-
- describe('cleanup', () => {
- it('should stop refresh when disabled after being enabled', () => {
- const onRefresh = vi.fn().mockResolvedValue(undefined)
-
- const { result } = renderHook(() =>
- useAutoRefresh({
- intervalMs: 5000,
- onRefresh,
- enabled: true,
- })
- )
-
- // Advance half interval
- act(() => {
- vi.advanceTimersByTime(2500)
- })
-
- // Disable
- act(() => {
- result.current.setEnabled(false)
- })
-
- // Advance another full interval
- act(() => {
- vi.advanceTimersByTime(10000)
- })
-
- // Should not have been called (disabled before first interval completed)
- expect(onRefresh).not.toHaveBeenCalled()
- })
- })
})
diff --git a/frontends/nextjs/src/lib/db/core/entities.ts b/frontends/nextjs/src/lib/db/core/entities.ts
new file mode 100644
index 000000000..c5b09e9f8
--- /dev/null
+++ b/frontends/nextjs/src/lib/db/core/entities.ts
@@ -0,0 +1,22 @@
+// Domain re-exports
+export * from '../auth'
+export * from '../users'
+export * from '../credentials'
+export * from '../sessions'
+export * from '../workflows'
+export * from '../lua-scripts'
+export * from '../pages'
+export * from '../schemas'
+export * from '../comments'
+export * from '../app-config'
+export * from '../system-config'
+export * from '../components'
+export * from '../css-classes'
+export * from '../dropdown-configs'
+export * from '../tenants'
+export * from '../packages'
+export * from '../power-transfers'
+export * from '../smtp-config'
+export * from '../god-credentials'
+export * from '../database-admin'
+export * from '../error-logs'
diff --git a/frontends/nextjs/src/lib/db/core/index.ts b/frontends/nextjs/src/lib/db/core/index.ts
index 26aded445..36681295d 100644
--- a/frontends/nextjs/src/lib/db/core/index.ts
+++ b/frontends/nextjs/src/lib/db/core/index.ts
@@ -6,220 +6,8 @@ export { DB_KEYS } from './types'
export { getAdapter, closeAdapter } from './dbal-client'
export type { DBALAdapter, ListOptions, ListResult } from './dbal-client'
-// Core
-export { hashPassword } from '../password/hash-password'
-export { verifyPassword } from '../password/verify-password'
-export { initializeDatabase } from './initialize-database'
+// Operations
+export { hashPassword, verifyPassword, initializeDatabase, Database } from './operations'
// Domain re-exports
-export * from '../auth'
-export * from '../users'
-export * from '../credentials'
-export * from '../sessions'
-export * from '../workflows'
-export * from '../lua-scripts'
-export * from '../pages'
-export * from '../schemas'
-export * from '../comments'
-export * from '../app-config'
-export * from '../system-config'
-export * from '../components'
-export * from '../css-classes'
-export * from '../dropdown-configs'
-export * from '../tenants'
-export * from '../packages'
-export * from '../power-transfers'
-export * from '../smtp-config'
-export * from '../god-credentials'
-export * from '../database-admin'
-export * from '../error-logs'
-
-// Import all for namespace class
-import { initializeDatabase } from './initialize-database'
-import { hashPassword } from '../password/hash-password'
-import { verifyPassword } from '../password/verify-password'
-import * as auth from '../auth'
-import * as users from '../users'
-import * as credentials from '../credentials'
-import * as sessions from '../sessions'
-import * as workflows from '../workflows'
-import * as luaScripts from '../lua-scripts'
-import * as pages from '../pages'
-import * as schemas from '../schemas'
-import * as comments from '../comments'
-import * as appConfig from '../app-config'
-import * as systemConfig from '../system-config'
-import * as components from '../components'
-import * as cssClasses from '../css-classes'
-import * as dropdownConfigs from '../dropdown-configs'
-import * as tenants from '../tenants'
-import * as packages from '../packages'
-import * as powerTransfers from '../power-transfers'
-import * as smtpConfig from '../smtp-config'
-import * as godCredentials from '../god-credentials'
-import * as databaseAdmin from '../database-admin'
-import * as errorLogs from '../error-logs'
-
-/**
- * Database namespace class - groups all DB operations as static methods
- * No instance state - pure function container for backward compatibility
- */
-export class Database {
- // Core
- static initializeDatabase = initializeDatabase
- static hashPassword = hashPassword
- static verifyPassword = verifyPassword
-
- // Auth
- static authenticateUser = auth.authenticateUser
- static getUserByUsername = auth.getUserByUsername
- static getUserByEmail = auth.getUserByEmail
-
- // Users
- static getUsers = users.getUsers
- static getUserById = users.getUserById
- static setUsers = users.setUsers
- static addUser = users.addUser
- static updateUser = users.updateUser
- static deleteUser = users.deleteUser
- static getSuperGod = users.getSuperGod
- static transferSuperGodPower = users.transferSuperGodPower
-
- // Credentials
- static getCredentials = credentials.getCredentials
- static setCredential = credentials.setCredential
- static verifyCredentials = credentials.verifyCredentials
- static getPasswordChangeTimestamps = credentials.getPasswordChangeTimestamps
- static setPasswordChangeTimestamps = credentials.setPasswordChangeTimestamps
- static getPasswordResetTokens = credentials.getPasswordResetTokens
- static setPasswordResetToken = credentials.setPasswordResetToken
- static deletePasswordResetToken = credentials.deletePasswordResetToken
-
- // Sessions
- static createSession = sessions.createSession
- static getSessionById = sessions.getSessionById
- static getSessionByToken = sessions.getSessionByToken
- static updateSession = sessions.updateSession
- static deleteSession = sessions.deleteSession
- static deleteSessionByToken = sessions.deleteSessionByToken
- static listSessions = sessions.listSessions
-
- // Workflows
- static getWorkflows = workflows.getWorkflows
- static setWorkflows = workflows.setWorkflows
- static addWorkflow = workflows.addWorkflow
- static updateWorkflow = workflows.updateWorkflow
- static deleteWorkflow = workflows.deleteWorkflow
-
- // Lua Scripts
- static getLuaScripts = luaScripts.getLuaScripts
- static setLuaScripts = luaScripts.setLuaScripts
- static addLuaScript = luaScripts.addLuaScript
- static updateLuaScript = luaScripts.updateLuaScript
- static deleteLuaScript = luaScripts.deleteLuaScript
-
- // Pages
- static getPages = pages.getPages
- static setPages = pages.setPages
- static addPage = pages.addPage
- static updatePage = pages.updatePage
- static deletePage = pages.deletePage
-
- // Schemas
- static getSchemas = schemas.getSchemas
- static setSchemas = schemas.setSchemas
- static addSchema = schemas.addSchema
- static updateSchema = schemas.updateSchema
- static deleteSchema = schemas.deleteSchema
-
- // Comments
- static getComments = comments.getComments
- static setComments = comments.setComments
- static addComment = comments.addComment
- static updateComment = comments.updateComment
- static deleteComment = comments.deleteComment
-
- // App Config
- static getAppConfig = appConfig.getAppConfig
- static setAppConfig = appConfig.setAppConfig
-
- // System Config
- static getSystemConfigValue = systemConfig.getSystemConfigValue
-
- // Components
- static getComponentHierarchy = components.getComponentHierarchy
- static setComponentHierarchy = components.setComponentHierarchy
- static addComponentNode = components.addComponentNode
- static updateComponentNode = components.updateComponentNode
- static deleteComponentNode = components.deleteComponentNode
- static getComponentConfigs = components.getComponentConfigs
- static setComponentConfigs = components.setComponentConfigs
- static addComponentConfig = components.addComponentConfig
- static updateComponentConfig = components.updateComponentConfig
- static deleteComponentConfig = components.deleteComponentConfig
-
- // CSS Classes
- static getCssClasses = cssClasses.getCssClasses
- static setCssClasses = cssClasses.setCssClasses
- static addCssCategory = cssClasses.addCssCategory
- static updateCssCategory = cssClasses.updateCssCategory
- static deleteCssCategory = cssClasses.deleteCssCategory
-
- // Dropdown Configs
- static getDropdownConfigs = dropdownConfigs.getDropdownConfigs
- static setDropdownConfigs = dropdownConfigs.setDropdownConfigs
- static addDropdownConfig = dropdownConfigs.addDropdownConfig
- static updateDropdownConfig = dropdownConfigs.updateDropdownConfig
- static deleteDropdownConfig = dropdownConfigs.deleteDropdownConfig
-
- // Tenants
- static getTenants = tenants.getTenants
- static setTenants = tenants.setTenants
- static addTenant = tenants.addTenant
- static updateTenant = tenants.updateTenant
- static deleteTenant = tenants.deleteTenant
-
- // Packages
- static getInstalledPackages = packages.getInstalledPackages
- static setInstalledPackages = packages.setInstalledPackages
- static installPackage = packages.installPackage
- static uninstallPackage = packages.uninstallPackage
- static togglePackageEnabled = packages.togglePackageEnabled
- static getPackageData = packages.getPackageData
- static setPackageData = packages.setPackageData
- static deletePackageData = packages.deletePackageData
-
- // Power Transfers
- static getPowerTransferRequests = powerTransfers.getPowerTransferRequests
- static setPowerTransferRequests = powerTransfers.setPowerTransferRequests
- static addPowerTransferRequest = powerTransfers.addPowerTransferRequest
- static updatePowerTransferRequest = powerTransfers.updatePowerTransferRequest
- static deletePowerTransferRequest = powerTransfers.deletePowerTransferRequest
-
- // SMTP Config
- static getSMTPConfig = smtpConfig.getSMTPConfig
- static setSMTPConfig = smtpConfig.setSMTPConfig
-
- // God Credentials
- static getGodCredentialsExpiry = godCredentials.getGodCredentialsExpiry
- static setGodCredentialsExpiry = godCredentials.setGodCredentialsExpiry
- static getFirstLoginFlags = godCredentials.getFirstLoginFlags
- static setFirstLoginFlag = godCredentials.setFirstLoginFlag
- static getGodCredentialsExpiryDuration = godCredentials.getGodCredentialsExpiryDuration
- static setGodCredentialsExpiryDuration = godCredentials.setGodCredentialsExpiryDuration
- static shouldShowGodCredentials = godCredentials.shouldShowGodCredentials
- static resetGodCredentialsExpiry = godCredentials.resetGodCredentialsExpiry
-
- // Database Admin
- static clearDatabase = databaseAdmin.clearDatabase
- static exportDatabase = databaseAdmin.exportDatabase
- static importDatabase = databaseAdmin.importDatabase
- static seedDefaultData = databaseAdmin.seedDefaultData
-
- // Error Logs
- static getErrorLogs = errorLogs.getErrorLogs
- static addErrorLog = errorLogs.addErrorLog
- static updateErrorLog = errorLogs.updateErrorLog
- static deleteErrorLog = errorLogs.deleteErrorLog
- static clearErrorLogs = errorLogs.clearErrorLogs
-}
+export * from './entities'
diff --git a/frontends/nextjs/src/lib/db/core/operations.ts b/frontends/nextjs/src/lib/db/core/operations.ts
new file mode 100644
index 000000000..3135730a7
--- /dev/null
+++ b/frontends/nextjs/src/lib/db/core/operations.ts
@@ -0,0 +1,190 @@
+import { initializeDatabase } from './initialize-database'
+import { hashPassword } from '../password/hash-password'
+import { verifyPassword } from '../password/verify-password'
+import * as auth from '../auth'
+import * as users from '../users'
+import * as credentials from '../credentials'
+import * as sessions from '../sessions'
+import * as workflows from '../workflows'
+import * as luaScripts from '../lua-scripts'
+import * as pages from '../pages'
+import * as schemas from '../schemas'
+import * as comments from '../comments'
+import * as appConfig from '../app-config'
+import * as systemConfig from '../system-config'
+import * as components from '../components'
+import * as cssClasses from '../css-classes'
+import * as dropdownConfigs from '../dropdown-configs'
+import * as tenants from '../tenants'
+import * as packages from '../packages'
+import * as powerTransfers from '../power-transfers'
+import * as smtpConfig from '../smtp-config'
+import * as godCredentials from '../god-credentials'
+import * as databaseAdmin from '../database-admin'
+import * as errorLogs from '../error-logs'
+
+export { initializeDatabase, hashPassword, verifyPassword }
+
+/**
+ * Database namespace class - groups all DB operations as static methods
+ * No instance state - pure function container for backward compatibility
+ */
+export class Database {
+ // Core
+ static initializeDatabase = initializeDatabase
+ static hashPassword = hashPassword
+ static verifyPassword = verifyPassword
+
+ // Auth
+ static authenticateUser = auth.authenticateUser
+ static getUserByUsername = auth.getUserByUsername
+ static getUserByEmail = auth.getUserByEmail
+
+ // Users
+ static getUsers = users.getUsers
+ static getUserById = users.getUserById
+ static setUsers = users.setUsers
+ static addUser = users.addUser
+ static updateUser = users.updateUser
+ static deleteUser = users.deleteUser
+ static getSuperGod = users.getSuperGod
+ static transferSuperGodPower = users.transferSuperGodPower
+
+ // Credentials
+ static getCredentials = credentials.getCredentials
+ static setCredential = credentials.setCredential
+ static verifyCredentials = credentials.verifyCredentials
+ static getPasswordChangeTimestamps = credentials.getPasswordChangeTimestamps
+ static setPasswordChangeTimestamps = credentials.setPasswordChangeTimestamps
+ static getPasswordResetTokens = credentials.getPasswordResetTokens
+ static setPasswordResetToken = credentials.setPasswordResetToken
+ static deletePasswordResetToken = credentials.deletePasswordResetToken
+
+ // Sessions
+ static createSession = sessions.createSession
+ static getSessionById = sessions.getSessionById
+ static getSessionByToken = sessions.getSessionByToken
+ static updateSession = sessions.updateSession
+ static deleteSession = sessions.deleteSession
+ static deleteSessionByToken = sessions.deleteSessionByToken
+ static listSessions = sessions.listSessions
+
+ // Workflows
+ static getWorkflows = workflows.getWorkflows
+ static setWorkflows = workflows.setWorkflows
+ static addWorkflow = workflows.addWorkflow
+ static updateWorkflow = workflows.updateWorkflow
+ static deleteWorkflow = workflows.deleteWorkflow
+
+ // Lua Scripts
+ static getLuaScripts = luaScripts.getLuaScripts
+ static setLuaScripts = luaScripts.setLuaScripts
+ static addLuaScript = luaScripts.addLuaScript
+ static updateLuaScript = luaScripts.updateLuaScript
+ static deleteLuaScript = luaScripts.deleteLuaScript
+
+ // Pages
+ static getPages = pages.getPages
+ static setPages = pages.setPages
+ static addPage = pages.addPage
+ static updatePage = pages.updatePage
+ static deletePage = pages.deletePage
+
+ // Schemas
+ static getSchemas = schemas.getSchemas
+ static setSchemas = schemas.setSchemas
+ static addSchema = schemas.addSchema
+ static updateSchema = schemas.updateSchema
+ static deleteSchema = schemas.deleteSchema
+
+ // Comments
+ static getComments = comments.getComments
+ static setComments = comments.setComments
+ static addComment = comments.addComment
+ static updateComment = comments.updateComment
+ static deleteComment = comments.deleteComment
+
+ // App Config
+ static getAppConfig = appConfig.getAppConfig
+ static setAppConfig = appConfig.setAppConfig
+
+ // System Config
+ static getSystemConfigValue = systemConfig.getSystemConfigValue
+
+ // Components
+ static getComponentHierarchy = components.getComponentHierarchy
+ static setComponentHierarchy = components.setComponentHierarchy
+ static addComponentNode = components.addComponentNode
+ static updateComponentNode = components.updateComponentNode
+ static deleteComponentNode = components.deleteComponentNode
+ static getComponentConfigs = components.getComponentConfigs
+ static setComponentConfigs = components.setComponentConfigs
+ static addComponentConfig = components.addComponentConfig
+ static updateComponentConfig = components.updateComponentConfig
+ static deleteComponentConfig = components.deleteComponentConfig
+
+ // CSS Classes
+ static getCssClasses = cssClasses.getCssClasses
+ static setCssClasses = cssClasses.setCssClasses
+ static addCssCategory = cssClasses.addCssCategory
+ static updateCssCategory = cssClasses.updateCssCategory
+ static deleteCssCategory = cssClasses.deleteCssCategory
+
+ // Dropdown Configs
+ static getDropdownConfigs = dropdownConfigs.getDropdownConfigs
+ static setDropdownConfigs = dropdownConfigs.setDropdownConfigs
+ static addDropdownConfig = dropdownConfigs.addDropdownConfig
+ static updateDropdownConfig = dropdownConfigs.updateDropdownConfig
+ static deleteDropdownConfig = dropdownConfigs.deleteDropdownConfig
+
+ // Tenants
+ static getTenants = tenants.getTenants
+ static setTenants = tenants.setTenants
+ static addTenant = tenants.addTenant
+ static updateTenant = tenants.updateTenant
+ static deleteTenant = tenants.deleteTenant
+
+ // Packages
+ static getInstalledPackages = packages.getInstalledPackages
+ static setInstalledPackages = packages.setInstalledPackages
+ static installPackage = packages.installPackage
+ static uninstallPackage = packages.uninstallPackage
+ static togglePackageEnabled = packages.togglePackageEnabled
+ static getPackageData = packages.getPackageData
+ static setPackageData = packages.setPackageData
+ static deletePackageData = packages.deletePackageData
+
+ // Power Transfers
+ static getPowerTransferRequests = powerTransfers.getPowerTransferRequests
+ static setPowerTransferRequests = powerTransfers.setPowerTransferRequests
+ static addPowerTransferRequest = powerTransfers.addPowerTransferRequest
+ static updatePowerTransferRequest = powerTransfers.updatePowerTransferRequest
+ static deletePowerTransferRequest = powerTransfers.deletePowerTransferRequest
+
+ // SMTP Config
+ static getSMTPConfig = smtpConfig.getSMTPConfig
+ static setSMTPConfig = smtpConfig.setSMTPConfig
+
+ // God Credentials
+ static getGodCredentialsExpiry = godCredentials.getGodCredentialsExpiry
+ static setGodCredentialsExpiry = godCredentials.setGodCredentialsExpiry
+ static getFirstLoginFlags = godCredentials.getFirstLoginFlags
+ static setFirstLoginFlag = godCredentials.setFirstLoginFlag
+ static getGodCredentialsExpiryDuration = godCredentials.getGodCredentialsExpiryDuration
+ static setGodCredentialsExpiryDuration = godCredentials.setGodCredentialsExpiryDuration
+ static shouldShowGodCredentials = godCredentials.shouldShowGodCredentials
+ static resetGodCredentialsExpiry = godCredentials.resetGodCredentialsExpiry
+
+ // Database Admin
+ static clearDatabase = databaseAdmin.clearDatabase
+ static exportDatabase = databaseAdmin.exportDatabase
+ static importDatabase = databaseAdmin.importDatabase
+ static seedDefaultData = databaseAdmin.seedDefaultData
+
+ // Error Logs
+ static getErrorLogs = errorLogs.getErrorLogs
+ static addErrorLog = errorLogs.addErrorLog
+ static updateErrorLog = errorLogs.updateErrorLog
+ static deleteErrorLog = errorLogs.deleteErrorLog
+ static clearErrorLogs = errorLogs.clearErrorLogs
+}
diff --git a/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/advanced.ts b/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/advanced.ts
new file mode 100644
index 000000000..19ca68f3d
--- /dev/null
+++ b/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/advanced.ts
@@ -0,0 +1,3 @@
+import type { CssCategory } from '../../../../core/types'
+
+export const buildAdvancedCssCategories = (): CssCategory[] => []
diff --git a/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/base.ts b/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/base.ts
new file mode 100644
index 000000000..ac5c71f08
--- /dev/null
+++ b/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/base.ts
@@ -0,0 +1,278 @@
+import type { CssCategory } from '../../../../core/types'
+import { buildSizingClasses, buildSpacingClasses } from '../build-css-classes'
+
+export const buildBaseCssCategories = (): CssCategory[] => [
+ {
+ name: 'Layout',
+ classes: [
+ 'block',
+ 'inline-block',
+ 'inline',
+ 'flex',
+ 'inline-flex',
+ 'grid',
+ 'inline-grid',
+ 'contents',
+ 'hidden',
+ 'flex-row',
+ 'flex-row-reverse',
+ 'flex-col',
+ 'flex-col-reverse',
+ 'flex-wrap',
+ 'flex-wrap-reverse',
+ 'flex-nowrap',
+ ],
+ },
+ {
+ name: 'Spacing',
+ classes: buildSpacingClasses(),
+ },
+ {
+ name: 'Sizing',
+ classes: buildSizingClasses(),
+ },
+ {
+ name: 'Typography',
+ classes: [
+ 'text-xs',
+ 'text-sm',
+ 'text-base',
+ 'text-lg',
+ 'text-xl',
+ 'text-2xl',
+ 'text-3xl',
+ 'text-4xl',
+ 'text-5xl',
+ 'text-6xl',
+ 'font-thin',
+ 'font-light',
+ 'font-normal',
+ 'font-medium',
+ 'font-semibold',
+ 'font-bold',
+ 'font-extrabold',
+ 'font-black',
+ 'leading-none',
+ 'leading-tight',
+ 'leading-snug',
+ 'leading-normal',
+ 'leading-relaxed',
+ 'leading-loose',
+ 'tracking-tighter',
+ 'tracking-tight',
+ 'tracking-normal',
+ 'tracking-wide',
+ 'tracking-wider',
+ 'tracking-widest',
+ 'text-left',
+ 'text-center',
+ 'text-right',
+ 'text-justify',
+ 'uppercase',
+ 'lowercase',
+ 'capitalize',
+ 'normal-case',
+ 'italic',
+ 'not-italic',
+ 'underline',
+ 'no-underline',
+ 'line-through',
+ 'font-sans',
+ 'font-serif',
+ 'font-mono',
+ ],
+ },
+ {
+ name: 'Colors',
+ classes: [
+ 'text-foreground',
+ 'text-muted-foreground',
+ 'text-primary',
+ 'text-primary-foreground',
+ 'text-secondary',
+ 'text-secondary-foreground',
+ 'text-accent',
+ 'text-accent-foreground',
+ 'text-destructive',
+ 'text-destructive-foreground',
+ 'bg-background',
+ 'bg-card',
+ 'bg-muted',
+ 'bg-accent',
+ 'bg-primary',
+ 'bg-secondary',
+ 'bg-destructive',
+ 'bg-popover',
+ 'bg-transparent',
+ 'bg-white',
+ 'bg-black',
+ 'text-white',
+ 'text-black',
+ 'border-border',
+ 'border-input',
+ 'border-primary',
+ 'border-secondary',
+ 'border-accent',
+ 'border-destructive',
+ 'ring-ring',
+ 'ring-primary',
+ 'ring-secondary',
+ 'ring-accent',
+ 'ring-destructive',
+ ],
+ },
+ {
+ name: 'Borders',
+ classes: [
+ 'border',
+ 'border-0',
+ 'border-2',
+ 'border-4',
+ 'border-8',
+ 'border-t',
+ 'border-b',
+ 'border-l',
+ 'border-r',
+ 'border-x',
+ 'border-y',
+ 'border-solid',
+ 'border-dashed',
+ 'border-dotted',
+ 'border-double',
+ 'border-hidden',
+ 'rounded-none',
+ 'rounded-sm',
+ 'rounded',
+ 'rounded-md',
+ 'rounded-lg',
+ 'rounded-xl',
+ 'rounded-2xl',
+ 'rounded-3xl',
+ 'rounded-full',
+ ],
+ },
+ {
+ name: 'Effects',
+ classes: [
+ 'shadow-none',
+ 'shadow-sm',
+ 'shadow',
+ 'shadow-md',
+ 'shadow-lg',
+ 'shadow-xl',
+ 'shadow-2xl',
+ 'shadow-inner',
+ 'ring-0',
+ 'ring-1',
+ 'ring-2',
+ 'ring-4',
+ 'ring-offset-1',
+ 'ring-offset-2',
+ 'opacity-0',
+ 'opacity-25',
+ 'opacity-50',
+ 'opacity-75',
+ 'opacity-100',
+ 'transition',
+ 'transition-all',
+ 'transition-colors',
+ 'transition-opacity',
+ 'transition-transform',
+ 'duration-75',
+ 'duration-100',
+ 'duration-150',
+ 'duration-200',
+ 'duration-300',
+ 'duration-500',
+ 'ease-in',
+ 'ease-out',
+ 'ease-in-out',
+ 'blur-none',
+ 'blur-sm',
+ 'blur',
+ 'blur-md',
+ 'blur-lg',
+ 'backdrop-blur',
+ 'backdrop-blur-sm',
+ ],
+ },
+ {
+ name: 'Positioning',
+ classes: [
+ 'static',
+ 'relative',
+ 'absolute',
+ 'fixed',
+ 'sticky',
+ 'inset-0',
+ 'inset-x-0',
+ 'inset-y-0',
+ 'top-0',
+ 'right-0',
+ 'bottom-0',
+ 'left-0',
+ 'z-auto',
+ 'z-0',
+ 'z-10',
+ 'z-20',
+ 'z-30',
+ 'z-40',
+ 'z-50',
+ 'overflow-hidden',
+ 'overflow-auto',
+ 'overflow-scroll',
+ 'overflow-visible',
+ 'overflow-x-auto',
+ 'overflow-y-auto',
+ ],
+ },
+ {
+ name: 'Alignment',
+ classes: [
+ 'items-start',
+ 'items-center',
+ 'items-end',
+ 'items-stretch',
+ 'items-baseline',
+ 'justify-start',
+ 'justify-center',
+ 'justify-end',
+ 'justify-between',
+ 'justify-around',
+ 'justify-evenly',
+ 'content-start',
+ 'content-center',
+ 'content-end',
+ 'self-start',
+ 'self-center',
+ 'self-end',
+ 'self-stretch',
+ 'place-items-start',
+ 'place-items-center',
+ 'place-items-end',
+ ],
+ },
+ {
+ name: 'Interactivity',
+ classes: [
+ 'cursor-pointer',
+ 'cursor-default',
+ 'cursor-not-allowed',
+ 'pointer-events-none',
+ 'pointer-events-auto',
+ 'select-none',
+ 'select-text',
+ 'select-all',
+ 'select-auto',
+ 'hover:bg-accent',
+ 'hover:text-accent-foreground',
+ 'hover:underline',
+ 'active:scale-95',
+ 'focus:ring-2',
+ 'focus:ring-primary',
+ 'focus-visible:outline-none',
+ 'disabled:opacity-50',
+ 'disabled:pointer-events-none',
+ ],
+ },
+]
diff --git a/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/experimental.ts b/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/experimental.ts
new file mode 100644
index 000000000..6bc131ebd
--- /dev/null
+++ b/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/categories/experimental.ts
@@ -0,0 +1,3 @@
+import type { CssCategory } from '../../../../core/types'
+
+export const buildExperimentalCssCategories = (): CssCategory[] => []
diff --git a/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/default-css-categories.ts b/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/default-css-categories.ts
index 772760517..c9449ffdc 100644
--- a/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/default-css-categories.ts
+++ b/frontends/nextjs/src/lib/db/database-admin/seed-default-data/css/default-css-categories.ts
@@ -1,278 +1,10 @@
import type { CssCategory } from '../../../core/types'
-import { buildSizingClasses, buildSpacingClasses } from './build-css-classes'
+import { buildAdvancedCssCategories } from './categories/advanced'
+import { buildBaseCssCategories } from './categories/base'
+import { buildExperimentalCssCategories } from './categories/experimental'
export const buildDefaultCssCategories = (): CssCategory[] => [
- {
- name: 'Layout',
- classes: [
- 'block',
- 'inline-block',
- 'inline',
- 'flex',
- 'inline-flex',
- 'grid',
- 'inline-grid',
- 'contents',
- 'hidden',
- 'flex-row',
- 'flex-row-reverse',
- 'flex-col',
- 'flex-col-reverse',
- 'flex-wrap',
- 'flex-wrap-reverse',
- 'flex-nowrap',
- ],
- },
- {
- name: 'Spacing',
- classes: buildSpacingClasses(),
- },
- {
- name: 'Sizing',
- classes: buildSizingClasses(),
- },
- {
- name: 'Typography',
- classes: [
- 'text-xs',
- 'text-sm',
- 'text-base',
- 'text-lg',
- 'text-xl',
- 'text-2xl',
- 'text-3xl',
- 'text-4xl',
- 'text-5xl',
- 'text-6xl',
- 'font-thin',
- 'font-light',
- 'font-normal',
- 'font-medium',
- 'font-semibold',
- 'font-bold',
- 'font-extrabold',
- 'font-black',
- 'leading-none',
- 'leading-tight',
- 'leading-snug',
- 'leading-normal',
- 'leading-relaxed',
- 'leading-loose',
- 'tracking-tighter',
- 'tracking-tight',
- 'tracking-normal',
- 'tracking-wide',
- 'tracking-wider',
- 'tracking-widest',
- 'text-left',
- 'text-center',
- 'text-right',
- 'text-justify',
- 'uppercase',
- 'lowercase',
- 'capitalize',
- 'normal-case',
- 'italic',
- 'not-italic',
- 'underline',
- 'no-underline',
- 'line-through',
- 'font-sans',
- 'font-serif',
- 'font-mono',
- ],
- },
- {
- name: 'Colors',
- classes: [
- 'text-foreground',
- 'text-muted-foreground',
- 'text-primary',
- 'text-primary-foreground',
- 'text-secondary',
- 'text-secondary-foreground',
- 'text-accent',
- 'text-accent-foreground',
- 'text-destructive',
- 'text-destructive-foreground',
- 'bg-background',
- 'bg-card',
- 'bg-muted',
- 'bg-accent',
- 'bg-primary',
- 'bg-secondary',
- 'bg-destructive',
- 'bg-popover',
- 'bg-transparent',
- 'bg-white',
- 'bg-black',
- 'text-white',
- 'text-black',
- 'border-border',
- 'border-input',
- 'border-primary',
- 'border-secondary',
- 'border-accent',
- 'border-destructive',
- 'ring-ring',
- 'ring-primary',
- 'ring-secondary',
- 'ring-accent',
- 'ring-destructive',
- ],
- },
- {
- name: 'Borders',
- classes: [
- 'border',
- 'border-0',
- 'border-2',
- 'border-4',
- 'border-8',
- 'border-t',
- 'border-b',
- 'border-l',
- 'border-r',
- 'border-x',
- 'border-y',
- 'border-solid',
- 'border-dashed',
- 'border-dotted',
- 'border-double',
- 'border-hidden',
- 'rounded-none',
- 'rounded-sm',
- 'rounded',
- 'rounded-md',
- 'rounded-lg',
- 'rounded-xl',
- 'rounded-2xl',
- 'rounded-3xl',
- 'rounded-full',
- ],
- },
- {
- name: 'Effects',
- classes: [
- 'shadow-none',
- 'shadow-sm',
- 'shadow',
- 'shadow-md',
- 'shadow-lg',
- 'shadow-xl',
- 'shadow-2xl',
- 'shadow-inner',
- 'ring-0',
- 'ring-1',
- 'ring-2',
- 'ring-4',
- 'ring-offset-1',
- 'ring-offset-2',
- 'opacity-0',
- 'opacity-25',
- 'opacity-50',
- 'opacity-75',
- 'opacity-100',
- 'transition',
- 'transition-all',
- 'transition-colors',
- 'transition-opacity',
- 'transition-transform',
- 'duration-75',
- 'duration-100',
- 'duration-150',
- 'duration-200',
- 'duration-300',
- 'duration-500',
- 'ease-in',
- 'ease-out',
- 'ease-in-out',
- 'blur-none',
- 'blur-sm',
- 'blur',
- 'blur-md',
- 'blur-lg',
- 'backdrop-blur',
- 'backdrop-blur-sm',
- ],
- },
- {
- name: 'Positioning',
- classes: [
- 'static',
- 'relative',
- 'absolute',
- 'fixed',
- 'sticky',
- 'inset-0',
- 'inset-x-0',
- 'inset-y-0',
- 'top-0',
- 'right-0',
- 'bottom-0',
- 'left-0',
- 'z-auto',
- 'z-0',
- 'z-10',
- 'z-20',
- 'z-30',
- 'z-40',
- 'z-50',
- 'overflow-hidden',
- 'overflow-auto',
- 'overflow-scroll',
- 'overflow-visible',
- 'overflow-x-auto',
- 'overflow-y-auto',
- ],
- },
- {
- name: 'Alignment',
- classes: [
- 'items-start',
- 'items-center',
- 'items-end',
- 'items-stretch',
- 'items-baseline',
- 'justify-start',
- 'justify-center',
- 'justify-end',
- 'justify-between',
- 'justify-around',
- 'justify-evenly',
- 'content-start',
- 'content-center',
- 'content-end',
- 'self-start',
- 'self-center',
- 'self-end',
- 'self-stretch',
- 'place-items-start',
- 'place-items-center',
- 'place-items-end',
- ],
- },
- {
- name: 'Interactivity',
- classes: [
- 'cursor-pointer',
- 'cursor-default',
- 'cursor-not-allowed',
- 'pointer-events-none',
- 'pointer-events-auto',
- 'select-none',
- 'select-text',
- 'select-all',
- 'select-auto',
- 'hover:bg-accent',
- 'hover:text-accent-foreground',
- 'hover:underline',
- 'active:scale-95',
- 'focus:ring-2',
- 'focus:ring-primary',
- 'focus-visible:outline-none',
- 'disabled:opacity-50',
- 'disabled:pointer-events-none',
- ],
- },
+ ...buildBaseCssCategories(),
+ ...buildAdvancedCssCategories(),
+ ...buildExperimentalCssCategories(),
]
diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts
index ce3d5d2a1..ce1897add 100644
--- a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts
+++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts
@@ -1,5 +1,52 @@
import { describe, it, expect } from 'vitest'
-import { summarizeWorkflowRuns } from './analyze-workflow-runs'
+import {
+ analyzeWorkflowRuns,
+ parseWorkflowRuns,
+ summarizeWorkflowRuns,
+} from './analyze-workflow-runs'
+
+describe('parseWorkflowRuns', () => {
+ it('normalizes unknown entries and ignores items without numeric IDs', () => {
+ const runs = [
+ {
+ id: 1,
+ name: 'Build',
+ status: 'completed',
+ conclusion: 'success',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:10:00Z',
+ head_branch: 'main',
+ event: 'push',
+ },
+ { id: 'not-a-number' },
+ {
+ id: 2,
+ name: '',
+ status: '',
+ conclusion: 'failure',
+ created_at: '',
+ updated_at: '',
+ head_branch: '',
+ event: '',
+ },
+ ]
+
+ const parsed = parseWorkflowRuns(runs)
+
+ expect(parsed).toHaveLength(2)
+ expect(parsed[0].name).toBe('Build')
+ expect(parsed[1]).toEqual({
+ id: 2,
+ name: 'Unknown workflow',
+ status: 'unknown',
+ conclusion: 'failure',
+ created_at: '',
+ updated_at: '',
+ head_branch: 'unknown',
+ event: 'unknown',
+ })
+ })
+})
describe('summarizeWorkflowRuns', () => {
it('summarizes totals, success rate, and failure hotspots', () => {
@@ -60,3 +107,24 @@ describe('summarizeWorkflowRuns', () => {
expect(summary.mostRecent).toBeNull()
})
})
+
+describe('analyzeWorkflowRuns', () => {
+ it('returns parsed summary and formatted output', () => {
+ const result = analyzeWorkflowRuns([
+ {
+ id: 7,
+ name: 'Deploy',
+ status: 'completed',
+ conclusion: 'success',
+ created_at: '2024-02-01T00:00:00Z',
+ updated_at: '2024-02-01T00:05:00Z',
+ head_branch: 'main',
+ event: 'workflow_dispatch',
+ },
+ ])
+
+ expect(result.summary.total).toBe(1)
+ expect(result.formatted).toContain('Workflow Run Analysis')
+ expect(result.formatted).toContain('Deploy')
+ })
+})
diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts
index 0c9de1453..2f0049237 100644
--- a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts
+++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts
@@ -1,164 +1,18 @@
-export type WorkflowRunLike = {
- id: number
- name: string
- status: string
- conclusion: string | null
- created_at: string
- updated_at: string
- head_branch: string
- event: string
-}
+import { parseWorkflowRuns, WorkflowRunLike } from './parser'
+import { formatWorkflowRunAnalysis, summarizeWorkflowRuns, WorkflowRunSummary } from './stats'
-export type WorkflowRunSummary = {
- total: number
- completed: number
- successful: number
- failed: number
- cancelled: number
- inProgress: number
- successRate: number
- mostRecent: WorkflowRunLike | null
- recentRuns: WorkflowRunLike[]
- topFailingWorkflows: Array<{ name: string; failures: number }>
- failingBranches: Array<{ branch: string; failures: number }>
- failingEvents: Array<{ event: string; failures: number }>
-}
+export type { WorkflowRunLike, WorkflowRunSummary }
+export { parseWorkflowRuns, summarizeWorkflowRuns, formatWorkflowRunAnalysis }
-const DEFAULT_RECENT_COUNT = 5
-const DEFAULT_TOP_COUNT = 3
-
-function toTopCounts(
- values: string[],
- topCount: number
-): Array<{ key: string; count: number }> {
- const counts = new Map()
- values.forEach((value) => {
- counts.set(value, (counts.get(value) || 0) + 1)
- })
-
- return Array.from(counts.entries())
- .map(([key, count]) => ({ key, count }))
- .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
- .slice(0, topCount)
-}
-
-export function summarizeWorkflowRuns(
- runs: WorkflowRunLike[],
+export function analyzeWorkflowRuns(
+ runs: unknown[],
options?: { recentCount?: number; topCount?: number }
-): WorkflowRunSummary {
- const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT
- const topCount = options?.topCount ?? DEFAULT_TOP_COUNT
- const total = runs.length
-
- const completedRuns = runs.filter((run) => run.status === 'completed')
- const successful = completedRuns.filter((run) => run.conclusion === 'success').length
- const failed = completedRuns.filter((run) => run.conclusion === 'failure').length
- const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length
- const inProgress = total - completedRuns.length
- const successRate = completedRuns.length
- ? Math.round((successful / completedRuns.length) * 100)
- : 0
-
- const sortedByUpdated = [...runs].sort(
- (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
- )
- const mostRecent = sortedByUpdated[0] ?? null
- const recentRuns = sortedByUpdated.slice(0, recentCount)
-
- const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure')
- const topFailingWorkflows = toTopCounts(
- failureRuns.map((run) => run.name),
- topCount
- ).map((entry) => ({ name: entry.key, failures: entry.count }))
-
- const failingBranches = toTopCounts(
- failureRuns.map((run) => run.head_branch),
- topCount
- ).map((entry) => ({ branch: entry.key, failures: entry.count }))
-
- const failingEvents = toTopCounts(
- failureRuns.map((run) => run.event),
- topCount
- ).map((entry) => ({ event: entry.key, failures: entry.count }))
+) {
+ const parsedRuns = parseWorkflowRuns(runs)
+ const summary = summarizeWorkflowRuns(parsedRuns, options)
return {
- total,
- completed: completedRuns.length,
- successful,
- failed,
- cancelled,
- inProgress,
- successRate,
- mostRecent,
- recentRuns,
- topFailingWorkflows,
- failingBranches,
- failingEvents,
+ summary,
+ formatted: formatWorkflowRunAnalysis(summary),
}
}
-
-export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) {
- const lines: string[] = []
-
- lines.push('Workflow Run Analysis')
- lines.push('---------------------')
- lines.push(`Total runs: ${summary.total}`)
- lines.push(
- `Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})`
- )
- lines.push(`In progress: ${summary.inProgress}`)
- lines.push(`Success rate: ${summary.successRate}%`)
-
- if (summary.mostRecent) {
- lines.push('')
- lines.push('Most recent run:')
- lines.push(
- `- ${summary.mostRecent.name} | ${summary.mostRecent.status}${
- summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : ''
- } | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}`
- )
- }
-
- if (summary.recentRuns.length > 0) {
- lines.push('')
- lines.push('Recent runs:')
- summary.recentRuns.forEach((run) => {
- lines.push(
- `- ${run.name} | ${run.status}${
- run.conclusion ? `/${run.conclusion}` : ''
- } | ${run.head_branch} | ${run.updated_at}`
- )
- })
- }
-
- if (summary.topFailingWorkflows.length > 0) {
- lines.push('')
- lines.push('Top failing workflows:')
- summary.topFailingWorkflows.forEach((entry) => {
- lines.push(`- ${entry.name}: ${entry.failures}`)
- })
- }
-
- if (summary.failingBranches.length > 0) {
- lines.push('')
- lines.push('Failing branches:')
- summary.failingBranches.forEach((entry) => {
- lines.push(`- ${entry.branch}: ${entry.failures}`)
- })
- }
-
- if (summary.failingEvents.length > 0) {
- lines.push('')
- lines.push('Failing events:')
- summary.failingEvents.forEach((entry) => {
- lines.push(`- ${entry.event}: ${entry.failures}`)
- })
- }
-
- if (summary.total === 0) {
- lines.push('')
- lines.push('No workflow runs available to analyze.')
- }
-
- return lines.join('\n')
-}
diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts
new file mode 100644
index 000000000..25570391d
--- /dev/null
+++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts
@@ -0,0 +1,50 @@
+export type WorkflowRunLike = {
+ id: number
+ name: string
+ status: string
+ conclusion: string | null
+ created_at: string
+ updated_at: string
+ head_branch: string
+ event: string
+}
+
+const FALLBACK_NAME = 'Unknown workflow'
+const FALLBACK_STATUS = 'unknown'
+const FALLBACK_BRANCH = 'unknown'
+const FALLBACK_EVENT = 'unknown'
+
+function toStringOrFallback(value: unknown, fallback: string) {
+ return typeof value === 'string' && value.trim() ? value : fallback
+}
+
+export function parseWorkflowRuns(runs: unknown[]): WorkflowRunLike[] {
+ if (!Array.isArray(runs)) {
+ return []
+ }
+
+ return runs
+ .map((run) => {
+ const candidate = run as Partial & { id?: unknown }
+ const id = Number(candidate.id)
+
+ if (!Number.isFinite(id)) {
+ return null
+ }
+
+ return {
+ id,
+ name: toStringOrFallback(candidate.name, FALLBACK_NAME),
+ status: toStringOrFallback(candidate.status, FALLBACK_STATUS),
+ conclusion:
+ candidate.conclusion === null || typeof candidate.conclusion === 'string'
+ ? candidate.conclusion
+ : null,
+ created_at: toStringOrFallback(candidate.created_at, ''),
+ updated_at: toStringOrFallback(candidate.updated_at, ''),
+ head_branch: toStringOrFallback(candidate.head_branch, FALLBACK_BRANCH),
+ event: toStringOrFallback(candidate.event, FALLBACK_EVENT),
+ }
+ })
+ .filter((run): run is WorkflowRunLike => Boolean(run))
+}
diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts
new file mode 100644
index 000000000..138771e60
--- /dev/null
+++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts
@@ -0,0 +1,153 @@
+import { WorkflowRunLike } from './parser'
+
+export type WorkflowRunSummary = {
+ total: number
+ completed: number
+ successful: number
+ failed: number
+ cancelled: number
+ inProgress: number
+ successRate: number
+ mostRecent: WorkflowRunLike | null
+ recentRuns: WorkflowRunLike[]
+ topFailingWorkflows: Array<{ name: string; failures: number }>
+ failingBranches: Array<{ branch: string; failures: number }>
+ failingEvents: Array<{ event: string; failures: number }>
+}
+
+const DEFAULT_RECENT_COUNT = 5
+const DEFAULT_TOP_COUNT = 3
+
+function toTopCounts(
+ values: string[],
+ topCount: number
+): Array<{ key: string; count: number }> {
+ const counts = new Map()
+ values.forEach((value) => {
+ counts.set(value, (counts.get(value) || 0) + 1)
+ })
+
+ return Array.from(counts.entries())
+ .map(([key, count]) => ({ key, count }))
+ .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
+ .slice(0, topCount)
+}
+
+export function summarizeWorkflowRuns(
+ runs: WorkflowRunLike[],
+ options?: { recentCount?: number; topCount?: number }
+): WorkflowRunSummary {
+ const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT
+ const topCount = options?.topCount ?? DEFAULT_TOP_COUNT
+ const total = runs.length
+
+ const completedRuns = runs.filter((run) => run.status === 'completed')
+ const successful = completedRuns.filter((run) => run.conclusion === 'success').length
+ const failed = completedRuns.filter((run) => run.conclusion === 'failure').length
+ const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length
+ const inProgress = total - completedRuns.length
+ const successRate = completedRuns.length
+ ? Math.round((successful / completedRuns.length) * 100)
+ : 0
+
+ const sortedByUpdated = [...runs].sort(
+ (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
+ )
+ const mostRecent = sortedByUpdated[0] ?? null
+ const recentRuns = sortedByUpdated.slice(0, recentCount)
+
+ const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure')
+ const topFailingWorkflows = toTopCounts(
+ failureRuns.map((run) => run.name),
+ topCount
+ ).map((entry) => ({ name: entry.key, failures: entry.count }))
+
+ const failingBranches = toTopCounts(
+ failureRuns.map((run) => run.head_branch),
+ topCount
+ ).map((entry) => ({ branch: entry.key, failures: entry.count }))
+
+ const failingEvents = toTopCounts(
+ failureRuns.map((run) => run.event),
+ topCount
+ ).map((entry) => ({ event: entry.key, failures: entry.count }))
+
+ return {
+ total,
+ completed: completedRuns.length,
+ successful,
+ failed,
+ cancelled,
+ inProgress,
+ successRate,
+ mostRecent,
+ recentRuns,
+ topFailingWorkflows,
+ failingBranches,
+ failingEvents,
+ }
+}
+
+export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) {
+ const lines: string[] = []
+
+ lines.push('Workflow Run Analysis')
+ lines.push('---------------------')
+ lines.push(`Total runs: ${summary.total}`)
+ lines.push(
+ `Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})`
+ )
+ lines.push(`In progress: ${summary.inProgress}`)
+ lines.push(`Success rate: ${summary.successRate}%`)
+
+ if (summary.mostRecent) {
+ lines.push('')
+ lines.push('Most recent run:')
+ lines.push(
+ `- ${summary.mostRecent.name} | ${summary.mostRecent.status}${
+ summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : ''
+ } | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}`
+ )
+ }
+
+ if (summary.recentRuns.length > 0) {
+ lines.push('')
+ lines.push('Recent runs:')
+ summary.recentRuns.forEach((run) => {
+ lines.push(
+ `- ${run.name} | ${run.status}${run.conclusion ? `/${run.conclusion}` : ''} | ${run.head_branch} | ${run.updated_at}`
+ )
+ })
+ }
+
+ if (summary.topFailingWorkflows.length > 0) {
+ lines.push('')
+ lines.push('Top failing workflows:')
+ summary.topFailingWorkflows.forEach((entry) => {
+ lines.push(`- ${entry.name}: ${entry.failures}`)
+ })
+ }
+
+ if (summary.failingBranches.length > 0) {
+ lines.push('')
+ lines.push('Failing branches:')
+ summary.failingBranches.forEach((entry) => {
+ lines.push(`- ${entry.branch}: ${entry.failures}`)
+ })
+ }
+
+ if (summary.failingEvents.length > 0) {
+ lines.push('')
+ lines.push('Failing events:')
+ summary.failingEvents.forEach((entry) => {
+ lines.push(`- ${entry.event}: ${entry.failures}`)
+ })
+ }
+
+ if (summary.total === 0) {
+ lines.push('')
+ lines.push('No workflow runs available to analyze.')
+ }
+
+ return lines.join('\n')
+}
diff --git a/frontends/nextjs/src/lib/lua/engine/core/__tests__/lua-engine.events.test.ts b/frontends/nextjs/src/lib/lua/engine/core/__tests__/lua-engine.events.test.ts
new file mode 100644
index 000000000..e3e33513a
--- /dev/null
+++ b/frontends/nextjs/src/lib/lua/engine/core/__tests__/lua-engine.events.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { LuaEngine, createLuaEngine } from '../lua-engine'
+
+describe('lua-engine events', () => {
+ let engine: LuaEngine
+
+ beforeEach(() => {
+ engine = createLuaEngine()
+ })
+
+ afterEach(() => {
+ engine.destroy()
+ })
+
+ describe('logging', () => {
+ it('should capture log() calls', async () => {
+ const result = await engine.execute(`
+ log("message 1")
+ log("message 2")
+ return "done"
+ `)
+ expect(result.success).toBe(true)
+ expect(result.logs).toContain('message 1')
+ expect(result.logs).toContain('message 2')
+ })
+
+ it('should capture print() calls', async () => {
+ const result = await engine.execute(`
+ print("printed output")
+ return true
+ `)
+ expect(result.success).toBe(true)
+ expect(result.logs).toContain('printed output')
+ })
+
+ it.each([
+ { name: 'number', code: 'log(42)', expected: '42' },
+ { name: 'boolean', code: 'log(true)', expected: 'true' },
+ { name: 'multiple args', code: 'log("a", "b", "c")', expected: 'a b c' },
+ ])('should log $name correctly', async ({ code, expected }) => {
+ const result = await engine.execute(code)
+ expect(result.logs).toContain(expected)
+ })
+ })
+
+ describe('error handling', () => {
+ it.each([
+ {
+ name: 'syntax error',
+ code: 'this is not valid lua !!!',
+ errorContains: 'Syntax error',
+ },
+ {
+ name: 'undefined variable',
+ code: 'return undefinedVar.property',
+ errorContains: 'Runtime error',
+ },
+ {
+ name: 'type error',
+ code: 'return "string" + 5',
+ errorContains: 'error',
+ },
+ {
+ name: 'explicit error',
+ code: 'error("intentional error")',
+ errorContains: 'intentional error',
+ },
+ ])('should handle $name', async ({ code, errorContains }) => {
+ const result = await engine.execute(code)
+ expect(result.success).toBe(false)
+ expect(result.error?.toLowerCase()).toContain(errorContains.toLowerCase())
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/lua/engine/core/lua-engine.test.ts b/frontends/nextjs/src/lib/lua/engine/core/__tests__/lua-engine.execution.test.ts
similarity index 80%
rename from frontends/nextjs/src/lib/lua/engine/core/lua-engine.test.ts
rename to frontends/nextjs/src/lib/lua/engine/core/__tests__/lua-engine.execution.test.ts
index 4700460ff..8affd583b 100644
--- a/frontends/nextjs/src/lib/lua/engine/core/lua-engine.test.ts
+++ b/frontends/nextjs/src/lib/lua/engine/core/__tests__/lua-engine.execution.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
-import { LuaEngine, createLuaEngine, type LuaExecutionContext } from './lua-engine'
+import { LuaEngine, createLuaEngine, type LuaExecutionContext } from '../lua-engine'
-describe('lua-engine', () => {
+describe('lua-engine execution', () => {
let engine: LuaEngine
beforeEach(() => {
@@ -81,31 +81,31 @@ describe('lua-engine', () => {
{
name: 'access context.data number',
code: 'return context.data.value * 2',
- context: { data: { value: 21 } },
+ context: { data: { value: 21 } } satisfies LuaExecutionContext,
expected: 42,
},
{
name: 'access context.data string',
code: 'return context.data.name',
- context: { data: { name: 'test' } },
+ context: { data: { name: 'test' } } satisfies LuaExecutionContext,
expected: 'test',
},
{
name: 'access context.data boolean',
code: 'return context.data.active',
- context: { data: { active: true } },
+ context: { data: { active: true } } satisfies LuaExecutionContext,
expected: true,
},
{
name: 'access nested context.data',
code: 'return context.data.user.name',
- context: { data: { user: { name: 'Alice' } } },
+ context: { data: { user: { name: 'Alice' } } } satisfies LuaExecutionContext,
expected: 'Alice',
},
{
name: 'access context.data array',
code: 'return context.data.items[2]',
- context: { data: { items: [10, 20, 30] } },
+ context: { data: { items: [10, 20, 30] } } satisfies LuaExecutionContext,
expected: 20,
},
])('should $name', async ({ code, context, expected }) => {
@@ -241,66 +241,6 @@ describe('lua-engine', () => {
})
})
- describe('logging', () => {
- it('should capture log() calls', async () => {
- const result = await engine.execute(`
- log("message 1")
- log("message 2")
- return "done"
- `)
- expect(result.success).toBe(true)
- expect(result.logs).toContain('message 1')
- expect(result.logs).toContain('message 2')
- })
-
- it('should capture print() calls', async () => {
- const result = await engine.execute(`
- print("printed output")
- return true
- `)
- expect(result.success).toBe(true)
- expect(result.logs).toContain('printed output')
- })
-
- it.each([
- { name: 'number', code: 'log(42)', expected: '42' },
- { name: 'boolean', code: 'log(true)', expected: 'true' },
- { name: 'multiple args', code: 'log("a", "b", "c")', expected: 'a b c' },
- ])('should log $name correctly', async ({ code, expected }) => {
- const result = await engine.execute(code)
- expect(result.logs).toContain(expected)
- })
- })
-
- describe('error handling', () => {
- it.each([
- {
- name: 'syntax error',
- code: 'this is not valid lua !!!',
- errorContains: 'Syntax error',
- },
- {
- name: 'undefined variable',
- code: 'return undefinedVar.property',
- errorContains: 'Runtime error',
- },
- {
- name: 'type error',
- code: 'return "string" + 5',
- errorContains: 'error',
- },
- {
- name: 'explicit error',
- code: 'error("intentional error")',
- errorContains: 'intentional error',
- },
- ])('should handle $name', async ({ code, errorContains }) => {
- const result = await engine.execute(code)
- expect(result.success).toBe(false)
- expect(result.error?.toLowerCase()).toContain(errorContains.toLowerCase())
- })
- })
-
describe('multiple return values', () => {
it('should handle multiple return values', async () => {
const result = await engine.execute('return 1, 2, 3')
diff --git a/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/advanced.ts b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/advanced.ts
new file mode 100644
index 000000000..6c92a720b
--- /dev/null
+++ b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/advanced.ts
@@ -0,0 +1,3 @@
+import type { PackageTemplateConfig } from '../../types'
+
+export const ADVANCED_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []
diff --git a/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts
new file mode 100644
index 000000000..4a129db79
--- /dev/null
+++ b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts
@@ -0,0 +1,267 @@
+import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../../types'
+
+export const BASE_REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = {
+ id: 'react_next_starter',
+ name: 'Next.js Web App',
+ description: 'A clean Next.js starter with app router, hero component, and typed config files.',
+ rootName: 'web_app',
+ tags: ['nextjs', 'react', 'web', 'starter'],
+}
+
+const socialHubComponents = [
+ {
+ id: 'social_hub_root',
+ type: 'Stack',
+ props: { className: 'flex flex-col gap-6' },
+ children: [
+ {
+ id: 'social_hub_hero',
+ type: 'Card',
+ props: { className: 'p-6' },
+ children: [
+ {
+ id: 'social_hub_heading',
+ type: 'Heading',
+ props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' },
+ children: [],
+ },
+ {
+ id: 'social_hub_subtitle',
+ type: 'Text',
+ props: { children: 'A modern feed for creator updates, curated stories, and live moments.' },
+ children: [],
+ },
+ ],
+ },
+ {
+ id: 'social_hub_stats',
+ type: 'Grid',
+ props: { className: 'grid grid-cols-3 gap-4' },
+ children: [
+ {
+ id: 'social_hub_stat_1',
+ type: 'Card',
+ props: { className: 'p-4' },
+ children: [
+ {
+ id: 'social_hub_stat_label_1',
+ type: 'Text',
+ props: { children: 'Creators live', className: 'text-sm text-muted-foreground' },
+ children: [],
+ },
+ {
+ id: 'social_hub_stat_value_1',
+ type: 'Heading',
+ props: { children: '128', level: '3', className: 'text-xl font-semibold' },
+ children: [],
+ },
+ ],
+ },
+ {
+ id: 'social_hub_stat_2',
+ type: 'Card',
+ props: { className: 'p-4' },
+ children: [
+ {
+ id: 'social_hub_stat_label_2',
+ type: 'Text',
+ props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' },
+ children: [],
+ },
+ {
+ id: 'social_hub_stat_value_2',
+ type: 'Heading',
+ props: { children: '42', level: '3', className: 'text-xl font-semibold' },
+ children: [],
+ },
+ ],
+ },
+ {
+ id: 'social_hub_stat_3',
+ type: 'Card',
+ props: { className: 'p-4' },
+ children: [
+ {
+ id: 'social_hub_stat_label_3',
+ type: 'Text',
+ props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' },
+ children: [],
+ },
+ {
+ id: 'social_hub_stat_value_3',
+ type: 'Heading',
+ props: { children: '7', level: '3', className: 'text-xl font-semibold' },
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'social_hub_composer',
+ type: 'Card',
+ props: { className: 'p-4' },
+ children: [
+ {
+ id: 'social_hub_composer_label',
+ type: 'Label',
+ props: { children: 'Share a quick update' },
+ children: [],
+ },
+ {
+ id: 'social_hub_composer_input',
+ type: 'Textarea',
+ props: { placeholder: 'What are you building today?', rows: 3 },
+ children: [],
+ },
+ {
+ id: 'social_hub_composer_actions',
+ type: 'Flex',
+ props: { className: 'flex gap-2' },
+ children: [
+ {
+ id: 'social_hub_composer_publish',
+ type: 'Button',
+ props: { children: 'Publish', variant: 'default' },
+ children: [],
+ },
+ {
+ id: 'social_hub_composer_media',
+ type: 'Button',
+ props: { children: 'Add media', variant: 'outline' },
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'social_hub_feed',
+ type: 'Stack',
+ props: { className: 'flex flex-col gap-4' },
+ children: [
+ {
+ id: 'social_hub_feed_post_1',
+ type: 'Card',
+ props: { className: 'p-5' },
+ children: [
+ {
+ id: 'social_hub_feed_post_1_title',
+ type: 'Heading',
+ props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' },
+ children: [],
+ },
+ {
+ id: 'social_hub_feed_post_1_body',
+ type: 'Text',
+ props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' },
+ children: [],
+ },
+ {
+ id: 'social_hub_feed_post_1_badge',
+ type: 'Badge',
+ props: { children: 'Community' },
+ children: [],
+ },
+ ],
+ },
+ {
+ id: 'social_hub_feed_post_2',
+ type: 'Card',
+ props: { className: 'p-5' },
+ children: [
+ {
+ id: 'social_hub_feed_post_2_title',
+ type: 'Heading',
+ props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' },
+ children: [],
+ },
+ {
+ id: 'social_hub_feed_post_2_body',
+ type: 'Text',
+ props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' },
+ children: [],
+ },
+ {
+ id: 'social_hub_feed_post_2_badge',
+ type: 'Badge',
+ props: { children: 'Spotlight', variant: 'secondary' },
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+]
+
+const socialHubExamples = {
+ feedItems: [
+ {
+ id: 'post_001',
+ author: 'Nova',
+ title: 'Launch day recap',
+ summary: 'We shipped live rooms and doubled community sessions.',
+ tags: ['launch', 'community'],
+ },
+ {
+ id: 'post_002',
+ author: 'Kai',
+ title: 'Build log: day 42',
+ summary: 'Refined the moderation pipeline and added creator scorecards.',
+ tags: ['buildinpublic'],
+ },
+ ],
+ trendingTags: ['#buildinpublic', '#metabuilder', '#live'],
+ rooms: [
+ { id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true },
+ { id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false },
+ ],
+}
+
+const socialHubLuaScripts = [
+ {
+ fileName: 'init.lua',
+ description: 'Lifecycle hooks for package installation.',
+ code: 'local M = {}\\n\\nfunction M.on_install(context)\\n return { message = "Social Hub installed", version = context.version }\\nend\\n\\nfunction M.on_uninstall()\\n return { message = "Social Hub removed" }\\nend\\n\\nreturn M',
+ },
+ {
+ fileName: 'permissions.lua',
+ description: 'Role-based access rules for posting and moderation.',
+ code: 'local Permissions = {}\\n\\nfunction Permissions.can_post(user)\\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\\nend\\n\\nfunction Permissions.can_moderate(user)\\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\\nend\\n\\nreturn Permissions',
+ },
+ {
+ fileName: 'feed_rank.lua',
+ description: 'Score feed items based on recency and engagement.',
+ code: 'local FeedRank = {}\\n\\nfunction FeedRank.score(item)\\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\\n return freshness + engagement\\nend\\n\\nreturn FeedRank',
+ },
+ {
+ fileName: 'moderation.lua',
+ description: 'Flag content for review using lightweight heuristics.',
+ code: 'local Moderation = {}\\n\\nfunction Moderation.flag(content)\\n local lowered = string.lower(content or "")\\n if string.find(lowered, "spam") then\\n return { flagged = true, reason = "spam_keyword" }\\n end\\n return { flagged = false }\\nend\\n\\nreturn Moderation',
+ },
+ {
+ fileName: 'analytics.lua',
+ description: 'Aggregate engagement signals for dashboards.',
+ code: 'local Analytics = {}\\n\\nfunction Analytics.aggregate(events)\\n local summary = { views = 0, likes = 0, comments = 0 }\\n for _, event in ipairs(events or {}) do\\n summary.views = summary.views + (event.views or 0)\\n summary.likes = summary.likes + (event.likes or 0)\\n summary.comments = summary.comments + (event.comments or 0)\\n end\\n return summary\\nend\\n\\nreturn Analytics',
+ },
+]
+
+export const BASE_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [
+ {
+ id: 'package_social_hub',
+ name: 'Social Hub Package',
+ description: 'A package blueprint for social feeds, creator updates, and live rooms.',
+ rootName: 'social_hub',
+ packageId: 'social_hub',
+ author: 'MetaBuilder',
+ version: '1.0.0',
+ category: 'social',
+ summary: 'Modern social feed with creator tools and live rooms.',
+ components: socialHubComponents,
+ examples: socialHubExamples,
+ luaScripts: socialHubLuaScripts,
+ tags: ['package', 'social', 'feed', 'lua'],
+ },
+]
diff --git a/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/experimental.ts b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/experimental.ts
new file mode 100644
index 000000000..3180b351b
--- /dev/null
+++ b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/experimental.ts
@@ -0,0 +1,3 @@
+import type { PackageTemplateConfig } from '../../types'
+
+export const EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []
diff --git a/frontends/nextjs/src/lib/nerd-mode-ide/templates/template-configs.ts b/frontends/nextjs/src/lib/nerd-mode-ide/templates/template-configs.ts
index 60badbc19..b134a9822 100644
--- a/frontends/nextjs/src/lib/nerd-mode-ide/templates/template-configs.ts
+++ b/frontends/nextjs/src/lib/nerd-mode-ide/templates/template-configs.ts
@@ -1,267 +1,12 @@
-import type { PackageTemplateConfig, ReactAppTemplateConfig } from './types'
+import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../types'
+import { ADVANCED_PACKAGE_TEMPLATE_CONFIGS } from './configs/advanced'
+import { BASE_PACKAGE_TEMPLATE_CONFIGS, BASE_REACT_APP_TEMPLATE_CONFIG } from './configs/base'
+import { EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS } from './configs/experimental'
-export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = {
- id: 'react_next_starter',
- name: 'Next.js Web App',
- description: 'A clean Next.js starter with app router, hero component, and typed config files.',
- rootName: 'web_app',
- tags: ['nextjs', 'react', 'web', 'starter'],
-}
-
-const socialHubComponents = [
- {
- id: 'social_hub_root',
- type: 'Stack',
- props: { className: 'flex flex-col gap-6' },
- children: [
- {
- id: 'social_hub_hero',
- type: 'Card',
- props: { className: 'p-6' },
- children: [
- {
- id: 'social_hub_heading',
- type: 'Heading',
- props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' },
- children: [],
- },
- {
- id: 'social_hub_subtitle',
- type: 'Text',
- props: { children: 'A modern feed for creator updates, curated stories, and live moments.' },
- children: [],
- },
- ],
- },
- {
- id: 'social_hub_stats',
- type: 'Grid',
- props: { className: 'grid grid-cols-3 gap-4' },
- children: [
- {
- id: 'social_hub_stat_1',
- type: 'Card',
- props: { className: 'p-4' },
- children: [
- {
- id: 'social_hub_stat_label_1',
- type: 'Text',
- props: { children: 'Creators live', className: 'text-sm text-muted-foreground' },
- children: [],
- },
- {
- id: 'social_hub_stat_value_1',
- type: 'Heading',
- props: { children: '128', level: '3', className: 'text-xl font-semibold' },
- children: [],
- },
- ],
- },
- {
- id: 'social_hub_stat_2',
- type: 'Card',
- props: { className: 'p-4' },
- children: [
- {
- id: 'social_hub_stat_label_2',
- type: 'Text',
- props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' },
- children: [],
- },
- {
- id: 'social_hub_stat_value_2',
- type: 'Heading',
- props: { children: '42', level: '3', className: 'text-xl font-semibold' },
- children: [],
- },
- ],
- },
- {
- id: 'social_hub_stat_3',
- type: 'Card',
- props: { className: 'p-4' },
- children: [
- {
- id: 'social_hub_stat_label_3',
- type: 'Text',
- props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' },
- children: [],
- },
- {
- id: 'social_hub_stat_value_3',
- type: 'Heading',
- props: { children: '7', level: '3', className: 'text-xl font-semibold' },
- children: [],
- },
- ],
- },
- ],
- },
- {
- id: 'social_hub_composer',
- type: 'Card',
- props: { className: 'p-4' },
- children: [
- {
- id: 'social_hub_composer_label',
- type: 'Label',
- props: { children: 'Share a quick update' },
- children: [],
- },
- {
- id: 'social_hub_composer_input',
- type: 'Textarea',
- props: { placeholder: 'What are you building today?', rows: 3 },
- children: [],
- },
- {
- id: 'social_hub_composer_actions',
- type: 'Flex',
- props: { className: 'flex gap-2' },
- children: [
- {
- id: 'social_hub_composer_publish',
- type: 'Button',
- props: { children: 'Publish', variant: 'default' },
- children: [],
- },
- {
- id: 'social_hub_composer_media',
- type: 'Button',
- props: { children: 'Add media', variant: 'outline' },
- children: [],
- },
- ],
- },
- ],
- },
- {
- id: 'social_hub_feed',
- type: 'Stack',
- props: { className: 'flex flex-col gap-4' },
- children: [
- {
- id: 'social_hub_feed_post_1',
- type: 'Card',
- props: { className: 'p-5' },
- children: [
- {
- id: 'social_hub_feed_post_1_title',
- type: 'Heading',
- props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' },
- children: [],
- },
- {
- id: 'social_hub_feed_post_1_body',
- type: 'Text',
- props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' },
- children: [],
- },
- {
- id: 'social_hub_feed_post_1_badge',
- type: 'Badge',
- props: { children: 'Community' },
- children: [],
- },
- ],
- },
- {
- id: 'social_hub_feed_post_2',
- type: 'Card',
- props: { className: 'p-5' },
- children: [
- {
- id: 'social_hub_feed_post_2_title',
- type: 'Heading',
- props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' },
- children: [],
- },
- {
- id: 'social_hub_feed_post_2_body',
- type: 'Text',
- props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' },
- children: [],
- },
- {
- id: 'social_hub_feed_post_2_badge',
- type: 'Badge',
- props: { children: 'Spotlight', variant: 'secondary' },
- children: [],
- },
- ],
- },
- ],
- },
- ],
- },
-]
-
-const socialHubExamples = {
- feedItems: [
- {
- id: 'post_001',
- author: 'Nova',
- title: 'Launch day recap',
- summary: 'We shipped live rooms and doubled community sessions.',
- tags: ['launch', 'community'],
- },
- {
- id: 'post_002',
- author: 'Kai',
- title: 'Build log: day 42',
- summary: 'Refined the moderation pipeline and added creator scorecards.',
- tags: ['buildinpublic'],
- },
- ],
- trendingTags: ['#buildinpublic', '#metabuilder', '#live'],
- rooms: [
- { id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true },
- { id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false },
- ],
-}
-
-const socialHubLuaScripts = [
- {
- fileName: 'init.lua',
- description: 'Lifecycle hooks for package installation.',
- code: 'local M = {}\n\nfunction M.on_install(context)\n return { message = "Social Hub installed", version = context.version }\nend\n\nfunction M.on_uninstall()\n return { message = "Social Hub removed" }\nend\n\nreturn M',
- },
- {
- fileName: 'permissions.lua',
- description: 'Role-based access rules for posting and moderation.',
- code: 'local Permissions = {}\n\nfunction Permissions.can_post(user)\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\nend\n\nfunction Permissions.can_moderate(user)\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\nend\n\nreturn Permissions',
- },
- {
- fileName: 'feed_rank.lua',
- description: 'Score feed items based on recency and engagement.',
- code: 'local FeedRank = {}\n\nfunction FeedRank.score(item)\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\n return freshness + engagement\nend\n\nreturn FeedRank',
- },
- {
- fileName: 'moderation.lua',
- description: 'Flag content for review using lightweight heuristics.',
- code: 'local Moderation = {}\n\nfunction Moderation.flag(content)\n local lowered = string.lower(content or "")\n if string.find(lowered, "spam") then\n return { flagged = true, reason = "spam_keyword" }\n end\n return { flagged = false }\nend\n\nreturn Moderation',
- },
- {
- fileName: 'analytics.lua',
- description: 'Aggregate engagement signals for dashboards.',
- code: 'local Analytics = {}\n\nfunction Analytics.aggregate(events)\n local summary = { views = 0, likes = 0, comments = 0 }\n for _, event in ipairs(events or {}) do\n summary.views = summary.views + (event.views or 0)\n summary.likes = summary.likes + (event.likes or 0)\n summary.comments = summary.comments + (event.comments or 0)\n end\n return summary\nend\n\nreturn Analytics',
- },
-]
+export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = BASE_REACT_APP_TEMPLATE_CONFIG
export const PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [
- {
- id: 'package_social_hub',
- name: 'Social Hub Package',
- description: 'A package blueprint for social feeds, creator updates, and live rooms.',
- rootName: 'social_hub',
- packageId: 'social_hub',
- author: 'MetaBuilder',
- version: '1.0.0',
- category: 'social',
- summary: 'Modern social feed with creator tools and live rooms.',
- components: socialHubComponents,
- examples: socialHubExamples,
- luaScripts: socialHubLuaScripts,
- tags: ['package', 'social', 'feed', 'lua'],
- },
+ ...BASE_PACKAGE_TEMPLATE_CONFIGS,
+ ...ADVANCED_PACKAGE_TEMPLATE_CONFIGS,
+ ...EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS,
]
diff --git a/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat-ui-schema.ts b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat-ui-schema.ts
index f5ebfea80..9b6bb1225 100644
--- a/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat-ui-schema.ts
+++ b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat-ui-schema.ts
@@ -1,238 +1,44 @@
import type { PackageContent } from '../../package-types'
+import { ircWebchatComponentConfig } from './irc-webchat/schema/layout'
type IrcWebchatUiSchema = Pick
-export const createIrcWebchatUiSchema = (): IrcWebchatUiSchema => ({
- pages: [
- {
- id: 'page_chat',
- path: '/chat',
- title: 'IRC Webchat',
- level: 2,
- componentTree: [
- {
- id: 'comp_chat_root',
- type: 'IRCWebchat',
- props: {
- channelName: 'general',
- },
- children: [],
+const pages: IrcWebchatUiSchema['pages'] = [
+ {
+ id: 'page_chat',
+ path: '/chat',
+ title: 'IRC Webchat',
+ level: 2,
+ componentTree: [
+ {
+ id: 'comp_chat_root',
+ type: 'IRCWebchat',
+ props: {
+ channelName: 'general',
},
- ],
- requiresAuth: true,
- requiredRole: 'user',
- },
- ],
- componentHierarchy: {
- page_chat: {
- id: 'comp_chat_root',
- type: 'IRCWebchat',
- props: {},
- children: [],
- },
- },
- componentConfigs: {
- IRCWebchat: {
- type: 'IRCWebchat',
- category: 'social',
- label: 'IRC Webchat',
- description: 'IRC-style chat component with channels and commands',
- icon: '💬',
- props: [
- {
- name: 'channelName',
- type: 'string',
- label: 'Channel Name',
- defaultValue: 'general',
- required: false,
- },
- {
- name: 'showSettings',
- type: 'boolean',
- label: 'Show Settings',
- defaultValue: false,
- required: false,
- },
- {
- name: 'height',
- type: 'string',
- label: 'Height',
- defaultValue: '600px',
- required: false,
- },
- ],
- config: {
- layout: 'Card',
- styling: {
- className: 'h-[600px] flex flex-col',
- },
- children: [
- {
- id: 'header',
- type: 'CardHeader',
- props: {
- className: 'border-b border-border pb-3',
- },
- children: [
- {
- id: 'title_container',
- type: 'Flex',
- props: {
- className: 'flex items-center justify-between',
- },
- children: [
- {
- id: 'title',
- type: 'CardTitle',
- props: {
- className: 'flex items-center gap-2 text-lg',
- content: '#{channelName}',
- },
- },
- {
- id: 'actions',
- type: 'Flex',
- props: {
- className: 'flex items-center gap-2',
- },
- children: [
- {
- id: 'user_badge',
- type: 'Badge',
- props: {
- variant: 'secondary',
- className: 'gap-1.5',
- icon: 'Users',
- content: '{onlineUsersCount}',
- },
- },
- {
- id: 'settings_button',
- type: 'Button',
- props: {
- size: 'sm',
- variant: 'ghost',
- icon: 'Gear',
- onClick: 'toggleSettings',
- },
- },
- ],
- },
- ],
- },
- ],
- },
- {
- id: 'content',
- type: 'CardContent',
- props: {
- className: 'flex-1 flex flex-col p-0 overflow-hidden',
- },
- children: [
- {
- id: 'main_area',
- type: 'Flex',
- props: {
- className: 'flex flex-1 overflow-hidden',
- },
- children: [
- {
- id: 'messages_area',
- type: 'ScrollArea',
- props: {
- className: 'flex-1 p-4',
- },
- children: [
- {
- id: 'messages_container',
- type: 'MessageList',
- props: {
- className: 'space-y-2 font-mono text-sm',
- dataSource: 'messages',
- itemRenderer: 'renderMessage',
- },
- },
- ],
- },
- {
- id: 'sidebar',
- type: 'Container',
- props: {
- className: 'w-48 border-l border-border p-4 bg-muted/20',
- conditional: 'showSettings',
- },
- children: [
- {
- id: 'sidebar_title',
- type: 'Heading',
- props: {
- level: '4',
- className: 'font-semibold text-sm mb-3',
- content: 'Online Users',
- },
- },
- {
- id: 'users_list',
- type: 'UserList',
- props: {
- className: 'space-y-1.5 text-sm',
- dataSource: 'onlineUsers',
- },
- },
- ],
- },
- ],
- },
- {
- id: 'input_area',
- type: 'Container',
- props: {
- className: 'border-t border-border p-4',
- },
- children: [
- {
- id: 'input_row',
- type: 'Flex',
- props: {
- className: 'flex gap-2',
- },
- children: [
- {
- id: 'message_input',
- type: 'Input',
- props: {
- className: 'flex-1 font-mono',
- placeholder: 'Type a message... (/help for commands)',
- onKeyPress: 'handleKeyPress',
- value: '{inputMessage}',
- onChange: 'updateInputMessage',
- },
- },
- {
- id: 'send_button',
- type: 'Button',
- props: {
- size: 'icon',
- icon: 'PaperPlaneTilt',
- onClick: 'handleSendMessage',
- },
- },
- ],
- },
- {
- id: 'help_text',
- type: 'Text',
- props: {
- className: 'text-xs text-muted-foreground mt-2',
- content: 'Press Enter to send. Type /help for commands.',
- },
- },
- ],
- },
- ],
- },
- ],
+ children: [],
},
- },
+ ],
+ requiresAuth: true,
+ requiredRole: 'user',
},
+]
+
+const componentHierarchy: IrcWebchatUiSchema['componentHierarchy'] = {
+ page_chat: {
+ id: 'comp_chat_root',
+ type: 'IRCWebchat',
+ props: {},
+ children: [],
+ },
+}
+
+const componentConfigs: IrcWebchatUiSchema['componentConfigs'] = {
+ IRCWebchat: ircWebchatComponentConfig,
+}
+
+export const createIrcWebchatUiSchema = (): IrcWebchatUiSchema => ({
+ pages,
+ componentHierarchy,
+ componentConfigs,
})
diff --git a/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat-workflow-actions.ts b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat-workflow-actions.ts
index 6b86117d8..249358653 100644
--- a/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat-workflow-actions.ts
+++ b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat-workflow-actions.ts
@@ -1,197 +1,13 @@
import type { PackageContent } from '../../package-types'
+import { commandActions } from './irc-webchat/actions/commands'
+import { eventActions } from './irc-webchat/actions/events'
type IrcWebchatWorkflows = Pick
-export const createIrcWebchatWorkflowActions = (): IrcWebchatWorkflows => ({
- workflows: [
- {
- id: 'workflow_send_message',
- name: 'Send Chat Message',
- description: 'Workflow for sending a chat message',
- nodes: [],
- edges: [],
- enabled: true,
- },
- {
- id: 'workflow_join_channel',
- name: 'Join Channel',
- description: 'Workflow for joining a chat channel',
- nodes: [],
- edges: [],
- enabled: true,
- },
- ],
- luaScripts: [
- {
- id: 'lua_irc_send_message',
- name: 'Send IRC Message',
- description: 'Sends a message to the chat channel',
- code: `-- Send IRC Message
-function sendMessage(channelId, username, userId, message)
-local msgId = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999)
-local msg = {
- id = msgId,
- channelId = channelId,
- username = username,
- userId = userId,
- message = message,
- type = "message",
- timestamp = os.time() * 1000
-}
-log("Sending message: " .. message)
-return msg
-end
-
-return sendMessage`,
- parameters: [
- { name: 'channelId', type: 'string' },
- { name: 'username', type: 'string' },
- { name: 'userId', type: 'string' },
- { name: 'message', type: 'string' },
- ],
- returnType: 'table',
- },
- {
- id: 'lua_irc_handle_command',
- name: 'Handle IRC Command',
- description: 'Processes IRC commands like /help, /users, etc',
- code: `-- Handle IRC Command
-function handleCommand(command, channelId, username, onlineUsers)
-local parts = {}
-for part in string.gmatch(command, "%S+") do
- table.insert(parts, part)
-end
-
-local cmd = parts[1]:lower()
-local response = {
- id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
- username = "System",
- userId = "system",
- type = "system",
- timestamp = os.time() * 1000,
- channelId = channelId
-}
-
-if cmd == "/help" then
- response.message = "Available commands: /help, /users, /clear, /me "
-elseif cmd == "/users" then
- local userCount = #onlineUsers
- local userList = table.concat(onlineUsers, ", ")
- response.message = "Online users (" .. userCount .. "): " .. userList
-elseif cmd == "/clear" then
- response.message = "CLEAR_MESSAGES"
- response.type = "command"
-elseif cmd == "/me" then
- if #parts > 1 then
- local action = table.concat(parts, " ", 2)
- response.message = action
- response.username = username
- response.userId = username
- response.type = "system"
- else
- response.message = "Usage: /me "
- end
-else
- response.message = "Unknown command: " .. cmd .. ". Type /help for available commands."
-end
-
-return response
-end
-
-return handleCommand`,
- parameters: [
- { name: 'command', type: 'string' },
- { name: 'channelId', type: 'string' },
- { name: 'username', type: 'string' },
- { name: 'onlineUsers', type: 'table' },
- ],
- returnType: 'table',
- },
- {
- id: 'lua_irc_format_time',
- name: 'Format Timestamp',
- description: 'Formats a timestamp for display',
- code: `-- Format Timestamp
-function formatTime(timestamp)
-local date = os.date("*t", timestamp / 1000)
-local hour = date.hour
-local ampm = "AM"
-
-if hour >= 12 then
- ampm = "PM"
- if hour > 12 then
- hour = hour - 12
- end
-end
-
-if hour == 0 then
- hour = 12
-end
-
-return string.format("%02d:%02d %s", hour, date.min, ampm)
-end
-
-return formatTime`,
- parameters: [
- { name: 'timestamp', type: 'number' },
- ],
- returnType: 'string',
- },
- {
- id: 'lua_irc_user_join',
- name: 'User Join Channel',
- description: 'Handles user joining a channel',
- code: `-- User Join Channel
-function userJoin(channelId, username, userId)
-local joinMsg = {
- id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
- channelId = channelId,
- username = "System",
- userId = "system",
- message = username .. " has joined the channel",
- type = "join",
- timestamp = os.time() * 1000
-}
-
-log(username .. " joined channel " .. channelId)
-return joinMsg
-end
-
-return userJoin`,
- parameters: [
- { name: 'channelId', type: 'string' },
- { name: 'username', type: 'string' },
- { name: 'userId', type: 'string' },
- ],
- returnType: 'table',
- },
- {
- id: 'lua_irc_user_leave',
- name: 'User Leave Channel',
- description: 'Handles user leaving a channel',
- code: `-- User Leave Channel
-function userLeave(channelId, username, userId)
-local leaveMsg = {
- id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
- channelId = channelId,
- username = "System",
- userId = "system",
- message = username .. " has left the channel",
- type = "leave",
- timestamp = os.time() * 1000
-}
-
-log(username .. " left channel " .. channelId)
-return leaveMsg
-end
-
-return userLeave`,
- parameters: [
- { name: 'channelId', type: 'string' },
- { name: 'username', type: 'string' },
- { name: 'userId', type: 'string' },
- ],
- returnType: 'table',
- },
- ],
+const mergeActions = (...actions: IrcWebchatWorkflows[]): IrcWebchatWorkflows => ({
+ workflows: actions.flatMap((action) => action.workflows),
+ luaScripts: actions.flatMap((action) => action.luaScripts),
})
+
+export const createIrcWebchatWorkflowActions = (): IrcWebchatWorkflows =>
+ mergeActions(commandActions, eventActions)
diff --git a/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/actions/commands.ts b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/actions/commands.ts
new file mode 100644
index 000000000..01c4a10fe
--- /dev/null
+++ b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/actions/commands.ts
@@ -0,0 +1,103 @@
+import type { PackageContent } from '../../../../package-types'
+
+type IrcWebchatWorkflowActions = Pick
+
+export const commandActions: IrcWebchatWorkflowActions = {
+ workflows: [
+ {
+ id: 'workflow_send_message',
+ name: 'Send Chat Message',
+ description: 'Workflow for sending a chat message',
+ nodes: [],
+ edges: [],
+ enabled: true,
+ },
+ ],
+ luaScripts: [
+ {
+ id: 'lua_irc_send_message',
+ name: 'Send IRC Message',
+ description: 'Sends a message to the chat channel',
+ code: `-- Send IRC Message
+function sendMessage(channelId, username, userId, message)
+local msgId = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999)
+local msg = {
+ id = msgId,
+ channelId = channelId,
+ username = username,
+ userId = userId,
+ message = message,
+ type = "message",
+ timestamp = os.time() * 1000
+}
+log("Sending message: " .. message)
+return msg
+end
+
+return sendMessage`,
+ parameters: [
+ { name: 'channelId', type: 'string' },
+ { name: 'username', type: 'string' },
+ { name: 'userId', type: 'string' },
+ { name: 'message', type: 'string' },
+ ],
+ returnType: 'table',
+ },
+ {
+ id: 'lua_irc_handle_command',
+ name: 'Handle IRC Command',
+ description: 'Processes IRC commands like /help, /users, etc',
+ code: `-- Handle IRC Command
+function handleCommand(command, channelId, username, onlineUsers)
+local parts = {}
+for part in string.gmatch(command, "%S+") do
+ table.insert(parts, part)
+end
+
+local cmd = parts[1]:lower()
+local response = {
+ id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
+ username = "System",
+ userId = "system",
+ type = "system",
+ timestamp = os.time() * 1000,
+ channelId = channelId
+}
+
+if cmd == "/help" then
+ response.message = "Available commands: /help, /users, /clear, /me "
+elseif cmd == "/users" then
+ local userCount = #onlineUsers
+ local userList = table.concat(onlineUsers, ", ")
+ response.message = "Online users (" .. userCount .. "): " .. userList
+elseif cmd == "/clear" then
+ response.message = "CLEAR_MESSAGES"
+ response.type = "command"
+elseif cmd == "/me" then
+ if #parts > 1 then
+ local action = table.concat(parts, " ", 2)
+ response.message = action
+ response.username = username
+ response.userId = username
+ response.type = "system"
+ else
+ response.message = "Usage: /me "
+ end
+else
+ response.message = "Unknown command: " .. cmd .. ". Type /help for available commands."
+end
+
+return response
+end
+
+return handleCommand`,
+ parameters: [
+ { name: 'command', type: 'string' },
+ { name: 'channelId', type: 'string' },
+ { name: 'username', type: 'string' },
+ { name: 'onlineUsers', type: 'table' },
+ ],
+ returnType: 'table',
+ },
+ ],
+}
diff --git a/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/actions/events.ts b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/actions/events.ts
new file mode 100644
index 000000000..6ad746480
--- /dev/null
+++ b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/actions/events.ts
@@ -0,0 +1,104 @@
+import type { PackageContent } from '../../../../package-types'
+
+type IrcWebchatWorkflowActions = Pick
+
+export const eventActions: IrcWebchatWorkflowActions = {
+ workflows: [
+ {
+ id: 'workflow_join_channel',
+ name: 'Join Channel',
+ description: 'Workflow for joining a chat channel',
+ nodes: [],
+ edges: [],
+ enabled: true,
+ },
+ ],
+ luaScripts: [
+ {
+ id: 'lua_irc_format_time',
+ name: 'Format Timestamp',
+ description: 'Formats a timestamp for display',
+ code: `-- Format Timestamp
+function formatTime(timestamp)
+local date = os.date("*t", timestamp / 1000)
+local hour = date.hour
+local ampm = "AM"
+
+if hour >= 12 then
+ ampm = "PM"
+ if hour > 12 then
+ hour = hour - 12
+ end
+end
+
+if hour == 0 then
+ hour = 12
+end
+
+return string.format("%02d:%02d %s", hour, date.min, ampm)
+end
+
+return formatTime`,
+ parameters: [
+ { name: 'timestamp', type: 'number' },
+ ],
+ returnType: 'string',
+ },
+ {
+ id: 'lua_irc_user_join',
+ name: 'User Join Channel',
+ description: 'Handles user joining a channel',
+ code: `-- User Join Channel
+function userJoin(channelId, username, userId)
+local joinMsg = {
+ id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
+ channelId = channelId,
+ username = "System",
+ userId = "system",
+ message = username .. " has joined the channel",
+ type = "join",
+ timestamp = os.time() * 1000
+}
+
+log(username .. " joined channel " .. channelId)
+return joinMsg
+end
+
+return userJoin`,
+ parameters: [
+ { name: 'channelId', type: 'string' },
+ { name: 'username', type: 'string' },
+ { name: 'userId', type: 'string' },
+ ],
+ returnType: 'table',
+ },
+ {
+ id: 'lua_irc_user_leave',
+ name: 'User Leave Channel',
+ description: 'Handles user leaving a channel',
+ code: `-- User Leave Channel
+function userLeave(channelId, username, userId)
+local leaveMsg = {
+ id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
+ channelId = channelId,
+ username = "System",
+ userId = "system",
+ message = username .. " has left the channel",
+ type = "leave",
+ timestamp = os.time() * 1000
+}
+
+log(username .. " left channel " .. channelId)
+return leaveMsg
+end
+
+return userLeave`,
+ parameters: [
+ { name: 'channelId', type: 'string' },
+ { name: 'username', type: 'string' },
+ { name: 'userId', type: 'string' },
+ ],
+ returnType: 'table',
+ },
+ ],
+}
diff --git a/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/schema/layout.ts b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/schema/layout.ts
new file mode 100644
index 000000000..32d038178
--- /dev/null
+++ b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/schema/layout.ts
@@ -0,0 +1,143 @@
+import { createMessageArea, createMessageInputArea } from './messages'
+
+const createHeaderSection = () => ({
+ id: 'header',
+ type: 'CardHeader',
+ props: {
+ className: 'border-b border-border pb-3',
+ },
+ children: [
+ {
+ id: 'title_container',
+ type: 'Flex',
+ props: {
+ className: 'flex items-center justify-between',
+ },
+ children: [
+ {
+ id: 'title',
+ type: 'CardTitle',
+ props: {
+ className: 'flex items-center gap-2 text-lg',
+ content: '#{channelName}',
+ },
+ },
+ {
+ id: 'actions',
+ type: 'Flex',
+ props: {
+ className: 'flex items-center gap-2',
+ },
+ children: [
+ {
+ id: 'user_badge',
+ type: 'Badge',
+ props: {
+ variant: 'secondary',
+ className: 'gap-1.5',
+ icon: 'Users',
+ content: '{onlineUsersCount}',
+ },
+ },
+ {
+ id: 'settings_button',
+ type: 'Button',
+ props: {
+ size: 'sm',
+ variant: 'ghost',
+ icon: 'Gear',
+ onClick: 'toggleSettings',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+})
+
+const createSidebar = () => ({
+ id: 'sidebar',
+ type: 'Container',
+ props: {
+ className: 'w-48 border-l border-border p-4 bg-muted/20',
+ conditional: 'showSettings',
+ },
+ children: [
+ {
+ id: 'sidebar_title',
+ type: 'Heading',
+ props: {
+ level: '4',
+ className: 'font-semibold text-sm mb-3',
+ content: 'Online Users',
+ },
+ },
+ {
+ id: 'users_list',
+ type: 'UserList',
+ props: {
+ className: 'space-y-1.5 text-sm',
+ dataSource: 'onlineUsers',
+ },
+ },
+ ],
+})
+
+export const ircWebchatComponentConfig = {
+ type: 'IRCWebchat',
+ category: 'social',
+ label: 'IRC Webchat',
+ description: 'IRC-style chat component with channels and commands',
+ icon: '💬',
+ props: [
+ {
+ name: 'channelName',
+ type: 'string',
+ label: 'Channel Name',
+ defaultValue: 'general',
+ required: false,
+ },
+ {
+ name: 'showSettings',
+ type: 'boolean',
+ label: 'Show Settings',
+ defaultValue: false,
+ required: false,
+ },
+ {
+ name: 'height',
+ type: 'string',
+ label: 'Height',
+ defaultValue: '600px',
+ required: false,
+ },
+ ],
+ config: {
+ layout: 'Card',
+ styling: {
+ className: 'h-[600px] flex flex-col',
+ },
+ children: [
+ createHeaderSection(),
+ {
+ id: 'content',
+ type: 'CardContent',
+ props: {
+ className: 'flex-1 flex flex-col p-0 overflow-hidden',
+ },
+ children: [
+ {
+ id: 'main_area',
+ type: 'Flex',
+ props: {
+ className: 'flex flex-1 overflow-hidden',
+ },
+ children: [createMessageArea(), createSidebar()],
+ },
+ createMessageInputArea(),
+ ],
+ },
+ ],
+ },
+}
diff --git a/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/schema/messages.ts b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/schema/messages.ts
new file mode 100644
index 000000000..10428363d
--- /dev/null
+++ b/frontends/nextjs/src/lib/packages/core/package-definitions/set-b/irc-webchat/schema/messages.ts
@@ -0,0 +1,65 @@
+export const createMessageArea = () => ({
+ id: 'messages_area',
+ type: 'ScrollArea',
+ props: {
+ className: 'flex-1 p-4',
+ },
+ children: [
+ {
+ id: 'messages_container',
+ type: 'MessageList',
+ props: {
+ className: 'space-y-2 font-mono text-sm',
+ dataSource: 'messages',
+ itemRenderer: 'renderMessage',
+ },
+ },
+ ],
+})
+
+export const createMessageInputArea = () => ({
+ id: 'input_area',
+ type: 'Container',
+ props: {
+ className: 'border-t border-border p-4',
+ },
+ children: [
+ {
+ id: 'input_row',
+ type: 'Flex',
+ props: {
+ className: 'flex gap-2',
+ },
+ children: [
+ {
+ id: 'message_input',
+ type: 'Input',
+ props: {
+ className: 'flex-1 font-mono',
+ placeholder: 'Type a message... (/help for commands)',
+ onKeyPress: 'handleKeyPress',
+ value: '{inputMessage}',
+ onChange: 'updateInputMessage',
+ },
+ },
+ {
+ id: 'send_button',
+ type: 'Button',
+ props: {
+ size: 'icon',
+ icon: 'PaperPlaneTilt',
+ onClick: 'handleSendMessage',
+ },
+ },
+ ],
+ },
+ {
+ id: 'help_text',
+ type: 'Text',
+ props: {
+ className: 'text-xs text-muted-foreground mt-2',
+ content: 'Press Enter to send. Type /help for commands.',
+ },
+ },
+ ],
+})
diff --git a/frontends/nextjs/src/lib/packages/tests/package-glue.test.ts b/frontends/nextjs/src/lib/packages/tests/package-glue.test.ts
index 9b810b9d3..e1a2ff01b 100644
--- a/frontends/nextjs/src/lib/packages/tests/package-glue.test.ts
+++ b/frontends/nextjs/src/lib/packages/tests/package-glue.test.ts
@@ -1,30 +1,13 @@
/**
- * Tests for package-glue module - Package registry utilities
- * Following parameterized test pattern per project conventions
+ * Shared helpers for package-glue test suites.
+ * Individual suites live under ./package-glue/.
*/
-import { describe, it, expect, beforeEach, vi } from 'vitest'
-import type { PackageRegistry, PackageDefinition, LuaScriptFile } from './package-glue'
-import {
- getPackage,
- getPackagesByCategory,
- getPackageComponents,
- getPackageScripts,
- getPackageScriptFiles,
- getAllPackageScripts,
- getPackageExamples,
- checkDependencies,
- installPackageComponents,
- installPackageScripts,
- installPackage,
- uninstallPackage,
- getInstalledPackages,
- isPackageInstalled,
- exportAllPackagesForSeed,
-} from './package-glue'
+import { vi } from 'vitest'
-// Helper to create mock package definitions
-function createMockPackage(
+import type { LuaScriptFile, PackageDefinition, PackageRegistry } from '../package-glue'
+
+export function createMockPackage(
id: string,
options: Partial = {}
): PackageDefinition {
@@ -44,8 +27,7 @@ function createMockPackage(
}
}
-// Helper to create mock registry
-function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
+export function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
const registry: PackageRegistry = {}
for (const pkg of packages) {
registry[pkg.packageId] = pkg
@@ -53,8 +35,9 @@ function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
return registry
}
-// Helper to create mock database
-function createMockDb() {
+export type MockDb = ReturnType
+
+export function createMockDb() {
const data: Record> = {}
return {
set: vi.fn(async (table: string, key: string, value: any) => {
@@ -74,546 +57,7 @@ function createMockDb() {
}
}
-describe('package-glue', () => {
- describe('getPackage', () => {
- it.each([
- {
- name: 'returns package when found',
- registry: createMockRegistry([createMockPackage('test_pkg')]),
- packageId: 'test_pkg',
- expectFound: true,
- },
- {
- name: 'returns undefined when not found',
- registry: createMockRegistry([createMockPackage('other_pkg')]),
- packageId: 'test_pkg',
- expectFound: false,
- },
- {
- name: 'returns undefined from empty registry',
- registry: createMockRegistry([]),
- packageId: 'test_pkg',
- expectFound: false,
- },
- ])('should handle $name', ({ registry, packageId, expectFound }) => {
- const result = getPackage(registry, packageId)
-
- if (expectFound) {
- expect(result).toBeDefined()
- expect(result?.packageId).toBe(packageId)
- } else {
- expect(result).toBeUndefined()
- }
- })
- })
-
- describe('getPackagesByCategory', () => {
- const mixedRegistry = createMockRegistry([
- createMockPackage('pkg1', { category: 'ui' }),
- createMockPackage('pkg2', { category: 'ui' }),
- createMockPackage('pkg3', { category: 'data' }),
- createMockPackage('pkg4', { category: 'util' }),
- ])
-
- it.each([
- {
- name: 'returns packages in category',
- registry: mixedRegistry,
- category: 'ui',
- expectedCount: 2,
- },
- {
- name: 'returns single package in category',
- registry: mixedRegistry,
- category: 'data',
- expectedCount: 1,
- },
- {
- name: 'returns empty array for unknown category',
- registry: mixedRegistry,
- category: 'unknown',
- expectedCount: 0,
- },
- {
- name: 'returns empty array from empty registry',
- registry: createMockRegistry([]),
- category: 'ui',
- expectedCount: 0,
- },
- ])('should handle $name', ({ registry, category, expectedCount }) => {
- const result = getPackagesByCategory(registry, category)
-
- expect(result).toHaveLength(expectedCount)
- result.forEach((pkg) => {
- expect(pkg.category).toBe(category)
- })
- })
- })
-
- describe('getPackageComponents', () => {
- it.each([
- {
- name: 'returns components array',
- pkg: createMockPackage('test', {
- components: [{ id: 'c1' }, { id: 'c2' }],
- }),
- expectedLength: 2,
- },
- {
- name: 'returns empty array when no components',
- pkg: createMockPackage('test', { components: [] }),
- expectedLength: 0,
- },
- {
- name: 'returns empty array when components is undefined',
- pkg: { ...createMockPackage('test'), components: undefined as any },
- expectedLength: 0,
- },
- ])('should handle $name', ({ pkg, expectedLength }) => {
- const result = getPackageComponents(pkg)
-
- expect(Array.isArray(result)).toBe(true)
- expect(result).toHaveLength(expectedLength)
- })
- })
-
- describe('getPackageScripts', () => {
- it.each([
- {
- name: 'returns scripts string',
- pkg: createMockPackage('test', { scripts: 'return 42' }),
- expected: 'return 42',
- },
- {
- name: 'returns empty string when undefined',
- pkg: createMockPackage('test'),
- expected: '',
- },
- {
- name: 'returns empty string when null',
- pkg: { ...createMockPackage('test'), scripts: null as any },
- expected: '',
- },
- ])('should handle $name', ({ pkg, expected }) => {
- const result = getPackageScripts(pkg)
-
- expect(result).toBe(expected)
- })
- })
-
- describe('getPackageScriptFiles', () => {
- const mockScriptFiles: LuaScriptFile[] = [
- { name: 'init', path: 'scripts/init.lua', code: 'return {}' },
- { name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
- ]
-
- it.each([
- {
- name: 'returns script files array',
- pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
- expectedLength: 2,
- },
- {
- name: 'returns empty array when undefined',
- pkg: createMockPackage('test'),
- expectedLength: 0,
- },
- {
- name: 'returns empty array when empty',
- pkg: createMockPackage('test', { scriptFiles: [] }),
- expectedLength: 0,
- },
- ])('should handle $name', ({ pkg, expectedLength }) => {
- const result = getPackageScriptFiles(pkg)
-
- expect(Array.isArray(result)).toBe(true)
- expect(result).toHaveLength(expectedLength)
- })
- })
-
- describe('getAllPackageScripts', () => {
- const mockScriptFiles: LuaScriptFile[] = [
- { name: 'init', path: 'scripts/init.lua', code: 'return {}' },
- ]
-
- it.each([
- {
- name: 'returns both legacy and files',
- pkg: createMockPackage('test', {
- scripts: 'legacy code',
- scriptFiles: mockScriptFiles,
- }),
- expectedLegacy: 'legacy code',
- expectedFilesLength: 1,
- },
- {
- name: 'handles missing legacy',
- pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
- expectedLegacy: '',
- expectedFilesLength: 1,
- },
- {
- name: 'handles missing files',
- pkg: createMockPackage('test', { scripts: 'code' }),
- expectedLegacy: 'code',
- expectedFilesLength: 0,
- },
- {
- name: 'handles both missing',
- pkg: createMockPackage('test'),
- expectedLegacy: '',
- expectedFilesLength: 0,
- },
- ])('should handle $name', ({ pkg, expectedLegacy, expectedFilesLength }) => {
- const result = getAllPackageScripts(pkg)
-
- expect(result.legacy).toBe(expectedLegacy)
- expect(result.files).toHaveLength(expectedFilesLength)
- })
- })
-
- describe('getPackageExamples', () => {
- it.each([
- {
- name: 'returns examples object',
- pkg: createMockPackage('test', {
- examples: { demo: 'code' },
- }),
- hasExamples: true,
- },
- {
- name: 'returns empty object when undefined',
- pkg: createMockPackage('test'),
- hasExamples: false,
- },
- ])('should handle $name', ({ pkg, hasExamples }) => {
- const result = getPackageExamples(pkg)
-
- expect(typeof result).toBe('object')
- if (hasExamples) {
- expect(result.demo).toBe('code')
- } else {
- expect(Object.keys(result)).toHaveLength(0)
- }
- })
- })
-
- describe('checkDependencies', () => {
- it.each([
- {
- name: 'satisfied when no dependencies',
- registry: createMockRegistry([createMockPackage('test')]),
- packageId: 'test',
- expectedSatisfied: true,
- expectedMissing: [],
- },
- {
- name: 'satisfied when all dependencies present',
- registry: createMockRegistry([
- createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
- createMockPackage('dep1'),
- createMockPackage('dep2'),
- ]),
- packageId: 'test',
- expectedSatisfied: true,
- expectedMissing: [],
- },
- {
- name: 'not satisfied when dependencies missing',
- registry: createMockRegistry([
- createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
- createMockPackage('dep1'),
- ]),
- packageId: 'test',
- expectedSatisfied: false,
- expectedMissing: ['dep2'],
- },
- {
- name: 'not satisfied when package not found',
- registry: createMockRegistry([]),
- packageId: 'nonexistent',
- expectedSatisfied: false,
- expectedMissing: ['nonexistent'],
- },
- ])('should handle $name', ({ registry, packageId, expectedSatisfied, expectedMissing }) => {
- const result = checkDependencies(registry, packageId)
-
- expect(result.satisfied).toBe(expectedSatisfied)
- expect(result.missing).toEqual(expectedMissing)
- })
- })
-
- describe('installPackageComponents', () => {
- it('should install all components to database', async () => {
- const db = createMockDb()
- const pkg = createMockPackage('test', {
- components: [
- { id: 'comp1', type: 'button' },
- { id: 'comp2', type: 'form' },
- ],
- })
-
- await installPackageComponents(pkg, db)
-
- expect(db.set).toHaveBeenCalledTimes(2)
- expect(db.set).toHaveBeenCalledWith('components', 'comp1', { id: 'comp1', type: 'button' })
- expect(db.set).toHaveBeenCalledWith('components', 'comp2', { id: 'comp2', type: 'form' })
- })
-
- it('should handle empty components array', async () => {
- const db = createMockDb()
- const pkg = createMockPackage('test', { components: [] })
-
- await installPackageComponents(pkg, db)
-
- expect(db.set).not.toHaveBeenCalled()
- })
- })
-
- describe('installPackageScripts', () => {
- it('should install legacy script', async () => {
- const db = createMockDb()
- const pkg = createMockPackage('test', { scripts: 'return 42' })
-
- await installPackageScripts(pkg, db)
-
- expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test', {
- id: 'package_test',
- name: 'Package test Scripts',
- code: 'return 42',
- category: 'package',
- packageId: 'test',
- })
- })
-
- it('should install multi-file scripts', async () => {
- const db = createMockDb()
- const pkg = createMockPackage('test', {
- scriptFiles: [
- { name: 'init', path: 'scripts/init.lua', code: 'return {}', category: 'setup', description: 'Init script' },
- ],
- })
-
- await installPackageScripts(pkg, db)
-
- expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test_init', {
- id: 'package_test_init',
- name: 'Package test - init',
- code: 'return {}',
- category: 'setup',
- packageId: 'test',
- path: 'scripts/init.lua',
- description: 'Init script',
- })
- })
-
- it('should install both legacy and multi-file scripts', async () => {
- const db = createMockDb()
- const pkg = createMockPackage('test', {
- scripts: 'legacy',
- scriptFiles: [{ name: 'utils', path: 'scripts/utils.lua', code: 'helpers' }],
- })
-
- await installPackageScripts(pkg, db)
-
- expect(db.set).toHaveBeenCalledTimes(2)
- })
- })
-
- describe('installPackage', () => {
- it.each([
- {
- name: 'successfully installs package',
- registry: createMockRegistry([createMockPackage('test')]),
- packageId: 'test',
- expectSuccess: true,
- },
- {
- name: 'fails when package not found',
- registry: createMockRegistry([]),
- packageId: 'nonexistent',
- expectSuccess: false,
- expectedError: 'Package nonexistent not found',
- },
- {
- name: 'fails when dependencies missing',
- registry: createMockRegistry([
- createMockPackage('test', { dependencies: ['missing'] }),
- ]),
- packageId: 'test',
- expectSuccess: false,
- expectedError: 'Missing dependencies: missing',
- },
- ])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
- const db = createMockDb()
-
- const result = await installPackage(registry, packageId, db)
-
- expect(result.success).toBe(expectSuccess)
- if (expectedError) {
- expect(result.error).toContain(expectedError)
- }
- if (expectSuccess) {
- expect(db.set).toHaveBeenCalledWith(
- 'installed_packages',
- packageId,
- expect.objectContaining({ packageId, name: expect.any(String) })
- )
- }
- })
- })
-
- describe('uninstallPackage', () => {
- it.each([
- {
- name: 'successfully uninstalls package',
- registry: createMockRegistry([
- createMockPackage('test', { components: [{ id: 'c1' }] }),
- ]),
- packageId: 'test',
- expectSuccess: true,
- },
- {
- name: 'fails when package not found',
- registry: createMockRegistry([]),
- packageId: 'nonexistent',
- expectSuccess: false,
- expectedError: 'Package nonexistent not found',
- },
- ])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
- const db = createMockDb()
-
- const result = await uninstallPackage(registry, packageId, db)
-
- expect(result.success).toBe(expectSuccess)
- if (expectedError) {
- expect(result.error).toContain(expectedError)
- }
- if (expectSuccess) {
- expect(db.delete).toHaveBeenCalledWith('installed_packages', packageId)
- }
- })
- })
-
- describe('getInstalledPackages', () => {
- it('should return installed package IDs', async () => {
- const db = createMockDb()
- db._data['installed_packages'] = { pkg1: {}, pkg2: {} }
-
- const result = await getInstalledPackages(db)
-
- expect(result).toEqual(['pkg1', 'pkg2'])
- })
-
- it('should return empty array on error', async () => {
- const db = createMockDb()
- db.getAll = vi.fn().mockRejectedValue(new Error('DB error'))
-
- const result = await getInstalledPackages(db)
-
- expect(result).toEqual([])
- })
-
- it('should return empty array when no packages', async () => {
- const db = createMockDb()
-
- const result = await getInstalledPackages(db)
-
- expect(result).toEqual([])
- })
- })
-
- describe('isPackageInstalled', () => {
- it.each([
- {
- name: 'returns true when installed',
- setupDb: (db: ReturnType) => {
- db._data['installed_packages'] = { test: { packageId: 'test' } }
- },
- packageId: 'test',
- expected: true,
- },
- {
- name: 'returns false when not installed',
- setupDb: () => {},
- packageId: 'test',
- expected: false,
- },
- ])('should handle $name', async ({ setupDb, packageId, expected }) => {
- const db = createMockDb()
- setupDb(db)
-
- const result = await isPackageInstalled(packageId, db)
-
- expect(result).toBe(expected)
- })
-
- it('should return false on error', async () => {
- const db = createMockDb()
- db.get = vi.fn().mockRejectedValue(new Error('DB error'))
-
- const result = await isPackageInstalled('test', db)
-
- expect(result).toBe(false)
- })
- })
-
- describe('exportAllPackagesForSeed', () => {
- it('should export all package data', () => {
- const registry = createMockRegistry([
- createMockPackage('pkg1', {
- name: 'Package 1',
- components: [{ id: 'c1' }],
- scripts: 'lua code',
- }),
- createMockPackage('pkg2', {
- name: 'Package 2',
- scriptFiles: [{ name: 'init', path: 'scripts/init.lua', code: 'return {}' }],
- }),
- ])
-
- const result = exportAllPackagesForSeed(registry)
-
- expect(result.components).toHaveLength(1)
- expect(result.scripts).toHaveLength(2) // 1 legacy + 1 file
- expect(result.packages).toHaveLength(2)
- expect(result.packages[0].packageId).toBe('pkg1')
- expect(result.packages[1].packageId).toBe('pkg2')
- })
-
- it('should handle empty registry', () => {
- const result = exportAllPackagesForSeed({})
-
- expect(result.components).toEqual([])
- expect(result.scripts).toEqual([])
- expect(result.packages).toEqual([])
- })
-
- it('should include script metadata', () => {
- const registry = createMockRegistry([
- createMockPackage('pkg', {
- scriptFiles: [
- {
- name: 'utils',
- path: 'scripts/utils.lua',
- code: 'return true',
- category: 'helpers',
- description: 'Utility functions',
- },
- ],
- }),
- ])
-
- const result = exportAllPackagesForSeed(registry)
-
- expect(result.scripts[0]).toMatchObject({
- id: 'package_pkg_utils',
- name: 'Package pkg - utils',
- code: 'return true',
- category: 'helpers',
- path: 'scripts/utils.lua',
- description: 'Utility functions',
- })
- })
- })
-})
+export const mockScriptFiles: LuaScriptFile[] = [
+ { name: 'init', path: 'scripts/init.lua', code: 'return {}' },
+ { name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
+]
diff --git a/frontends/nextjs/src/lib/packages/tests/package-glue/execution.test.ts b/frontends/nextjs/src/lib/packages/tests/package-glue/execution.test.ts
new file mode 100644
index 000000000..36d7ae1c9
--- /dev/null
+++ b/frontends/nextjs/src/lib/packages/tests/package-glue/execution.test.ts
@@ -0,0 +1,229 @@
+import { describe, it, expect, vi } from 'vitest'
+
+import {
+ getInstalledPackages,
+ installPackage,
+ installPackageComponents,
+ installPackageScripts,
+ isPackageInstalled,
+ uninstallPackage,
+} from '../../package-glue'
+import { createMockDb, createMockPackage, createMockRegistry, mockScriptFiles } from '../package-glue.test'
+
+describe('package-glue execution', () => {
+ describe('installPackageComponents', () => {
+ it('should install all components to database', async () => {
+ const db = createMockDb()
+ const pkg = createMockPackage('test', {
+ components: [
+ { id: 'comp1', type: 'button' },
+ { id: 'comp2', type: 'form' },
+ ],
+ })
+
+ await installPackageComponents(pkg, db)
+
+ expect(db.set).toHaveBeenCalledTimes(2)
+ expect(db.set).toHaveBeenCalledWith('components', 'comp1', { id: 'comp1', type: 'button' })
+ expect(db.set).toHaveBeenCalledWith('components', 'comp2', { id: 'comp2', type: 'form' })
+ })
+
+ it('should handle empty components array', async () => {
+ const db = createMockDb()
+ const pkg = createMockPackage('test', { components: [] })
+
+ await installPackageComponents(pkg, db)
+
+ expect(db.set).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('installPackageScripts', () => {
+ it('should install legacy script', async () => {
+ const db = createMockDb()
+ const pkg = createMockPackage('test', { scripts: 'return 42' })
+
+ await installPackageScripts(pkg, db)
+
+ expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test', {
+ id: 'package_test',
+ name: 'Package test Scripts',
+ code: 'return 42',
+ category: 'package',
+ packageId: 'test',
+ })
+ })
+
+ it('should install multi-file scripts', async () => {
+ const db = createMockDb()
+ const pkg = createMockPackage('test', {
+ scriptFiles: [
+ { name: 'init', path: 'scripts/init.lua', code: 'return {}', category: 'setup', description: 'Init script' },
+ ],
+ })
+
+ await installPackageScripts(pkg, db)
+
+ expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test_init', {
+ id: 'package_test_init',
+ name: 'Package test - init',
+ code: 'return {}',
+ category: 'setup',
+ packageId: 'test',
+ path: 'scripts/init.lua',
+ description: 'Init script',
+ })
+ })
+
+ it('should install both legacy and multi-file scripts', async () => {
+ const db = createMockDb()
+ const scriptFiles = mockScriptFiles.slice(0, 1)
+ const pkg = createMockPackage('test', {
+ scripts: 'legacy',
+ scriptFiles,
+ })
+
+ await installPackageScripts(pkg, db)
+
+ expect(db.set).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('installPackage', () => {
+ it.each([
+ {
+ name: 'successfully installs package',
+ registry: createMockRegistry([createMockPackage('test')]),
+ packageId: 'test',
+ expectSuccess: true,
+ },
+ {
+ name: 'fails when package not found',
+ registry: createMockRegistry([]),
+ packageId: 'nonexistent',
+ expectSuccess: false,
+ expectedError: 'Package nonexistent not found',
+ },
+ {
+ name: 'fails when dependencies missing',
+ registry: createMockRegistry([
+ createMockPackage('test', { dependencies: ['missing'] }),
+ ]),
+ packageId: 'test',
+ expectSuccess: false,
+ expectedError: 'Missing dependencies: missing',
+ },
+ ])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
+ const db = createMockDb()
+
+ const result = await installPackage(registry, packageId, db)
+
+ expect(result.success).toBe(expectSuccess)
+ if (expectedError) {
+ expect(result.error).toContain(expectedError)
+ }
+ if (expectSuccess) {
+ expect(db.set).toHaveBeenCalledWith(
+ 'installed_packages',
+ packageId,
+ expect.objectContaining({ packageId, name: expect.any(String) })
+ )
+ }
+ })
+ })
+
+ describe('uninstallPackage', () => {
+ it.each([
+ {
+ name: 'successfully uninstalls package',
+ registry: createMockRegistry([
+ createMockPackage('test', { components: [{ id: 'c1' }] }),
+ ]),
+ packageId: 'test',
+ expectSuccess: true,
+ },
+ {
+ name: 'fails when package not found',
+ registry: createMockRegistry([]),
+ packageId: 'nonexistent',
+ expectSuccess: false,
+ expectedError: 'Package nonexistent not found',
+ },
+ ])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
+ const db = createMockDb()
+
+ const result = await uninstallPackage(registry, packageId, db)
+
+ expect(result.success).toBe(expectSuccess)
+ if (expectedError) {
+ expect(result.error).toContain(expectedError)
+ }
+ if (expectSuccess) {
+ expect(db.delete).toHaveBeenCalledWith('installed_packages', packageId)
+ }
+ })
+ })
+
+ describe('getInstalledPackages', () => {
+ it('should return installed package IDs', async () => {
+ const db = createMockDb()
+ db._data['installed_packages'] = { pkg1: {}, pkg2: {} }
+
+ const result = await getInstalledPackages(db)
+
+ expect(result).toEqual(['pkg1', 'pkg2'])
+ })
+
+ it('should return empty array on error', async () => {
+ const db = createMockDb()
+ db.getAll = vi.fn().mockRejectedValue(new Error('DB error'))
+
+ const result = await getInstalledPackages(db)
+
+ expect(result).toEqual([])
+ })
+
+ it('should return empty array when no packages', async () => {
+ const db = createMockDb()
+
+ const result = await getInstalledPackages(db)
+
+ expect(result).toEqual([])
+ })
+ })
+
+ describe('isPackageInstalled', () => {
+ it.each([
+ {
+ name: 'returns true when installed',
+ setupDb: (db: ReturnType) => {
+ db._data['installed_packages'] = { test: { packageId: 'test' } }
+ },
+ packageId: 'test',
+ expected: true,
+ },
+ {
+ name: 'returns false when not installed',
+ setupDb: () => {},
+ packageId: 'test',
+ expected: false,
+ },
+ ])('should handle $name', async ({ setupDb, packageId, expected }) => {
+ const db = createMockDb()
+ setupDb(db)
+
+ const result = await isPackageInstalled(packageId, db)
+
+ expect(result).toBe(expected)
+ })
+
+ it('should return false on error', async () => {
+ const db = createMockDb()
+ db.get = vi.fn().mockRejectedValue(new Error('DB error'))
+
+ const result = await isPackageInstalled('test', db)
+
+ expect(result).toBe(false)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/packages/tests/package-glue/regression.test.ts b/frontends/nextjs/src/lib/packages/tests/package-glue/regression.test.ts
new file mode 100644
index 000000000..9f25df8a4
--- /dev/null
+++ b/frontends/nextjs/src/lib/packages/tests/package-glue/regression.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest'
+
+import { exportAllPackagesForSeed } from '../../package-glue'
+import { createMockPackage, createMockRegistry } from '../package-glue.test'
+
+describe('package-glue regressions', () => {
+ describe('exportAllPackagesForSeed', () => {
+ it('should export all package data', () => {
+ const registry = createMockRegistry([
+ createMockPackage('pkg1', {
+ name: 'Package 1',
+ components: [{ id: 'c1' }],
+ scripts: 'lua code',
+ }),
+ createMockPackage('pkg2', {
+ name: 'Package 2',
+ scriptFiles: [{ name: 'init', path: 'scripts/init.lua', code: 'return {}' }],
+ }),
+ ])
+
+ const result = exportAllPackagesForSeed(registry)
+
+ expect(result.components).toHaveLength(1)
+ expect(result.scripts).toHaveLength(2)
+ expect(result.packages).toHaveLength(2)
+ expect(result.packages[0].packageId).toBe('pkg1')
+ expect(result.packages[1].packageId).toBe('pkg2')
+ })
+
+ it('should handle empty registry', () => {
+ const result = exportAllPackagesForSeed({})
+
+ expect(result.components).toEqual([])
+ expect(result.scripts).toEqual([])
+ expect(result.packages).toEqual([])
+ })
+
+ it('should include script metadata', () => {
+ const registry = createMockRegistry([
+ createMockPackage('pkg', {
+ scriptFiles: [
+ {
+ name: 'utils',
+ path: 'scripts/utils.lua',
+ code: 'return true',
+ category: 'helpers',
+ description: 'Utility functions',
+ },
+ ],
+ }),
+ ])
+
+ const result = exportAllPackagesForSeed(registry)
+
+ expect(result.scripts[0]).toMatchObject({
+ id: 'package_pkg_utils',
+ name: 'Package pkg - utils',
+ code: 'return true',
+ category: 'helpers',
+ path: 'scripts/utils.lua',
+ description: 'Utility functions',
+ })
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/packages/tests/package-glue/validation.test.ts b/frontends/nextjs/src/lib/packages/tests/package-glue/validation.test.ts
new file mode 100644
index 000000000..129c72c50
--- /dev/null
+++ b/frontends/nextjs/src/lib/packages/tests/package-glue/validation.test.ts
@@ -0,0 +1,284 @@
+import { describe, it, expect } from 'vitest'
+
+import type { LuaScriptFile } from '../../package-glue'
+import {
+ checkDependencies,
+ getAllPackageScripts,
+ getPackage,
+ getPackageComponents,
+ getPackageExamples,
+ getPackageScriptFiles,
+ getPackageScripts,
+ getPackagesByCategory,
+} from '../../package-glue'
+import { createMockPackage, createMockRegistry } from '../package-glue.test'
+
+describe('package-glue validation', () => {
+ describe('getPackage', () => {
+ it.each([
+ {
+ name: 'returns package when found',
+ registry: createMockRegistry([createMockPackage('test_pkg')]),
+ packageId: 'test_pkg',
+ expectFound: true,
+ },
+ {
+ name: 'returns undefined when not found',
+ registry: createMockRegistry([createMockPackage('other_pkg')]),
+ packageId: 'test_pkg',
+ expectFound: false,
+ },
+ {
+ name: 'returns undefined from empty registry',
+ registry: createMockRegistry([]),
+ packageId: 'test_pkg',
+ expectFound: false,
+ },
+ ])('should handle $name', ({ registry, packageId, expectFound }) => {
+ const result = getPackage(registry, packageId)
+
+ if (expectFound) {
+ expect(result).toBeDefined()
+ expect(result?.packageId).toBe(packageId)
+ } else {
+ expect(result).toBeUndefined()
+ }
+ })
+ })
+
+ describe('getPackagesByCategory', () => {
+ const mixedRegistry = createMockRegistry([
+ createMockPackage('pkg1', { category: 'ui' }),
+ createMockPackage('pkg2', { category: 'ui' }),
+ createMockPackage('pkg3', { category: 'data' }),
+ createMockPackage('pkg4', { category: 'util' }),
+ ])
+
+ it.each([
+ {
+ name: 'returns packages in category',
+ registry: mixedRegistry,
+ category: 'ui',
+ expectedCount: 2,
+ },
+ {
+ name: 'returns single package in category',
+ registry: mixedRegistry,
+ category: 'data',
+ expectedCount: 1,
+ },
+ {
+ name: 'returns empty array for unknown category',
+ registry: mixedRegistry,
+ category: 'unknown',
+ expectedCount: 0,
+ },
+ {
+ name: 'returns empty array from empty registry',
+ registry: createMockRegistry([]),
+ category: 'ui',
+ expectedCount: 0,
+ },
+ ])('should handle $name', ({ registry, category, expectedCount }) => {
+ const result = getPackagesByCategory(registry, category)
+
+ expect(result).toHaveLength(expectedCount)
+ result.forEach((pkg) => {
+ expect(pkg.category).toBe(category)
+ })
+ })
+ })
+
+ describe('getPackageComponents', () => {
+ it.each([
+ {
+ name: 'returns components array',
+ pkg: createMockPackage('test', {
+ components: [{ id: 'c1' }, { id: 'c2' }],
+ }),
+ expectedLength: 2,
+ },
+ {
+ name: 'returns empty array when no components',
+ pkg: createMockPackage('test', { components: [] }),
+ expectedLength: 0,
+ },
+ {
+ name: 'returns empty array when components is undefined',
+ pkg: { ...createMockPackage('test'), components: undefined as any },
+ expectedLength: 0,
+ },
+ ])('should handle $name', ({ pkg, expectedLength }) => {
+ const result = getPackageComponents(pkg)
+
+ expect(Array.isArray(result)).toBe(true)
+ expect(result).toHaveLength(expectedLength)
+ })
+ })
+
+ describe('getPackageScripts', () => {
+ it.each([
+ {
+ name: 'returns scripts string',
+ pkg: createMockPackage('test', { scripts: 'return 42' }),
+ expected: 'return 42',
+ },
+ {
+ name: 'returns empty string when undefined',
+ pkg: createMockPackage('test'),
+ expected: '',
+ },
+ {
+ name: 'returns empty string when null',
+ pkg: { ...createMockPackage('test'), scripts: null as any },
+ expected: '',
+ },
+ ])('should handle $name', ({ pkg, expected }) => {
+ const result = getPackageScripts(pkg)
+
+ expect(result).toBe(expected)
+ })
+ })
+
+ describe('getPackageScriptFiles', () => {
+ const mockScriptFiles: LuaScriptFile[] = [
+ { name: 'init', path: 'scripts/init.lua', code: 'return {}' },
+ { name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
+ ]
+
+ it.each([
+ {
+ name: 'returns script files array',
+ pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
+ expectedLength: 2,
+ },
+ {
+ name: 'returns empty array when undefined',
+ pkg: createMockPackage('test'),
+ expectedLength: 0,
+ },
+ {
+ name: 'returns empty array when empty',
+ pkg: createMockPackage('test', { scriptFiles: [] }),
+ expectedLength: 0,
+ },
+ ])('should handle $name', ({ pkg, expectedLength }) => {
+ const result = getPackageScriptFiles(pkg)
+
+ expect(Array.isArray(result)).toBe(true)
+ expect(result).toHaveLength(expectedLength)
+ })
+ })
+
+ describe('getAllPackageScripts', () => {
+ const mockScriptFiles: LuaScriptFile[] = [
+ { name: 'init', path: 'scripts/init.lua', code: 'return {}' },
+ ]
+
+ it.each([
+ {
+ name: 'returns both legacy and files',
+ pkg: createMockPackage('test', {
+ scripts: 'legacy code',
+ scriptFiles: mockScriptFiles,
+ }),
+ expectedLegacy: 'legacy code',
+ expectedFilesLength: 1,
+ },
+ {
+ name: 'handles missing legacy',
+ pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
+ expectedLegacy: '',
+ expectedFilesLength: 1,
+ },
+ {
+ name: 'handles missing files',
+ pkg: createMockPackage('test', { scripts: 'code' }),
+ expectedLegacy: 'code',
+ expectedFilesLength: 0,
+ },
+ {
+ name: 'handles both missing',
+ pkg: createMockPackage('test'),
+ expectedLegacy: '',
+ expectedFilesLength: 0,
+ },
+ ])('should handle $name', ({ pkg, expectedLegacy, expectedFilesLength }) => {
+ const result = getAllPackageScripts(pkg)
+
+ expect(result.legacy).toBe(expectedLegacy)
+ expect(result.files).toHaveLength(expectedFilesLength)
+ })
+ })
+
+ describe('getPackageExamples', () => {
+ it.each([
+ {
+ name: 'returns examples object',
+ pkg: createMockPackage('test', {
+ examples: { demo: 'code' },
+ }),
+ hasExamples: true,
+ },
+ {
+ name: 'returns empty object when undefined',
+ pkg: createMockPackage('test'),
+ hasExamples: false,
+ },
+ ])('should handle $name', ({ pkg, hasExamples }) => {
+ const result = getPackageExamples(pkg)
+
+ expect(typeof result).toBe('object')
+ if (hasExamples) {
+ expect(result.demo).toBe('code')
+ } else {
+ expect(Object.keys(result)).toHaveLength(0)
+ }
+ })
+ })
+
+ describe('checkDependencies', () => {
+ it.each([
+ {
+ name: 'satisfied when no dependencies',
+ registry: createMockRegistry([createMockPackage('test')]),
+ packageId: 'test',
+ expectedSatisfied: true,
+ expectedMissing: [],
+ },
+ {
+ name: 'satisfied when all dependencies present',
+ registry: createMockRegistry([
+ createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
+ createMockPackage('dep1'),
+ createMockPackage('dep2'),
+ ]),
+ packageId: 'test',
+ expectedSatisfied: true,
+ expectedMissing: [],
+ },
+ {
+ name: 'not satisfied when dependencies missing',
+ registry: createMockRegistry([
+ createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
+ createMockPackage('dep1'),
+ ]),
+ packageId: 'test',
+ expectedSatisfied: false,
+ expectedMissing: ['dep2'],
+ },
+ {
+ name: 'not satisfied when package not found',
+ registry: createMockRegistry([]),
+ packageId: 'nonexistent',
+ expectedSatisfied: false,
+ expectedMissing: ['nonexistent'],
+ },
+ ])('should handle $name', ({ registry, packageId, expectedSatisfied, expectedMissing }) => {
+ const result = checkDependencies(registry, packageId)
+
+ expect(result.satisfied).toBe(expectedSatisfied)
+ expect(result.missing).toEqual(expectedMissing)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/rendering/page/builder-types.ts b/frontends/nextjs/src/lib/rendering/page/builder-types.ts
index 346266bd4..bc9bfb617 100644
--- a/frontends/nextjs/src/lib/rendering/page/builder-types.ts
+++ b/frontends/nextjs/src/lib/rendering/page/builder-types.ts
@@ -1 +1 @@
-export * from '../builder-types'
+export * from '@/lib/types/builder-types'
diff --git a/frontends/nextjs/src/lib/rendering/page/components.ts b/frontends/nextjs/src/lib/rendering/page/components.ts
new file mode 100644
index 000000000..b5da67a93
--- /dev/null
+++ b/frontends/nextjs/src/lib/rendering/page/components.ts
@@ -0,0 +1,99 @@
+import type { ComponentInstance } from '@/lib/types/builder-types'
+
+export const buildHeaderActions = (): ComponentInstance[] => [
+ {
+ id: 'header_login_btn',
+ type: 'Button',
+ props: {
+ children: 'Login',
+ variant: 'default',
+ size: 'sm',
+ },
+ children: [],
+ },
+]
+
+export const buildProfileCard = (): ComponentInstance => ({
+ id: 'comp_profile',
+ type: 'Card',
+ props: {
+ className: 'p-6',
+ },
+ children: [
+ {
+ id: 'comp_profile_header',
+ type: 'Heading',
+ props: {
+ level: 2,
+ children: 'User Profile',
+ className: 'text-2xl font-bold mb-4',
+ },
+ children: [],
+ },
+ {
+ id: 'comp_profile_content',
+ type: 'Container',
+ props: {
+ className: 'space-y-4',
+ },
+ children: [
+ {
+ id: 'comp_profile_bio',
+ type: 'Textarea',
+ props: {
+ placeholder: 'Tell us about yourself...',
+ className: 'min-h-32',
+ },
+ children: [],
+ },
+ {
+ id: 'comp_profile_save',
+ type: 'Button',
+ props: {
+ children: 'Save Profile',
+ variant: 'default',
+ },
+ children: [],
+ },
+ ],
+ },
+ ],
+})
+
+export const buildCommentsCard = (): ComponentInstance => ({
+ id: 'comp_comments',
+ type: 'Card',
+ props: {
+ className: 'p-6',
+ },
+ children: [
+ {
+ id: 'comp_comments_header',
+ type: 'Heading',
+ props: {
+ level: 2,
+ children: 'Community Comments',
+ className: 'text-2xl font-bold mb-4',
+ },
+ children: [],
+ },
+ {
+ id: 'comp_comments_input',
+ type: 'Textarea',
+ props: {
+ placeholder: 'Share your thoughts...',
+ className: 'mb-4',
+ },
+ children: [],
+ },
+ {
+ id: 'comp_comments_post',
+ type: 'Button',
+ props: {
+ children: 'Post Comment',
+ variant: 'default',
+ },
+ children: [],
+ },
+ ],
+})
diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level2-user-dashboard.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level2-user-dashboard.ts
index b20c55eec..ecadaccbd 100644
--- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level2-user-dashboard.ts
+++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level2-user-dashboard.ts
@@ -1,131 +1,46 @@
-import type { PageDefinition } from './page-renderer'
-import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
+import { buildCommentsCard, buildProfileCard } from '@/lib/rendering/page/components'
+import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
export function buildLevel2UserDashboard(): PageDefinition {
- const profileCard: ComponentInstance = {
- id: 'comp_profile',
- type: 'Card',
- props: {
- className: 'p-6'
- },
- children: [
+ return {
+ id: 'page_level2_dashboard',
+ level: 2,
+ title: 'User Dashboard',
+ description: 'User dashboard with profile and comments',
+ layout: 'dashboard',
+ components: [buildProfileCard(), buildCommentsCard()],
+ permissions: {
+ requiresAuth: true,
+ requiredRole: 'user',
+ },
+ metadata: {
+ showHeader: true,
+ showFooter: false,
+ headerTitle: 'Dashboard',
+ sidebarItems: [
{
- id: 'comp_profile_header',
- type: 'Heading',
- props: {
- level: 2,
- children: 'User Profile',
- className: 'text-2xl font-bold mb-4'
- },
- children: []
+ id: 'nav_home',
+ label: 'Home',
+ icon: '🏠',
+ action: 'navigate',
+ target: '1',
},
{
- id: 'comp_profile_content',
- type: 'Container',
- props: {
- className: 'space-y-4'
- },
- children: [
- {
- id: 'comp_profile_bio',
- type: 'Textarea',
- props: {
- placeholder: 'Tell us about yourself...',
- className: 'min-h-32'
- },
- children: []
- },
- {
- id: 'comp_profile_save',
- type: 'Button',
- props: {
- children: 'Save Profile',
- variant: 'default'
- },
- children: []
- }
- ]
- }
- ]
- }
-
- const commentsCard: ComponentInstance = {
- id: 'comp_comments',
- type: 'Card',
- props: {
- className: 'p-6'
- },
- children: [
- {
- id: 'comp_comments_header',
- type: 'Heading',
- props: {
- level: 2,
- children: 'Community Comments',
- className: 'text-2xl font-bold mb-4'
- },
- children: []
+ id: 'nav_profile',
+ label: 'Profile',
+ icon: '👤',
+ action: 'navigate',
+ target: '2',
},
{
- id: 'comp_comments_input',
- type: 'Textarea',
- props: {
- placeholder: 'Share your thoughts...',
- className: 'mb-4'
- },
- children: []
+ id: 'nav_chat',
+ label: 'Chat',
+ icon: '💬',
+ action: 'navigate',
+ target: '2',
},
- {
- id: 'comp_comments_post',
- type: 'Button',
- props: {
- children: 'Post Comment',
- variant: 'default'
- },
- children: []
- }
- ]
- }
-
- return {
- id: 'page_level2_dashboard',
- level: 2,
- title: 'User Dashboard',
- description: 'User dashboard with profile and comments',
- layout: 'dashboard',
- components: [profileCard, commentsCard],
- permissions: {
- requiresAuth: true,
- requiredRole: 'user'
- },
- metadata: {
- showHeader: true,
- showFooter: false,
- headerTitle: 'Dashboard',
- sidebarItems: [
- {
- id: 'nav_home',
- label: 'Home',
- icon: '🏠',
- action: 'navigate',
- target: '1'
- },
- {
- id: 'nav_profile',
- label: 'Profile',
- icon: '👤',
- action: 'navigate',
- target: '2'
- },
- {
- id: 'nav_chat',
- label: 'Chat',
- icon: '💬',
- action: 'navigate',
- target: '2'
- }
- ]
- }
- }
+ ],
+ },
+ }
}
diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level3-admin-panel.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level3-admin-panel.ts
index c01748387..09decf394 100644
--- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level3-admin-panel.ts
+++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level3-admin-panel.ts
@@ -1,4 +1,4 @@
-import type { PageDefinition } from './page-renderer'
+import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-page-definition-builder.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-page-definition-builder.ts
index bb5d846e8..52bea90f7 100644
--- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-page-definition-builder.ts
+++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-page-definition-builder.ts
@@ -1,4 +1,4 @@
-import type { PageDefinition } from './page-renderer'
+import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-pages.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-pages.ts
index f8c718851..c92c18a88 100644
--- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-pages.ts
+++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-pages.ts
@@ -1,4 +1,4 @@
-import type { PageDefinition } from './page-renderer'
+import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/homepage/build-level1-homepage.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/homepage/build-level1-homepage.ts
index 0b21ffdfa..071f69f15 100644
--- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/homepage/build-level1-homepage.ts
+++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/homepage/build-level1-homepage.ts
@@ -1,21 +1,9 @@
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from '@/lib/rendering/page/builder-types'
+import { buildHeaderActions } from '@/lib/rendering/page/components'
import { buildFeaturesComponent } from './build-features-component'
import { buildHeroComponent } from './build-hero-component'
-const buildHeaderActions = (): ComponentInstance[] => [
- {
- id: 'header_login_btn',
- type: 'Button',
- props: {
- children: 'Login',
- variant: 'default',
- size: 'sm'
- },
- children: []
- }
-]
-
export const buildLevel1Homepage = (): PageDefinition => {
const heroComponent = buildHeroComponent()
const featuresComponent = buildFeaturesComponent()
diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/initialize-default-pages.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/initialize-default-pages.ts
index aadeb907e..8f2a89c30 100644
--- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/initialize-default-pages.ts
+++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/initialize-default-pages.ts
@@ -1,4 +1,4 @@
-import type { PageDefinition } from './page-renderer'
+import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
diff --git a/frontends/nextjs/src/lib/rendering/page/page-renderer.ts b/frontends/nextjs/src/lib/rendering/page/page-renderer.ts
index 1e56867c6..04e953bdd 100644
--- a/frontends/nextjs/src/lib/rendering/page/page-renderer.ts
+++ b/frontends/nextjs/src/lib/rendering/page/page-renderer.ts
@@ -1,8 +1,8 @@
-import type { ComponentInstance } from '../types/builder-types'
-import type { User } from '../types/level-types'
-import { Database } from '../database'
-import type { LuaEngine } from '../lua-engine'
-import { executeLuaScriptWithProfile } from '../lua/execute-lua-script-with-profile'
+import { Database } from '@/lib/database'
+import type { LuaEngine } from '@/lib/lua-engine'
+import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile'
+import type { ComponentInstance } from '@/lib/types/builder-types'
+import type { User } from '@/lib/types/level-types'
export interface PageDefinition {
id: string
diff --git a/frontends/nextjs/src/lib/rendering/page/utils.ts b/frontends/nextjs/src/lib/rendering/page/utils.ts
new file mode 100644
index 000000000..22472c760
--- /dev/null
+++ b/frontends/nextjs/src/lib/rendering/page/utils.ts
@@ -0,0 +1,29 @@
+import type { ComponentInstance } from '@/lib/types/builder-types'
+import type { User, UserRole } from '@/lib/types/level-types'
+import type { PageDefinition } from './page-renderer'
+
+export function createMockPage(
+ id: string,
+ options: Partial = {}
+): PageDefinition {
+ return {
+ id,
+ level: options.level ?? 1,
+ title: options.title ?? `Page ${id}`,
+ layout: options.layout ?? 'default',
+ components: (options.components as ComponentInstance[] | undefined) ?? [],
+ permissions: options.permissions,
+ luaScripts: options.luaScripts,
+ metadata: options.metadata,
+ }
+}
+
+export function createMockUser(role: UserRole | string, id = 'user1'): User {
+ return {
+ id,
+ username: `User ${id}`,
+ role: role as UserRole,
+ email: `${id}@test.com`,
+ createdAt: Date.now(),
+ }
+}
diff --git a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.evaluation.test.ts b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.evaluation.test.ts
new file mode 100644
index 000000000..e562eae07
--- /dev/null
+++ b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.evaluation.test.ts
@@ -0,0 +1,127 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { DeclarativeComponentRenderer } from '@/lib/rendering/declarative-component-renderer'
+
+describe('declarative-component-renderer evaluation', () => {
+ let renderer: DeclarativeComponentRenderer
+
+ beforeEach(() => {
+ renderer = new DeclarativeComponentRenderer()
+ })
+
+ describe('interpolateValue', () => {
+ it.each([
+ {
+ name: 'simple interpolation',
+ template: 'Hello {name}!',
+ context: { name: 'World' },
+ expected: 'Hello World!',
+ },
+ {
+ name: 'multiple placeholders',
+ template: '{greeting} {name}, welcome to {place}',
+ context: { greeting: 'Hi', name: 'Alice', place: 'Wonderland' },
+ expected: 'Hi Alice, welcome to Wonderland',
+ },
+ {
+ name: 'missing placeholder',
+ template: 'Hello {name}, age: {age}',
+ context: { name: 'Bob' },
+ expected: 'Hello Bob, age: {age}',
+ },
+ {
+ name: 'numeric value',
+ template: 'Count: {count}',
+ context: { count: 42 },
+ expected: 'Count: 42',
+ },
+ {
+ name: 'boolean value',
+ template: 'Active: {active}',
+ context: { active: true },
+ expected: 'Active: true',
+ },
+ {
+ name: 'empty template',
+ template: '',
+ context: { name: 'test' },
+ expected: '',
+ },
+ {
+ name: 'no placeholders',
+ template: 'Plain text',
+ context: { name: 'ignored' },
+ expected: 'Plain text',
+ },
+ {
+ name: 'null template',
+ template: null as any,
+ context: { name: 'test' },
+ expected: null,
+ },
+ {
+ name: 'undefined value in context',
+ template: 'Value: {val}',
+ context: { val: undefined },
+ expected: 'Value: {val}',
+ },
+ ])('should handle $name', ({ template, context, expected }) => {
+ expect(renderer.interpolateValue(template, context)).toBe(expected)
+ })
+ })
+
+ describe('evaluateConditional', () => {
+ it.each([
+ { name: 'boolean true', condition: true, context: {}, expected: true },
+ { name: 'boolean false', condition: false, context: {}, expected: false },
+ { name: 'empty string condition', condition: '', context: {}, expected: true },
+ { name: 'null condition', condition: null as any, context: {}, expected: true },
+ { name: 'undefined condition', condition: undefined as any, context: {}, expected: true },
+ { name: 'truthy context value', condition: 'isActive', context: { isActive: true }, expected: true },
+ { name: 'falsy context value', condition: 'isActive', context: { isActive: false }, expected: false },
+ { name: 'missing context key', condition: 'missing', context: {}, expected: false },
+ { name: 'truthy string value', condition: 'name', context: { name: 'test' }, expected: true },
+ { name: 'empty string value', condition: 'name', context: { name: '' }, expected: false },
+ { name: 'zero value', condition: 'count', context: { count: 0 }, expected: false },
+ { name: 'positive number', condition: 'count', context: { count: 5 }, expected: true },
+ ])('should return $expected for $name', ({ condition, context, expected }) => {
+ expect(renderer.evaluateConditional(condition, context)).toBe(expected)
+ })
+ })
+
+ describe('resolveDataSource', () => {
+ it.each([
+ {
+ name: 'existing array data source',
+ dataSource: 'items',
+ context: { items: [1, 2, 3] },
+ expected: [1, 2, 3],
+ },
+ {
+ name: 'empty array data source',
+ dataSource: 'items',
+ context: { items: [] },
+ expected: [],
+ },
+ {
+ name: 'missing data source',
+ dataSource: 'missing',
+ context: {},
+ expected: [],
+ },
+ {
+ name: 'null data source key',
+ dataSource: '',
+ context: { items: [1] },
+ expected: [],
+ },
+ {
+ name: 'object array data source',
+ dataSource: 'users',
+ context: { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] },
+ expected: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
+ },
+ ])('should resolve $name', ({ dataSource, context, expected }) => {
+ expect(renderer.resolveDataSource(dataSource, context)).toEqual(expected)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lifecycle.test.ts b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lifecycle.test.ts
new file mode 100644
index 000000000..05f80594d
--- /dev/null
+++ b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lifecycle.test.ts
@@ -0,0 +1,183 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ DeclarativeComponentRenderer,
+ getDeclarativeRenderer,
+ loadPackageComponents,
+ type DeclarativeComponentConfig,
+} from '@/lib/rendering/declarative-component-renderer'
+
+describe('declarative-component-renderer lifecycle', () => {
+ let renderer: DeclarativeComponentRenderer
+
+ beforeEach(() => {
+ renderer = new DeclarativeComponentRenderer()
+ })
+
+ describe('registerComponentConfig', () => {
+ it.each([
+ {
+ name: 'basic component',
+ type: 'button',
+ config: {
+ type: 'button',
+ category: 'input',
+ label: 'Button',
+ description: 'A clickable button',
+ icon: 'click',
+ props: [],
+ config: { layout: 'inline', styling: { className: 'btn' }, children: [] },
+ },
+ },
+ {
+ name: 'component with props',
+ type: 'input',
+ config: {
+ type: 'input',
+ category: 'form',
+ label: 'Input Field',
+ description: 'Text input',
+ icon: 'text',
+ props: [
+ { name: 'placeholder', type: 'string', label: 'Placeholder', required: false },
+ { name: 'value', type: 'string', label: 'Value', required: true, defaultValue: '' },
+ ],
+ config: { layout: 'block', styling: { className: 'input' }, children: [] },
+ },
+ },
+ ])('should register $name', ({ type, config }) => {
+ renderer.registerComponentConfig(type, config as DeclarativeComponentConfig)
+
+ expect(renderer.hasComponentConfig(type)).toBe(true)
+ expect(renderer.getComponentConfig(type)).toEqual(config)
+ })
+ })
+
+ describe('hasComponentConfig', () => {
+ it.each([
+ { type: 'registered', shouldRegister: true, expected: true },
+ { type: 'unregistered', shouldRegister: false, expected: false },
+ ])('should return $expected for $type component', ({ type, shouldRegister, expected }) => {
+ if (shouldRegister) {
+ renderer.registerComponentConfig(type, {
+ type,
+ category: 'test',
+ label: 'Test',
+ description: '',
+ icon: '',
+ props: [],
+ config: { layout: '', styling: { className: '' }, children: [] },
+ })
+ }
+
+ expect(renderer.hasComponentConfig(type)).toBe(expected)
+ })
+ })
+
+ describe('getComponentConfig', () => {
+ it('should return undefined for non-existent component', () => {
+ expect(renderer.getComponentConfig('nonexistent')).toBeUndefined()
+ })
+
+ it('should return config for registered component', () => {
+ const config: DeclarativeComponentConfig = {
+ type: 'test',
+ category: 'test',
+ label: 'Test Component',
+ description: 'A test',
+ icon: 'test',
+ props: [],
+ config: { layout: 'block', styling: { className: 'test' }, children: [] },
+ }
+ renderer.registerComponentConfig('test', config)
+
+ expect(renderer.getComponentConfig('test')).toEqual(config)
+ })
+ })
+
+ describe('getDeclarativeRenderer', () => {
+ it('should return a global renderer instance', () => {
+ const renderer1 = getDeclarativeRenderer()
+ const renderer2 = getDeclarativeRenderer()
+
+ expect(renderer1).toBe(renderer2)
+ expect(renderer1).toBeInstanceOf(DeclarativeComponentRenderer)
+ })
+ })
+
+ describe('loadPackageComponents', () => {
+ it('should load component configs from package', () => {
+ const renderer = getDeclarativeRenderer()
+ const testType = `loadTest_${Date.now()}`
+
+ loadPackageComponents({
+ componentConfigs: {
+ [testType]: {
+ type: testType,
+ category: 'test',
+ label: 'Loaded Component',
+ description: 'Loaded from package',
+ icon: 'package',
+ props: [],
+ config: { layout: 'block', styling: { className: 'loaded' }, children: [] },
+ },
+ },
+ })
+
+ expect(renderer.hasComponentConfig(testType)).toBe(true)
+ })
+
+ it('should load Lua scripts from package', () => {
+ const luaExecuteSpy = vi.spyOn(DeclarativeComponentRenderer.prototype as any, 'executeLuaScript')
+
+ loadPackageComponents({
+ luaScripts: [
+ {
+ id: `pkgScript_${Date.now()}`,
+ code: 'function formatTime() return 1 end',
+ parameters: [],
+ returnType: 'number',
+ },
+ ],
+ })
+
+ expect(luaExecuteSpy).not.toHaveBeenCalled()
+ })
+
+ it('should handle empty package content', () => {
+ loadPackageComponents({})
+ loadPackageComponents({ componentConfigs: {} })
+ loadPackageComponents({ luaScripts: [] })
+
+ expect(true).toBe(true)
+ })
+
+ it('should handle package with both configs and scripts', () => {
+ const renderer = getDeclarativeRenderer()
+ const uniqueId = Date.now()
+
+ loadPackageComponents({
+ componentConfigs: {
+ [`combo_${uniqueId}`]: {
+ type: `combo_${uniqueId}`,
+ category: 'combo',
+ label: 'Combo',
+ description: 'Combined',
+ icon: 'combo',
+ props: [],
+ config: { layout: 'flex', styling: { className: 'combo' }, children: [] },
+ },
+ },
+ luaScripts: [
+ {
+ id: `comboScript_${uniqueId}`,
+ code: 'function userJoin(name) return "Welcome " .. name end',
+ parameters: [{ name: 'name' }],
+ returnType: 'string',
+ },
+ ],
+ })
+
+ expect(renderer.hasComponentConfig(`combo_${uniqueId}`)).toBe(true)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lua.test.ts b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lua.test.ts
new file mode 100644
index 000000000..378a55af5
--- /dev/null
+++ b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lua.test.ts
@@ -0,0 +1,61 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { DeclarativeComponentRenderer } from '@/lib/rendering/declarative-component-renderer'
+
+describe('declarative-component-renderer lua integration', () => {
+ let renderer: DeclarativeComponentRenderer
+
+ beforeEach(() => {
+ renderer = new DeclarativeComponentRenderer()
+ })
+
+ describe('registerLuaScript', () => {
+ it('should register and store Lua scripts', () => {
+ const script = {
+ code: 'return x + y',
+ parameters: [{ name: 'x' }, { name: 'y' }],
+ returnType: 'number',
+ }
+ renderer.registerLuaScript('add', script)
+
+ expect(renderer.executeLuaScript('add', [1, 2])).resolves.toBeDefined()
+ })
+ })
+
+ describe('executeLuaScript', () => {
+ it('should throw error for non-existent script', async () => {
+ await expect(renderer.executeLuaScript('nonexistent', [])).rejects.toThrow(
+ 'Lua script not found: nonexistent'
+ )
+ })
+
+ it('should execute script with parameters', async () => {
+ renderer.registerLuaScript('testScript', {
+ code: `
+function formatTime(timestamp)
+ return timestamp * 1000
+end
+`,
+ parameters: [{ name: 'timestamp' }],
+ returnType: 'number',
+ })
+
+ const result = await renderer.executeLuaScript('testScript', [5])
+ expect(result).toBe(5000)
+ })
+
+ it('should handle script with no parameters', async () => {
+ renderer.registerLuaScript('constantScript', {
+ code: `
+function formatTime()
+ return 42
+end
+`,
+ parameters: [],
+ returnType: 'number',
+ })
+
+ const result = await renderer.executeLuaScript('constantScript', [])
+ expect(result).toBe(42)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.test.ts b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.test.ts
deleted file mode 100644
index a46bb5bde..000000000
--- a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.test.ts
+++ /dev/null
@@ -1,355 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest'
-import {
- DeclarativeComponentRenderer,
- getDeclarativeRenderer,
- loadPackageComponents,
- type DeclarativeComponentConfig,
-} from './declarative-component-renderer'
-
-describe('declarative-component-renderer', () => {
- let renderer: DeclarativeComponentRenderer
-
- beforeEach(() => {
- renderer = new DeclarativeComponentRenderer()
- })
-
- describe('DeclarativeComponentRenderer', () => {
- describe('registerComponentConfig', () => {
- it.each([
- {
- name: 'basic component',
- type: 'button',
- config: {
- type: 'button',
- category: 'input',
- label: 'Button',
- description: 'A clickable button',
- icon: 'click',
- props: [],
- config: { layout: 'inline', styling: { className: 'btn' }, children: [] },
- },
- },
- {
- name: 'component with props',
- type: 'input',
- config: {
- type: 'input',
- category: 'form',
- label: 'Input Field',
- description: 'Text input',
- icon: 'text',
- props: [
- { name: 'placeholder', type: 'string', label: 'Placeholder', required: false },
- { name: 'value', type: 'string', label: 'Value', required: true, defaultValue: '' },
- ],
- config: { layout: 'block', styling: { className: 'input' }, children: [] },
- },
- },
- ])('should register $name', ({ type, config }) => {
- renderer.registerComponentConfig(type, config as DeclarativeComponentConfig)
-
- expect(renderer.hasComponentConfig(type)).toBe(true)
- expect(renderer.getComponentConfig(type)).toEqual(config)
- })
- })
-
- describe('hasComponentConfig', () => {
- it.each([
- { type: 'registered', shouldRegister: true, expected: true },
- { type: 'unregistered', shouldRegister: false, expected: false },
- ])('should return $expected for $type component', ({ type, shouldRegister, expected }) => {
- if (shouldRegister) {
- renderer.registerComponentConfig(type, {
- type,
- category: 'test',
- label: 'Test',
- description: '',
- icon: '',
- props: [],
- config: { layout: '', styling: { className: '' }, children: [] },
- })
- }
-
- expect(renderer.hasComponentConfig(type)).toBe(expected)
- })
- })
-
- describe('getComponentConfig', () => {
- it('should return undefined for non-existent component', () => {
- expect(renderer.getComponentConfig('nonexistent')).toBeUndefined()
- })
-
- it('should return config for registered component', () => {
- const config: DeclarativeComponentConfig = {
- type: 'test',
- category: 'test',
- label: 'Test Component',
- description: 'A test',
- icon: 'test',
- props: [],
- config: { layout: 'block', styling: { className: 'test' }, children: [] },
- }
- renderer.registerComponentConfig('test', config)
-
- expect(renderer.getComponentConfig('test')).toEqual(config)
- })
- })
-
- describe('interpolateValue', () => {
- it.each([
- {
- name: 'simple interpolation',
- template: 'Hello {name}!',
- context: { name: 'World' },
- expected: 'Hello World!',
- },
- {
- name: 'multiple placeholders',
- template: '{greeting} {name}, welcome to {place}',
- context: { greeting: 'Hi', name: 'Alice', place: 'Wonderland' },
- expected: 'Hi Alice, welcome to Wonderland',
- },
- {
- name: 'missing placeholder',
- template: 'Hello {name}, age: {age}',
- context: { name: 'Bob' },
- expected: 'Hello Bob, age: {age}',
- },
- {
- name: 'numeric value',
- template: 'Count: {count}',
- context: { count: 42 },
- expected: 'Count: 42',
- },
- {
- name: 'boolean value',
- template: 'Active: {active}',
- context: { active: true },
- expected: 'Active: true',
- },
- {
- name: 'empty template',
- template: '',
- context: { name: 'test' },
- expected: '',
- },
- {
- name: 'no placeholders',
- template: 'Plain text',
- context: { name: 'ignored' },
- expected: 'Plain text',
- },
- {
- name: 'null template',
- template: null as any,
- context: { name: 'test' },
- expected: null,
- },
- {
- name: 'undefined value in context',
- template: 'Value: {val}',
- context: { val: undefined },
- expected: 'Value: {val}',
- },
- ])('should handle $name', ({ template, context, expected }) => {
- expect(renderer.interpolateValue(template, context)).toBe(expected)
- })
- })
-
- describe('evaluateConditional', () => {
- it.each([
- { name: 'boolean true', condition: true, context: {}, expected: true },
- { name: 'boolean false', condition: false, context: {}, expected: false },
- { name: 'empty string condition', condition: '', context: {}, expected: true },
- { name: 'null condition', condition: null as any, context: {}, expected: true },
- { name: 'undefined condition', condition: undefined as any, context: {}, expected: true },
- { name: 'truthy context value', condition: 'isActive', context: { isActive: true }, expected: true },
- { name: 'falsy context value', condition: 'isActive', context: { isActive: false }, expected: false },
- { name: 'missing context key', condition: 'missing', context: {}, expected: false },
- { name: 'truthy string value', condition: 'name', context: { name: 'test' }, expected: true },
- { name: 'empty string value', condition: 'name', context: { name: '' }, expected: false },
- { name: 'zero value', condition: 'count', context: { count: 0 }, expected: false },
- { name: 'positive number', condition: 'count', context: { count: 5 }, expected: true },
- ])('should return $expected for $name', ({ condition, context, expected }) => {
- expect(renderer.evaluateConditional(condition, context)).toBe(expected)
- })
- })
-
- describe('resolveDataSource', () => {
- it.each([
- {
- name: 'existing array data source',
- dataSource: 'items',
- context: { items: [1, 2, 3] },
- expected: [1, 2, 3],
- },
- {
- name: 'empty array data source',
- dataSource: 'items',
- context: { items: [] },
- expected: [],
- },
- {
- name: 'missing data source',
- dataSource: 'missing',
- context: {},
- expected: [],
- },
- {
- name: 'null data source key',
- dataSource: '',
- context: { items: [1] },
- expected: [],
- },
- {
- name: 'object array data source',
- dataSource: 'users',
- context: { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] },
- expected: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
- },
- ])('should resolve $name', ({ dataSource, context, expected }) => {
- expect(renderer.resolveDataSource(dataSource, context)).toEqual(expected)
- })
- })
-
- describe('registerLuaScript', () => {
- it('should register and store Lua scripts', () => {
- const script = {
- code: 'return x + y',
- parameters: [{ name: 'x' }, { name: 'y' }],
- returnType: 'number',
- }
- renderer.registerLuaScript('add', script)
-
- // Verify registration by attempting to execute
- // The script is stored internally
- expect(true).toBe(true) // Script registered without error
- })
- })
-
- describe('executeLuaScript', () => {
- it('should throw error for non-existent script', async () => {
- await expect(renderer.executeLuaScript('nonexistent', [])).rejects.toThrow(
- 'Lua script not found: nonexistent'
- )
- })
-
- it('should execute script with parameters', async () => {
- renderer.registerLuaScript('testScript', {
- code: `
-function formatTime(timestamp)
- return timestamp * 1000
-end
-`,
- parameters: [{ name: 'timestamp' }],
- returnType: 'number',
- })
-
- const result = await renderer.executeLuaScript('testScript', [5])
- expect(result).toBe(5000)
- })
-
- it('should handle script with no parameters', async () => {
- renderer.registerLuaScript('constantScript', {
- code: `
-function formatTime()
- return 42
-end
-`,
- parameters: [],
- returnType: 'number',
- })
-
- const result = await renderer.executeLuaScript('constantScript', [])
- expect(result).toBe(42)
- })
- })
- })
-
- describe('getDeclarativeRenderer', () => {
- it('should return a global renderer instance', () => {
- const renderer1 = getDeclarativeRenderer()
- const renderer2 = getDeclarativeRenderer()
-
- expect(renderer1).toBe(renderer2)
- expect(renderer1).toBeInstanceOf(DeclarativeComponentRenderer)
- })
- })
-
- describe('loadPackageComponents', () => {
- it('should load component configs from package', () => {
- const renderer = getDeclarativeRenderer()
- const testType = `loadTest_${Date.now()}`
-
- loadPackageComponents({
- componentConfigs: {
- [testType]: {
- type: testType,
- category: 'test',
- label: 'Loaded Component',
- description: 'Loaded from package',
- icon: 'package',
- props: [],
- config: { layout: 'block', styling: { className: 'loaded' }, children: [] },
- },
- },
- })
-
- expect(renderer.hasComponentConfig(testType)).toBe(true)
- })
-
- it('should load Lua scripts from package', () => {
- loadPackageComponents({
- luaScripts: [
- {
- id: `pkgScript_${Date.now()}`,
- code: 'function formatTime() return 1 end',
- parameters: [],
- returnType: 'number',
- },
- ],
- })
-
- // Script loaded without error
- expect(true).toBe(true)
- })
-
- it('should handle empty package content', () => {
- // Should not throw
- loadPackageComponents({})
- loadPackageComponents({ componentConfigs: {} })
- loadPackageComponents({ luaScripts: [] })
-
- expect(true).toBe(true)
- })
-
- it('should handle package with both configs and scripts', () => {
- const renderer = getDeclarativeRenderer()
- const uniqueId = Date.now()
-
- loadPackageComponents({
- componentConfigs: {
- [`combo_${uniqueId}`]: {
- type: `combo_${uniqueId}`,
- category: 'combo',
- label: 'Combo',
- description: 'Combined',
- icon: 'combo',
- props: [],
- config: { layout: 'flex', styling: { className: 'combo' }, children: [] },
- },
- },
- luaScripts: [
- {
- id: `comboScript_${uniqueId}`,
- code: 'function userJoin(name) return "Welcome " .. name end',
- parameters: [{ name: 'name' }],
- returnType: 'string',
- },
- ],
- })
-
- expect(renderer.hasComponentConfig(`combo_${uniqueId}`)).toBe(true)
- })
- })
-})
diff --git a/frontends/nextjs/src/lib/rendering/tests/page-renderer.layout.test.ts b/frontends/nextjs/src/lib/rendering/tests/page-renderer.layout.test.ts
new file mode 100644
index 000000000..ad2047c0a
--- /dev/null
+++ b/frontends/nextjs/src/lib/rendering/tests/page-renderer.layout.test.ts
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PageRenderer } from '@/lib/rendering/page/page-renderer'
+import { createMockPage } from '@/lib/rendering/page/utils'
+
+const { Database, MockLuaEngine } = vi.hoisted(() => {
+ class MockLuaEngine {
+ execute = vi.fn()
+ }
+ return {
+ Database: {
+ getPages: vi.fn(),
+ addPage: vi.fn(),
+ getLuaScripts: vi.fn(),
+ },
+ MockLuaEngine,
+ }
+})
+
+vi.mock('@/lib/database', () => ({ Database }))
+vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
+
+describe('page-renderer layout queries', () => {
+ let renderer: PageRenderer
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ renderer = new PageRenderer()
+ Database.getPages.mockResolvedValue([])
+ Database.addPage.mockResolvedValue(undefined)
+ Database.getLuaScripts.mockResolvedValue([])
+ })
+
+ describe('getPagesByLevel', () => {
+ it('should filter pages by level', async () => {
+ await renderer.registerPage(createMockPage('p1', { level: 1 }))
+ await renderer.registerPage(createMockPage('p2', { level: 2 }))
+ await renderer.registerPage(createMockPage('p3', { level: 2 }))
+ await renderer.registerPage(createMockPage('p4', { level: 3 }))
+
+ const level2Pages = renderer.getPagesByLevel(2)
+
+ expect(level2Pages).toHaveLength(2)
+ expect(level2Pages.map(p => p.id)).toContain('p2')
+ expect(level2Pages.map(p => p.id)).toContain('p3')
+ })
+
+ it('should return empty array for level with no pages', async () => {
+ await renderer.registerPage(createMockPage('p1', { level: 1 }))
+
+ const level5Pages = renderer.getPagesByLevel(5)
+
+ expect(level5Pages).toHaveLength(0)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/rendering/tests/page-renderer.lifecycle.test.ts b/frontends/nextjs/src/lib/rendering/tests/page-renderer.lifecycle.test.ts
new file mode 100644
index 000000000..24d15e036
--- /dev/null
+++ b/frontends/nextjs/src/lib/rendering/tests/page-renderer.lifecycle.test.ts
@@ -0,0 +1,138 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { getPageRenderer, PageRenderer } from '@/lib/rendering/page/page-renderer'
+import { createMockPage } from '@/lib/rendering/page/utils'
+
+const { Database, MockLuaEngine } = vi.hoisted(() => {
+ class MockLuaEngine {
+ execute = vi.fn()
+ }
+ return {
+ Database: {
+ getPages: vi.fn(),
+ addPage: vi.fn(),
+ getLuaScripts: vi.fn(),
+ },
+ MockLuaEngine,
+ }
+})
+
+vi.mock('@/lib/database', () => ({ Database }))
+vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
+
+describe('page-renderer lifecycle', () => {
+ let renderer: PageRenderer
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ renderer = new PageRenderer()
+ Database.getPages.mockResolvedValue([])
+ Database.addPage.mockResolvedValue(undefined)
+ Database.getLuaScripts.mockResolvedValue([])
+ })
+
+ describe('registerPage', () => {
+ it('should register a page and add to database', async () => {
+ const page = createMockPage('test-page', { title: 'Test Page' })
+
+ await renderer.registerPage(page)
+
+ expect(Database.addPage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'test-page',
+ title: 'Test Page',
+ })
+ )
+ expect(renderer.getPage('test-page')).toEqual(page)
+ })
+
+ it('should handle pages with permissions', async () => {
+ const page = createMockPage('auth-page', {
+ permissions: {
+ requiresAuth: true,
+ requiredRole: 'admin',
+ },
+ })
+
+ await renderer.registerPage(page)
+
+ expect(Database.addPage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ requiresAuth: true,
+ requiredRole: 'admin',
+ })
+ )
+ })
+ })
+
+ describe('loadPages', () => {
+ it('should load pages from database', async () => {
+ Database.getPages.mockResolvedValue([
+ {
+ id: 'page1',
+ title: 'Page 1',
+ level: 2,
+ componentTree: [],
+ requiresAuth: false,
+ },
+ {
+ id: 'page2',
+ title: 'Page 2',
+ level: 3,
+ componentTree: [{ id: 'c1', type: 'text' }],
+ requiresAuth: true,
+ requiredRole: 'admin',
+ },
+ ])
+
+ await renderer.loadPages()
+
+ expect(renderer.getPage('page1')).toBeDefined()
+ expect(renderer.getPage('page2')).toBeDefined()
+ expect(renderer.getPage('page1')?.title).toBe('Page 1')
+ expect(renderer.getPage('page2')?.permissions?.requiresAuth).toBe(true)
+ })
+
+ it('should handle empty database', async () => {
+ Database.getPages.mockResolvedValue([])
+
+ await renderer.loadPages()
+
+ expect(renderer.getPage('nonexistent')).toBeUndefined()
+ })
+ })
+
+ describe('getPage', () => {
+ it.each([
+ {
+ name: 'returns page when exists',
+ pageId: 'existing',
+ expectFound: true,
+ },
+ {
+ name: 'returns undefined when not exists',
+ pageId: 'nonexistent',
+ expectFound: false,
+ },
+ ])('should handle $name', async ({ pageId, expectFound }) => {
+ await renderer.registerPage(createMockPage('existing'))
+
+ const result = renderer.getPage(pageId)
+
+ if (expectFound) {
+ expect(result).toBeDefined()
+ expect(result?.id).toBe(pageId)
+ } else {
+ expect(result).toBeUndefined()
+ }
+ })
+ })
+
+ describe('getPageRenderer singleton', () => {
+ it('should return the same instance', () => {
+ const instance1 = getPageRenderer()
+ const instance2 = getPageRenderer()
+
+ expect(instance1).toBe(instance2)
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/rendering/tests/page-renderer.permissions.test.ts b/frontends/nextjs/src/lib/rendering/tests/page-renderer.permissions.test.ts
new file mode 100644
index 000000000..68c87b172
--- /dev/null
+++ b/frontends/nextjs/src/lib/rendering/tests/page-renderer.permissions.test.ts
@@ -0,0 +1,100 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PageRenderer, type PageDefinition } from '@/lib/rendering/page/page-renderer'
+import { createMockPage, createMockUser } from '@/lib/rendering/page/utils'
+
+const { Database, MockLuaEngine } = vi.hoisted(() => {
+ class MockLuaEngine {
+ execute = vi.fn()
+ }
+ return {
+ Database: {
+ getPages: vi.fn(),
+ addPage: vi.fn(),
+ getLuaScripts: vi.fn(),
+ },
+ MockLuaEngine,
+ }
+})
+
+vi.mock('@/lib/database', () => ({ Database }))
+vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
+
+describe('page-renderer permissions', () => {
+ let renderer: PageRenderer
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ renderer = new PageRenderer()
+ Database.getPages.mockResolvedValue([])
+ Database.addPage.mockResolvedValue(undefined)
+ Database.getLuaScripts.mockResolvedValue([])
+ })
+
+ describe('checkPermissions', () => {
+ it.each([
+ {
+ name: 'allows when no permissions defined',
+ page: createMockPage('open'),
+ user: null,
+ expectedAllowed: true,
+ },
+ {
+ name: 'blocks unauthenticated user when auth required',
+ page: createMockPage('auth', {
+ permissions: { requiresAuth: true },
+ }),
+ user: null,
+ expectedAllowed: false,
+ expectedReason: 'Authentication required',
+ },
+ {
+ name: 'allows authenticated user when auth required',
+ page: createMockPage('auth', {
+ permissions: { requiresAuth: true },
+ }),
+ user: createMockUser('user'),
+ expectedAllowed: true,
+ },
+ {
+ name: 'blocks user with insufficient role',
+ page: createMockPage('admin', {
+ permissions: { requiresAuth: true, requiredRole: 'admin' },
+ }),
+ user: createMockUser('user'),
+ expectedAllowed: false,
+ expectedReason: 'Insufficient permissions',
+ },
+ {
+ name: 'allows user with sufficient role',
+ page: createMockPage('admin', {
+ permissions: { requiresAuth: true, requiredRole: 'admin' },
+ }),
+ user: createMockUser('admin'),
+ expectedAllowed: true,
+ },
+ {
+ name: 'allows god role for admin page',
+ page: createMockPage('admin', {
+ permissions: { requiresAuth: true, requiredRole: 'admin' },
+ }),
+ user: createMockUser('god'),
+ expectedAllowed: true,
+ },
+ {
+ name: 'allows supergod role for god page',
+ page: createMockPage('god', {
+ permissions: { requiresAuth: true, requiredRole: 'god' },
+ }),
+ user: createMockUser('supergod'),
+ expectedAllowed: true,
+ },
+ ])('should handle $name', async ({ page, user, expectedAllowed, expectedReason }) => {
+ const result = await renderer.checkPermissions(page as PageDefinition, user)
+
+ expect(result.allowed).toBe(expectedAllowed)
+ if (expectedReason) {
+ expect(result.reason).toBe(expectedReason)
+ }
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/rendering/tests/page-renderer.test.ts b/frontends/nextjs/src/lib/rendering/tests/page-renderer.test.ts
deleted file mode 100644
index 562b2dc3d..000000000
--- a/frontends/nextjs/src/lib/rendering/tests/page-renderer.test.ts
+++ /dev/null
@@ -1,265 +0,0 @@
-/**
- * Tests for page-renderer.ts - Page rendering and permission checking
- * Following parameterized test pattern per project conventions
- */
-
-import { describe, it, expect, beforeEach, vi } from 'vitest'
-import type { PageDefinition } from './page-renderer'
-import type { User, UserRole } from '../types/level-types'
-
-// Mock Database
-const { Database, MockLuaEngine } = vi.hoisted(() => {
- class MockLuaEngine {
- execute = vi.fn()
- }
- return {
- Database: {
- getPages: vi.fn(),
- addPage: vi.fn(),
- getLuaScripts: vi.fn(),
- },
- MockLuaEngine,
- }
-})
-
-vi.mock('../database', () => ({ Database }))
-vi.mock('../lua-engine', () => ({ LuaEngine: MockLuaEngine }))
-
-import { PageRenderer, getPageRenderer } from './page-renderer'
-
-// Helper to create mock page definitions
-function createMockPage(
- id: string,
- options: Partial = {}
-): PageDefinition {
- return {
- id,
- level: options.level ?? 1,
- title: options.title ?? `Page ${id}`,
- layout: options.layout ?? 'default',
- components: options.components ?? [],
- permissions: options.permissions,
- luaScripts: options.luaScripts,
- metadata: options.metadata,
- }
-}
-
-// Helper to create mock users
-function createMockUser(role: string, id = 'user1'): User {
- return {
- id,
- username: `User ${id}`,
- role: role as UserRole,
- email: `${id}@test.com`,
- createdAt: Date.now(),
- }
-}
-
-describe('page-renderer', () => {
- let renderer: PageRenderer
-
- beforeEach(() => {
- vi.clearAllMocks()
- renderer = new PageRenderer()
- Database.getPages.mockResolvedValue([])
- Database.addPage.mockResolvedValue(undefined)
- Database.getLuaScripts.mockResolvedValue([])
- })
-
- describe('registerPage', () => {
- it('should register a page and add to database', async () => {
- const page = createMockPage('test-page', { title: 'Test Page' })
-
- await renderer.registerPage(page)
-
- expect(Database.addPage).toHaveBeenCalledWith(
- expect.objectContaining({
- id: 'test-page',
- title: 'Test Page',
- })
- )
- expect(renderer.getPage('test-page')).toEqual(page)
- })
-
- it('should handle pages with permissions', async () => {
- const page = createMockPage('auth-page', {
- permissions: {
- requiresAuth: true,
- requiredRole: 'admin',
- },
- })
-
- await renderer.registerPage(page)
-
- expect(Database.addPage).toHaveBeenCalledWith(
- expect.objectContaining({
- requiresAuth: true,
- requiredRole: 'admin',
- })
- )
- })
- })
-
- describe('loadPages', () => {
- it('should load pages from database', async () => {
- Database.getPages.mockResolvedValue([
- {
- id: 'page1',
- title: 'Page 1',
- level: 2,
- componentTree: [],
- requiresAuth: false,
- },
- {
- id: 'page2',
- title: 'Page 2',
- level: 3,
- componentTree: [{ id: 'c1', type: 'text' }],
- requiresAuth: true,
- requiredRole: 'admin',
- },
- ])
-
- await renderer.loadPages()
-
- expect(renderer.getPage('page1')).toBeDefined()
- expect(renderer.getPage('page2')).toBeDefined()
- expect(renderer.getPage('page1')?.title).toBe('Page 1')
- expect(renderer.getPage('page2')?.permissions?.requiresAuth).toBe(true)
- })
-
- it('should handle empty database', async () => {
- Database.getPages.mockResolvedValue([])
-
- await renderer.loadPages()
-
- expect(renderer.getPage('nonexistent')).toBeUndefined()
- })
- })
-
- describe('getPage', () => {
- it.each([
- {
- name: 'returns page when exists',
- pageId: 'existing',
- expectFound: true,
- },
- {
- name: 'returns undefined when not exists',
- pageId: 'nonexistent',
- expectFound: false,
- },
- ])('should handle $name', async ({ pageId, expectFound }) => {
- await renderer.registerPage(createMockPage('existing'))
-
- const result = renderer.getPage(pageId)
-
- if (expectFound) {
- expect(result).toBeDefined()
- expect(result?.id).toBe(pageId)
- } else {
- expect(result).toBeUndefined()
- }
- })
- })
-
- describe('getPagesByLevel', () => {
- it('should filter pages by level', async () => {
- await renderer.registerPage(createMockPage('p1', { level: 1 }))
- await renderer.registerPage(createMockPage('p2', { level: 2 }))
- await renderer.registerPage(createMockPage('p3', { level: 2 }))
- await renderer.registerPage(createMockPage('p4', { level: 3 }))
-
- const level2Pages = renderer.getPagesByLevel(2)
-
- expect(level2Pages).toHaveLength(2)
- expect(level2Pages.map(p => p.id)).toContain('p2')
- expect(level2Pages.map(p => p.id)).toContain('p3')
- })
-
- it('should return empty array for level with no pages', async () => {
- await renderer.registerPage(createMockPage('p1', { level: 1 }))
-
- const level5Pages = renderer.getPagesByLevel(5)
-
- expect(level5Pages).toHaveLength(0)
- })
- })
-
- describe('checkPermissions', () => {
- it.each([
- {
- name: 'allows when no permissions defined',
- page: createMockPage('open'),
- user: null,
- expectedAllowed: true,
- },
- {
- name: 'blocks unauthenticated user when auth required',
- page: createMockPage('auth', {
- permissions: { requiresAuth: true },
- }),
- user: null,
- expectedAllowed: false,
- expectedReason: 'Authentication required',
- },
- {
- name: 'allows authenticated user when auth required',
- page: createMockPage('auth', {
- permissions: { requiresAuth: true },
- }),
- user: createMockUser('user'),
- expectedAllowed: true,
- },
- {
- name: 'blocks user with insufficient role',
- page: createMockPage('admin', {
- permissions: { requiresAuth: true, requiredRole: 'admin' },
- }),
- user: createMockUser('user'),
- expectedAllowed: false,
- expectedReason: 'Insufficient permissions',
- },
- {
- name: 'allows user with sufficient role',
- page: createMockPage('admin', {
- permissions: { requiresAuth: true, requiredRole: 'admin' },
- }),
- user: createMockUser('admin'),
- expectedAllowed: true,
- },
- {
- name: 'allows god role for admin page',
- page: createMockPage('admin', {
- permissions: { requiresAuth: true, requiredRole: 'admin' },
- }),
- user: createMockUser('god'),
- expectedAllowed: true,
- },
- {
- name: 'allows supergod role for god page',
- page: createMockPage('god', {
- permissions: { requiresAuth: true, requiredRole: 'god' },
- }),
- user: createMockUser('supergod'),
- expectedAllowed: true,
- },
- ])('should handle $name', async ({ page, user, expectedAllowed, expectedReason }) => {
- const result = await renderer.checkPermissions(page, user)
-
- expect(result.allowed).toBe(expectedAllowed)
- if (expectedReason) {
- expect(result.reason).toBe(expectedReason)
- }
- })
- })
-
- describe('getPageRenderer singleton', () => {
- it('should return the same instance', () => {
- const instance1 = getPageRenderer()
- const instance2 = getPageRenderer()
-
- expect(instance1).toBe(instance2)
- })
- })
-})
diff --git a/frontends/nextjs/src/lib/schema-utils.ts b/frontends/nextjs/src/lib/schema-utils.ts
new file mode 100644
index 000000000..a02a1958d
--- /dev/null
+++ b/frontends/nextjs/src/lib/schema-utils.ts
@@ -0,0 +1,3 @@
+// Backward compatibility entry point for schema utilities
+// Prefer importing from '@/lib/schema' but keep legacy path working
+export * from './schema'
diff --git a/frontends/nextjs/src/lib/schema/SchemaUtils.ts b/frontends/nextjs/src/lib/schema/SchemaUtils.ts
index d56780371..bb4693aa2 100644
--- a/frontends/nextjs/src/lib/schema/SchemaUtils.ts
+++ b/frontends/nextjs/src/lib/schema/SchemaUtils.ts
@@ -1,27 +1,27 @@
import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types'
-
-// Import individual functions (lambdas)
-import { getModelKey } from './functions/get-model-key'
-import { getRecordsKey } from './functions/get-records-key'
-import { findModel } from './functions/find-model'
-import { getFieldLabel } from './functions/get-field-label'
-import { getModelLabel } from './functions/get-model-label'
-import { getModelLabelPlural } from './functions/get-model-label-plural'
-import { getHelpText } from './functions/get-help-text'
-import { generateId } from './functions/generate-id'
-import { validateField } from './functions/validate-field'
-import { validateRecord } from './functions/validate-record'
-import { getDefaultValue } from './functions/get-default-value'
-import { createEmptyRecord } from './functions/create-empty-record'
-import { sortRecords } from './functions/sort-records'
-import { filterRecords } from './functions/filter-records'
+import {
+ createEmptyRecord,
+ findModel,
+ filterRecords,
+ generateId,
+ getDefaultValue,
+ getFieldLabel,
+ getHelpText,
+ getModelKey,
+ getModelLabel,
+ getModelLabelPlural,
+ getRecordsKey,
+ sortRecords,
+ validateField,
+ validateRecord,
+} from './functions'
/**
* SchemaUtils - Class wrapper for schema utility functions
- *
+ *
* This class serves as a container for lambda functions related to schema operations.
* Each method delegates to an individual function file in the functions/ directory.
- *
+ *
* Pattern: "class is container for lambdas"
* - Each lambda is defined in its own file under functions/
* - This class wraps them for convenient namespaced access
diff --git a/frontends/nextjs/src/lib/schema/__tests__/schema-utils.fixtures.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.fixtures.ts
new file mode 100644
index 000000000..2eec75544
--- /dev/null
+++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.fixtures.ts
@@ -0,0 +1,30 @@
+import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types'
+
+export const createMockField = (): FieldSchema => ({
+ name: 'email',
+ type: 'email',
+ label: 'Email Address',
+ required: true,
+ helpText: 'Enter a valid email',
+})
+
+export const createMockModel = (): ModelSchema => ({
+ name: 'User',
+ label: 'User Account',
+ labelPlural: 'Users',
+ fields: [
+ { name: 'id', type: 'string', required: true },
+ { name: 'name', type: 'string', required: true, label: 'Full Name' },
+ { name: 'email', type: 'email', required: true },
+ { name: 'age', type: 'number' },
+ ],
+})
+
+export const createMockSchema = (): SchemaConfig => ({
+ apps: [
+ {
+ name: 'TestApp',
+ models: [createMockModel()],
+ },
+ ],
+})
diff --git a/frontends/nextjs/src/lib/schema/__tests__/schema-utils.migration.test.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.migration.test.ts
new file mode 100644
index 000000000..b766616f9
--- /dev/null
+++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.migration.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from 'vitest'
+import { findModel, getModelKey, getRecordsKey } from '@/lib/schema-utils'
+import type { SchemaConfig } from '@/lib/schema-types'
+import { createMockSchema } from './schema-utils.fixtures'
+
+describe('schema-utils migration', () => {
+ describe('getModelKey', () => {
+ it.each([
+ { appName: 'MyApp', modelName: 'User', expected: 'MyApp_User' },
+ { appName: 'app-v2', modelName: 'User_Profile', expected: 'app-v2_User_Profile' },
+ { appName: '', modelName: 'Model', expected: '_Model' },
+ ])('should generate key "$expected" for app=$appName, model=$modelName', ({ appName, modelName, expected }) => {
+ const result = getModelKey(appName, modelName)
+ expect(result).toBe(expected)
+ })
+ })
+
+ describe('getRecordsKey', () => {
+ it('should generate a records key with prefix', () => {
+ const result = getRecordsKey('MyApp', 'User')
+ expect(result).toBe('records_MyApp_User')
+ })
+
+ it('should include records prefix', () => {
+ const result = getRecordsKey('app', 'data')
+ expect(result).toMatch(/^records_/)
+ })
+ })
+
+ describe('findModel', () => {
+ it('should find a model by app and model name', () => {
+ const result = findModel(createMockSchema(), 'TestApp', 'User')
+ expect(result).toBeDefined()
+ expect(result?.name).toBe('User')
+ })
+
+ it('should return undefined if app not found', () => {
+ const result = findModel(createMockSchema(), 'NonExistentApp', 'User')
+ expect(result).toBeUndefined()
+ })
+
+ it('should return undefined if model not found in app', () => {
+ const result = findModel(createMockSchema(), 'TestApp', 'NonExistentModel')
+ expect(result).toBeUndefined()
+ })
+
+ it('should handle multiple apps correctly', () => {
+ const multiAppSchema: SchemaConfig = {
+ apps: [
+ { name: 'App1', models: [{ name: 'Model1', fields: [] }] },
+ { name: 'App2', models: [{ name: 'Model2', fields: [] }] },
+ ],
+ }
+ const result = findModel(multiAppSchema, 'App2', 'Model2')
+ expect(result?.name).toBe('Model2')
+ })
+ })
+})
diff --git a/frontends/nextjs/src/lib/schema/schema-utils.test.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts
similarity index 51%
rename from frontends/nextjs/src/lib/schema/schema-utils.test.ts
rename to frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts
index c19c83304..a6264b22f 100644
--- a/frontends/nextjs/src/lib/schema/schema-utils.test.ts
+++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts
@@ -1,108 +1,22 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
- getModelKey,
- getRecordsKey,
- findModel,
+ createEmptyRecord,
+ filterRecords,
+ generateId,
+ getDefaultValue,
getFieldLabel,
+ getHelpText,
getModelLabel,
getModelLabelPlural,
- getHelpText,
- generateId,
- validateField,
- validateRecord,
- getDefaultValue,
- createEmptyRecord,
sortRecords,
- filterRecords,
} from '@/lib/schema-utils'
-import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types'
-
-describe('schema-utils', () => {
- // Test data setup
- const mockField: FieldSchema = {
- name: 'email',
- type: 'email',
- label: 'Email Address',
- required: true,
- helpText: 'Enter a valid email',
- }
-
- const mockModel: ModelSchema = {
- name: 'User',
- label: 'User Account',
- labelPlural: 'Users',
- fields: [
- { name: 'id', type: 'string', required: true },
- { name: 'name', type: 'string', required: true, label: 'Full Name' },
- { name: 'email', type: 'email', required: true },
- { name: 'age', type: 'number' },
- ],
- }
-
- const mockSchema: SchemaConfig = {
- apps: [
- {
- name: 'TestApp',
- models: [mockModel],
- },
- ],
- }
-
- describe('getModelKey', () => {
- it.each([
- { appName: 'MyApp', modelName: 'User', expected: 'MyApp_User' },
- { appName: 'app-v2', modelName: 'User_Profile', expected: 'app-v2_User_Profile' },
- { appName: '', modelName: 'Model', expected: '_Model' },
- ])('should generate key "$expected" for app=$appName, model=$modelName', ({ appName, modelName, expected }) => {
- const result = getModelKey(appName, modelName)
- expect(result).toBe(expected)
- })
- })
-
- describe('getRecordsKey', () => {
- it('should generate a records key with prefix', () => {
- const result = getRecordsKey('MyApp', 'User')
- expect(result).toBe('records_MyApp_User')
- })
-
- it('should include records prefix', () => {
- const result = getRecordsKey('app', 'data')
- expect(result).toMatch(/^records_/)
- })
- })
-
- describe('findModel', () => {
- it('should find a model by app and model name', () => {
- const result = findModel(mockSchema, 'TestApp', 'User')
- expect(result).toBeDefined()
- expect(result?.name).toBe('User')
- })
-
- it('should return undefined if app not found', () => {
- const result = findModel(mockSchema, 'NonExistentApp', 'User')
- expect(result).toBeUndefined()
- })
-
- it('should return undefined if model not found in app', () => {
- const result = findModel(mockSchema, 'TestApp', 'NonExistentModel')
- expect(result).toBeUndefined()
- })
-
- it('should handle multiple apps correctly', () => {
- const multiAppSchema: SchemaConfig = {
- apps: [
- { name: 'App1', models: [{ name: 'Model1', fields: [] }] },
- { name: 'App2', models: [{ name: 'Model2', fields: [] }] },
- ],
- }
- const result = findModel(multiAppSchema, 'App2', 'Model2')
- expect(result?.name).toBe('Model2')
- })
- })
+import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
+import { createMockField, createMockModel } from './schema-utils.fixtures'
+describe('schema-utils serialization', () => {
describe('getFieldLabel', () => {
it.each([
- { field: mockField, expected: 'Email Address', description: 'custom label' },
+ { field: createMockField(), expected: 'Email Address', description: 'custom label' },
{ field: { name: 'email', type: 'email' }, expected: 'Email', description: 'auto-capitalized field name' },
{ field: { name: 'firstName', type: 'string' }, expected: 'FirstName', description: 'multi-word field name' },
])('should return $description', ({ field, expected }) => {
@@ -113,7 +27,7 @@ describe('schema-utils', () => {
describe('getModelLabel', () => {
it('should return custom label if provided', () => {
- const result = getModelLabel(mockModel)
+ const result = getModelLabel(createMockModel())
expect(result).toBe('User Account')
})
@@ -126,7 +40,7 @@ describe('schema-utils', () => {
describe('getModelLabelPlural', () => {
it('should return custom plural label if provided', () => {
- const result = getModelLabelPlural(mockModel)
+ const result = getModelLabelPlural(createMockModel())
expect(result).toBe('Users')
})
@@ -139,7 +53,7 @@ describe('schema-utils', () => {
describe('getHelpText', () => {
it('should return help text if string', () => {
- const result = getHelpText(mockField)
+ const result = getHelpText(createMockField())
expect(result).toBe('Enter a valid email')
})
@@ -178,135 +92,6 @@ describe('schema-utils', () => {
})
})
- describe('validateField', () => {
- it.each([
- {
- name: 'required field empty',
- field: { name: 'email', type: 'email', required: true },
- value: '',
- shouldHaveError: true,
- },
- {
- name: 'non-required field empty',
- field: { name: 'nickname', type: 'string', required: false },
- value: '',
- shouldHaveError: false,
- },
- {
- name: 'invalid email',
- field: { name: 'email', type: 'email' },
- value: 'invalid',
- shouldHaveError: true,
- },
- {
- name: 'valid email',
- field: { name: 'email', type: 'email' },
- value: 'test@example.com',
- shouldHaveError: false,
- },
- {
- name: 'invalid URL',
- field: { name: 'website', type: 'url' },
- value: 'not a url',
- shouldHaveError: true,
- },
- {
- name: 'valid URL',
- field: { name: 'website', type: 'url' },
- value: 'https://example.com',
- shouldHaveError: false,
- },
- {
- name: 'number below min',
- field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
- value: -1,
- shouldHaveError: true,
- },
- {
- name: 'number above max',
- field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
- value: 200,
- shouldHaveError: true,
- },
- {
- name: 'valid number in range',
- field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
- value: 25,
- shouldHaveError: false,
- },
- {
- name: 'string too short',
- field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
- value: 'short',
- shouldHaveError: true,
- },
- {
- name: 'string too long',
- field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
- value: 'verylongpasswordthatexceedslimit',
- shouldHaveError: true,
- },
- {
- name: 'valid string length',
- field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
- value: 'goodpass123',
- shouldHaveError: false,
- },
- {
- name: 'valid pattern match',
- field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } },
- value: 'ABC-123',
- shouldHaveError: false,
- },
- {
- name: 'invalid pattern match',
- field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } },
- value: 'abc-123',
- shouldHaveError: true,
- },
- ])('should $name', ({ field, value, shouldHaveError }) => {
- const result = validateField(field as FieldSchema, value)
- if (shouldHaveError) {
- expect(result).toBeTruthy()
- } else {
- expect(result).toBeNull()
- }
- })
- })
-
- describe('validateRecord', () => {
- it('should validate all fields in a record', () => {
- const record = { id: '1', name: 'John', email: 'invalid-email' }
- const errors = validateRecord(mockModel, record)
- expect(errors.email).toBeTruthy()
- })
-
- it('should return empty errors for valid record', () => {
- const record = {
- id: '1',
- name: 'John Doe',
- email: 'john@example.com',
- age: 30,
- }
- const errors = validateRecord(mockModel, record)
- expect(Object.keys(errors).length).toBe(0)
- })
-
- it('should skip non-editable fields', () => {
- const model: ModelSchema = {
- name: 'Post',
- fields: [
- { name: 'id', type: 'string', editable: false },
- { name: 'title', type: 'string', required: true },
- ],
- }
- const record = { title: '' }
- const errors = validateRecord(model, record)
- expect(errors.id).toBeUndefined()
- expect(errors.title).toBeTruthy()
- })
- })
-
describe('getDefaultValue', () => {
it.each([
{ field: { name: 'count', type: 'number', default: 42 }, expected: 42, description: 'custom default' },
@@ -335,7 +120,7 @@ describe('schema-utils', () => {
describe('createEmptyRecord', () => {
it('should create a record with all fields', () => {
- const record = createEmptyRecord(mockModel)
+ const record = createEmptyRecord(createMockModel())
expect(record.id).toBeDefined()
expect(record.name).toBe('')
expect(record.email).toBe('')
@@ -343,8 +128,8 @@ describe('schema-utils', () => {
})
it('should generate unique ID', () => {
- const record1 = createEmptyRecord(mockModel)
- const record2 = createEmptyRecord(mockModel)
+ const record1 = createEmptyRecord(createMockModel())
+ const record2 = createEmptyRecord(createMockModel())
expect(record1.id).not.toBe(record2.id)
})
diff --git a/frontends/nextjs/src/lib/schema/__tests__/schema-utils.validation.test.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.validation.test.ts
new file mode 100644
index 000000000..ba56e9239
--- /dev/null
+++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.validation.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect } from 'vitest'
+import { validateField, validateRecord } from '@/lib/schema-utils'
+import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
+import { createMockModel } from './schema-utils.fixtures'
+
+describe('schema-utils validation', () => {
+ describe('validateField', () => {
+ it.each([
+ {
+ name: 'required field empty',
+ field: { name: 'email', type: 'email', required: true },
+ value: '',
+ shouldHaveError: true,
+ },
+ {
+ name: 'non-required field empty',
+ field: { name: 'nickname', type: 'string', required: false },
+ value: '',
+ shouldHaveError: false,
+ },
+ {
+ name: 'invalid email',
+ field: { name: 'email', type: 'email' },
+ value: 'invalid',
+ shouldHaveError: true,
+ },
+ {
+ name: 'valid email',
+ field: { name: 'email', type: 'email' },
+ value: 'test@example.com',
+ shouldHaveError: false,
+ },
+ {
+ name: 'invalid URL',
+ field: { name: 'website', type: 'url' },
+ value: 'not a url',
+ shouldHaveError: true,
+ },
+ {
+ name: 'valid URL',
+ field: { name: 'website', type: 'url' },
+ value: 'https://example.com',
+ shouldHaveError: false,
+ },
+ {
+ name: 'number below min',
+ field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
+ value: -1,
+ shouldHaveError: true,
+ },
+ {
+ name: 'number above max',
+ field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
+ value: 200,
+ shouldHaveError: true,
+ },
+ {
+ name: 'valid number in range',
+ field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
+ value: 25,
+ shouldHaveError: false,
+ },
+ {
+ name: 'string too short',
+ field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
+ value: 'short',
+ shouldHaveError: true,
+ },
+ {
+ name: 'string too long',
+ field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
+ value: 'verylongpasswordthatexceedslimit',
+ shouldHaveError: true,
+ },
+ {
+ name: 'valid string length',
+ field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
+ value: 'goodpass123',
+ shouldHaveError: false,
+ },
+ {
+ name: 'valid pattern match',
+ field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } },
+ value: 'ABC-123',
+ shouldHaveError: false,
+ },
+ {
+ name: 'invalid pattern match',
+ field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } },
+ value: 'abc-123',
+ shouldHaveError: true,
+ },
+ ])('should $name', ({ field, value, shouldHaveError }) => {
+ const result = validateField(field as FieldSchema, value)
+ if (shouldHaveError) {
+ expect(result).toBeTruthy()
+ } else {
+ expect(result).toBeNull()
+ }
+ })
+ })
+
+ describe('validateRecord', () => {
+ it('should validate all fields in a record', () => {
+ const record = { id: '1', name: 'John', email: 'invalid-email' }
+ const errors = validateRecord(createMockModel(), record)
+ expect(errors.email).toBeTruthy()
+ })
+
+ it('should return empty errors for valid record', () => {
+ const record = {
+ id: '1',
+ name: 'John Doe',
+ email: 'john@example.com',
+ age: 30,
+ }
+ const errors = validateRecord(createMockModel(), record)
+ expect(Object.keys(errors).length).toBe(0)
+ })
+
+ it('should skip non-editable fields', () => {
+ const model: ModelSchema = {
+ name: 'Post',
+ fields: [
+ { name: 'id', type: 'string', editable: false },
+ { name: 'title', type: 'string', required: true },
+ ],
+ }
+ const record = { title: '' }
+ const errors = validateRecord(model, record)
+ expect(errors.id).toBeUndefined()
+ expect(errors.title).toBeTruthy()
+ })
+ })
+})
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/functions/index.ts b/frontends/nextjs/src/lib/schema/functions/index.ts
index 5575a442a..036a235a8 100644
--- a/frontends/nextjs/src/lib/schema/functions/index.ts
+++ b/frontends/nextjs/src/lib/schema/functions/index.ts
@@ -1,15 +1,15 @@
// Individual function exports
-export { getModelKey } from './get-model-key'
-export { getRecordsKey } from './get-records-key'
-export { findModel } from './find-model'
-export { getFieldLabel } from './get-field-label'
-export { getModelLabel } from './get-model-label'
-export { getModelLabelPlural } from './get-model-label-plural'
-export { getHelpText } from './get-help-text'
-export { generateId } from './generate-id'
-export { validateField } from './validate-field'
-export { validateRecord } from './validate-record'
-export { getDefaultValue } from './get-default-value'
-export { createEmptyRecord } from './create-empty-record'
-export { sortRecords } from './sort-records'
-export { filterRecords } from './filter-records'
+export { getModelKey } from './model/get-model-key'
+export { getRecordsKey } from './record/get-records-key'
+export { findModel } from './model/find-model'
+export { getFieldLabel } from './field/get-field-label'
+export { getModelLabel } from './model/get-model-label'
+export { getModelLabelPlural } from './model/get-model-label-plural'
+export { getHelpText } from './field/get-help-text'
+export { generateId } from './record/crud/generate-id'
+export { validateField } from './field/validate-field'
+export { validateRecord } from './record/validate-record'
+export { getDefaultValue } from './field/get-default-value'
+export { createEmptyRecord } from './record/crud/create-empty-record'
+export { sortRecords } from './record/sort-records'
+export { filterRecords } from './record/filter-records'
diff --git a/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts b/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts
index f6cdce151..441524bde 100644
--- a/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts
+++ b/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts
@@ -1,6 +1,6 @@
import type { ModelSchema } from '@/lib/schema-types'
import { generateId } from './generate-id'
-import { getDefaultValue } from './get-default-value'
+import { getDefaultValue } from '../../field/get-default-value'
/**
* Create an empty record with default values for a model
@@ -9,7 +9,7 @@ import { getDefaultValue } from './get-default-value'
*/
export const createEmptyRecord = (model: ModelSchema): any => {
const record: any = {}
-
+
for (const field of model.fields) {
if (field.name === 'id') {
record.id = generateId()
@@ -19,6 +19,6 @@ export const createEmptyRecord = (model: ModelSchema): any => {
record[field.name] = getDefaultValue(field)
}
}
-
+
return record
}
diff --git a/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts b/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts
index 9b61543da..77e842a8f 100644
--- a/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts
+++ b/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts
@@ -1,5 +1,5 @@
import type { ModelSchema } from '@/lib/schema-types'
-import { validateField } from './validate-field'
+import { validateField } from '../field/validate-field'
/**
* Validate a record against its model schema
@@ -12,7 +12,7 @@ export const validateRecord = (
record: any
): Record => {
const errors: Record = {}
-
+
for (const field of model.fields) {
if (field.editable === false) continue
const error = validateField(field, record[field.name])
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'
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: / ',
+ 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'
diff --git a/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.errors.test.ts b/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.errors.test.ts
new file mode 100644
index 000000000..5e4aba120
--- /dev/null
+++ b/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.errors.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { WorkflowEngine } from '../workflow-engine'
+import { createContext, createNode, createWorkflow } from './workflow-engine.fixtures'
+
+describe('workflow-engine errors', () => {
+ it('fails unknown node types with a clear error', async () => {
+ const workflow = createWorkflow('err-1', 'Unknown node', [
+ createNode('mystery', 'unknown' as any, 'Mystery node'),
+ ])
+
+ const result = await WorkflowEngine.execute(workflow, createContext({}))
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('Unknown node type')
+ })
+
+ it('reports condition evaluation failures', async () => {
+ const workflow = createWorkflow('err-2', 'Bad condition', [
+ createNode('trigger', 'trigger', 'Start trigger'),
+ createNode('condition', 'condition', 'Broken', {
+ condition: '(() => { throw new Error("nope") })()',
+ }),
+ ])
+
+ const result = await WorkflowEngine.execute(workflow, createContext({}))
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('Condition evaluation failed')
+ expect(result.outputs.trigger).toEqual({})
+ })
+
+ it('stops after configured retries when a node keeps failing', async () => {
+ const workflow = createWorkflow('err-3', 'Retry failure', [
+ createNode('trigger', 'trigger', 'Start trigger'),
+ createNode('retry', 'transform', 'Keep failing', {
+ retry: { maxAttempts: 2, delayMs: 0 },
+ transform: '(() => { throw new Error("still failing") })()',
+ }),
+ ])
+
+ const result = await WorkflowEngine.execute(workflow, createContext({ data: 1 }))
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('Transform failed')
+ expect(result.logs.filter((log) => log.includes('Retrying node'))).toHaveLength(1)
+ })
+
+ it('propagates Lua script resolution errors', async () => {
+ const workflow = createWorkflow('err-4', 'Missing script', [
+ createNode('lua', 'lua', 'Lookup', { scriptId: 'missing-script' }),
+ ])
+
+ const result = await WorkflowEngine.execute(workflow, createContext({}, { scripts: [] }))
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('Script not found: missing-script')
+ })
+})
diff --git a/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.execution.test.ts b/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.execution.test.ts
new file mode 100644
index 000000000..b2dc093ff
--- /dev/null
+++ b/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.execution.test.ts
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { createWorkflowEngine, WorkflowEngine } from '../workflow-engine'
+import { createContext, createNode, createWorkflow } from './workflow-engine.fixtures'
+
+describe('workflow-engine execution', () => {
+ let engine: WorkflowEngine
+
+ beforeEach(() => {
+ engine = createWorkflowEngine()
+ })
+
+ it('executes nodes sequentially and returns aggregated outputs', async () => {
+ const workflow = createWorkflow('exec-1', 'Sequential run', [
+ createNode('trigger', 'trigger', 'Start trigger'),
+ createNode('transform', 'transform', 'Add one', { transform: 'data.value + 1' }),
+ createNode('action', 'action', 'Echo'),
+ ])
+
+ const result = await engine.executeWorkflow(workflow, createContext({ value: 5 }))
+
+ expect(result.success).toBe(true)
+ expect(result.outputs.trigger).toEqual({ value: 5 })
+ expect(result.outputs.transform).toBe(6)
+ expect(result.outputs.action).toBe(6)
+ expect(result.logs.at(-1)).toContain('Workflow completed successfully')
+ })
+
+ it('stops execution after a false condition while keeping prior outputs', async () => {
+ const workflow = createWorkflow('exec-2', 'Early stop', [
+ createNode('trigger', 'trigger', 'Start trigger'),
+ createNode('condition', 'condition', 'Stopper', { condition: 'false' }),
+ createNode('action', 'action', 'Should not run'),
+ ])
+
+ const result = await engine.executeWorkflow(workflow, createContext({}))
+
+ expect(result.success).toBe(true)
+ expect(result.outputs.action).toBeUndefined()
+ expect(Object.keys(result.outputs)).toHaveLength(2)
+ expect(result.logs.some((log) => log.includes('Condition node returned false'))).toBe(true)
+ })
+
+ it('passes user context through to Lua nodes', async () => {
+ const workflow = createWorkflow('exec-3', 'Lua context', [
+ createNode('lua', 'lua', 'User echo', { code: 'return context.user.id' }),
+ ])
+
+ const context = createContext({}, { user: { id: 'user-123' } })
+ const result = await WorkflowEngine.execute(workflow, context)
+
+ expect(result.success).toBe(true)
+ expect(result.outputs.lua).toBe('user-123')
+ expect(result.logs[0]).toContain('Starting workflow: Lua context')
+ })
+})
diff --git a/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.fixtures.ts b/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.fixtures.ts
new file mode 100644
index 000000000..e1ab9e686
--- /dev/null
+++ b/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.fixtures.ts
@@ -0,0 +1,22 @@
+import type { Workflow, WorkflowNode } from '../../../types/level-types'
+import type { WorkflowExecutionContext } from '../../workflow-execution-context'
+
+export function createNode(
+ id: string,
+ type: WorkflowNode['type'],
+ label: string,
+ config: Record = {}
+): WorkflowNode {
+ return { id, type, label, config, position: { x: 0, y: 0 } }
+}
+
+export function createWorkflow(id: string, name: string, nodes: WorkflowNode[]): Workflow {
+ return { id, name, nodes, edges: [], enabled: true }
+}
+
+export function createContext(
+ data: any = {},
+ overrides: Partial = {}
+): WorkflowExecutionContext {
+ return { data, ...overrides }
+}
diff --git a/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.persistence.test.ts b/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.persistence.test.ts
new file mode 100644
index 000000000..adcc6d40f
--- /dev/null
+++ b/frontends/nextjs/src/lib/workflow/engine/__tests__/workflow-engine.persistence.test.ts
@@ -0,0 +1,55 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { WorkflowEngine } from '../workflow-engine'
+import { createContext, createNode, createWorkflow } from './workflow-engine.fixtures'
+import * as SandboxFactory from '../../../lua/engine/sandbox/create-sandboxed-lua-engine'
+import type { SandboxedLuaResult } from '../../../lua/engine/sandbox/sandboxed-lua-engine'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('workflow-engine persistence', () => {
+ it('returns accumulated outputs and logs when a node fails', async () => {
+ const workflow = createWorkflow('persist-1', 'Failure keeps history', [
+ createNode('trigger', 'trigger', 'Start trigger'),
+ createNode('transform', 'transform', 'Break here', {
+ transform: '(() => { throw new Error("boom"); })()',
+ }),
+ createNode('action', 'action', 'Should not run'),
+ ])
+
+ const result = await WorkflowEngine.execute(workflow, createContext({ value: 3 }))
+
+ expect(result.success).toBe(false)
+ expect(result.outputs.trigger).toEqual({ value: 3 })
+ expect(result.outputs.action).toBeUndefined()
+ expect(result.logs.some((log) => log.includes('Break here'))).toBe(true)
+ expect(result.error).toContain('Transform failed')
+ })
+
+ it('persists security warnings from Lua execution', async () => {
+ const sandboxResult: SandboxedLuaResult = {
+ execution: { success: true, result: 99, logs: ['lua log'] },
+ security: { severity: 'high', issues: [{ message: 'uses os' }] } as any,
+ }
+
+ const mockEngine = {
+ executeWithSandbox: vi.fn(async () => sandboxResult),
+ destroy: vi.fn(),
+ }
+
+ vi.spyOn(SandboxFactory, 'createSandboxedLuaEngine').mockReturnValue(mockEngine as any)
+
+ const workflow = createWorkflow('persist-2', 'Lua security', [
+ createNode('lua', 'lua', 'Sandboxed', { code: 'return 1' }),
+ ])
+
+ const result = await WorkflowEngine.execute(workflow, createContext({}))
+
+ expect(result.success).toBe(true)
+ expect(result.outputs.lua).toBe(99)
+ expect(result.securityWarnings).toContain('Security issues detected: uses os')
+ expect(result.logs.some((log) => log.includes('[Lua] lua log'))).toBe(true)
+ expect(mockEngine.destroy).toHaveBeenCalled()
+ })
+})
diff --git a/frontends/nextjs/src/lib/workflow/engine/workflow-engine.test.ts b/frontends/nextjs/src/lib/workflow/engine/workflow-engine.test.ts
deleted file mode 100644
index bc91ceb0a..000000000
--- a/frontends/nextjs/src/lib/workflow/engine/workflow-engine.test.ts
+++ /dev/null
@@ -1,388 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest'
-import { WorkflowEngine, createWorkflowEngine } from './workflow-engine'
-import type { Workflow, WorkflowNode, LuaScript } from '../types/level-types'
-
-// Helper to create a minimal valid WorkflowNode
-function createNode(
- id: string,
- type: WorkflowNode['type'],
- label: string,
- config: Record = {}
-): WorkflowNode {
- return { id, type, label, config, position: { x: 0, y: 0 } }
-}
-
-// Helper to create a minimal valid Workflow
-function createWorkflow(
- id: string,
- name: string,
- nodes: WorkflowNode[]
-): Workflow {
- return { id, name, nodes, edges: [], enabled: true }
-}
-
-describe('workflow-engine', () => {
- let engine: WorkflowEngine
-
- beforeEach(() => {
- engine = createWorkflowEngine()
- })
-
- describe('createWorkflowEngine', () => {
- it('should create a new WorkflowEngine instance', () => {
- const engine = createWorkflowEngine()
- expect(engine).toBeInstanceOf(WorkflowEngine)
- })
- })
-
- describe('executeWorkflow', () => {
- it.each([
- {
- name: 'simple trigger workflow',
- workflow: createWorkflow('w1', 'Simple Workflow', [
- createNode('n1', 'trigger', 'Start'),
- ]),
- context: { data: { test: 'value' } },
- expected: {
- success: true,
- hasOutputs: true,
- outputCount: 1,
- },
- },
- {
- name: 'workflow with action node',
- workflow: createWorkflow('w2', 'Action Workflow', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'action', 'Process', { action: 'process' }),
- ]),
- context: { data: { value: 42 } },
- expected: {
- success: true,
- hasOutputs: true,
- outputCount: 2,
- },
- },
- {
- name: 'workflow with transform node',
- workflow: createWorkflow('w3', 'Transform Workflow', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'transform', 'Double', { transform: 'data.value * 2' }),
- ]),
- context: { data: { value: 5 } },
- expected: {
- success: true,
- hasOutputs: true,
- outputCount: 2,
- },
- },
- ])('should execute $name successfully', async ({ workflow, context, expected }) => {
- const result = await engine.executeWorkflow(workflow, context)
-
- expect(result.success).toBe(expected.success)
- expect(result.error).toBeUndefined()
- expect(Object.keys(result.outputs)).toHaveLength(expected.outputCount)
- expect(result.logs.length).toBeGreaterThan(0)
- expect(result.logs[0]).toContain(`Starting workflow: ${workflow.name}`)
- })
-
- it.each([
- {
- name: 'false condition stops execution',
- workflow: createWorkflow('w4', 'Condition Workflow', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'condition', 'Check', { condition: 'false' }),
- createNode('n3', 'action', 'Never Run'),
- ]),
- context: { data: {} },
- expected: {
- success: true,
- outputCount: 2, // Only trigger and condition, not the action
- stoppedEarly: true,
- },
- },
- {
- name: 'true condition continues execution',
- workflow: createWorkflow('w5', 'Pass Condition', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'condition', 'Check', { condition: 'true' }),
- createNode('n3', 'action', 'Should Run'),
- ]),
- context: { data: {} },
- expected: {
- success: true,
- outputCount: 3, // All nodes execute
- stoppedEarly: false,
- },
- },
- {
- name: 'data-based condition',
- workflow: createWorkflow('w6', 'Data Condition', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'condition', 'Check Value', { condition: 'data.value > 10' }),
- ]),
- context: { data: { value: 5 } },
- expected: {
- success: true,
- outputCount: 2,
- stoppedEarly: true,
- },
- },
- ])('should handle $name', async ({ workflow, context, expected }) => {
- const result = await engine.executeWorkflow(workflow, context)
-
- expect(result.success).toBe(expected.success)
- expect(Object.keys(result.outputs)).toHaveLength(expected.outputCount)
-
- if (expected.stoppedEarly) {
- expect(result.logs.some(log => log.includes('stopping workflow'))).toBe(true)
- }
- })
-
- it.each([
- {
- name: 'node with unknown type',
- workflow: createWorkflow('w7', 'Invalid Node', [
- createNode('n1', 'unknown' as any, 'Bad Node'),
- ]),
- context: { data: {} },
- expectedError: 'Unknown node type',
- },
- {
- name: 'invalid condition syntax',
- workflow: createWorkflow('w8', 'Bad Condition', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'condition', 'Invalid', { condition: 'invalid syntax !' }),
- ]),
- context: { data: {} },
- expectedError: 'failed',
- },
- {
- name: 'invalid transform syntax',
- workflow: createWorkflow('w9', 'Bad Transform', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'transform', 'Invalid', { transform: 'this is not valid javascript' }),
- ]),
- context: { data: {} },
- expectedError: 'Transform failed',
- },
- ])('should handle node error: $name', async ({ workflow, context, expectedError }) => {
- const result = await engine.executeWorkflow(workflow, context)
-
- expect(result.success).toBe(false)
- expect(result.error).toBeDefined()
- expect(result.error?.toLowerCase()).toContain(expectedError.toLowerCase())
- expect(result.logs.length).toBeGreaterThan(0)
- })
-
- it('retries a failing node until it succeeds', async () => {
- const context: any = { data: { value: 10 }, meta: { attempts: 0 } }
- const workflow = createWorkflow('w-retry', 'Retry Workflow', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'transform', 'Retry Transform', {
- retry: { maxAttempts: 3, delayMs: 0 },
- transform:
- '(() => { context.meta.attempts += 1; if (context.meta.attempts < 2) { throw new Error("boom"); } return data.value + 1; })()',
- }),
- ])
-
- const result = await engine.executeWorkflow(workflow, context)
-
- expect(result.success).toBe(true)
- expect(result.outputs.n2).toBe(11)
- expect(context.meta.attempts).toBe(2)
- expect(result.logs.some(log => log.includes('Retrying node'))).toBe(true)
- })
-
- it('stops retrying after max attempts', async () => {
- const context: any = { data: { value: 1 }, meta: { attempts: 0 } }
- const workflow = createWorkflow('w-retry-fail', 'Retry Failure', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'transform', 'Always Fail', {
- retry: { maxAttempts: 2, delayMs: 0 },
- transform:
- '(() => { context.meta.attempts += 1; throw new Error("still failing"); })()',
- }),
- ])
-
- const result = await engine.executeWorkflow(workflow, context)
-
- expect(result.success).toBe(false)
- expect(result.error?.toLowerCase()).toContain('transform failed')
- expect(context.meta.attempts).toBe(2)
- expect(result.logs.some(log => log.includes('Retrying node'))).toBe(true)
- })
- })
-
- describe('Lua node execution', () => {
- it.each([
- {
- name: 'simple Lua return',
- node: createNode('n1', 'lua', 'Lua Node', { code: 'return 42' }),
- data: {},
- expectedOutput: 42,
- expectedSuccess: true,
- },
- {
- name: 'Lua with data access',
- node: createNode('n2', 'lua', 'Data Access', { code: 'return context.data.value * 2' }),
- data: { value: 21 },
- expectedOutput: 42,
- expectedSuccess: true,
- },
- {
- name: 'Lua with default code',
- node: createNode('n3', 'lua', 'Default'),
- data: { test: 'value' },
- expectedOutput: { test: 'value' },
- expectedSuccess: true,
- },
- ])('should execute $name', async ({ node, data, expectedOutput, expectedSuccess }) => {
- const workflow = createWorkflow('w-lua', 'Lua Test', [node])
-
- const result = await engine.executeWorkflow(workflow, { data })
-
- expect(result.success).toBe(expectedSuccess)
- expect(result.outputs[node.id]).toEqual(expectedOutput)
- })
-
- it.each([
- {
- name: 'Lua with script reference',
- scripts: [
- { id: 'script1', name: 'Test Script', code: 'return 100', description: '' },
- ] as LuaScript[],
- node: createNode('n1', 'lua', 'Script Ref', { scriptId: 'script1' }),
- expectedOutput: 100,
- },
- {
- name: 'Lua with missing script',
- scripts: [] as LuaScript[],
- node: createNode('n2', 'lua', 'Missing Script', { scriptId: 'nonexistent' }),
- expectedError: 'Script not found',
- },
- ])('should handle $name', async ({ scripts, node, expectedOutput, expectedError }) => {
- const workflow = createWorkflow('w-script', 'Script Test', [node])
-
- const result = await engine.executeWorkflow(workflow, { data: {}, scripts })
-
- if (expectedError) {
- expect(result.success).toBe(false)
- expect(result.error).toContain(expectedError)
- } else {
- expect(result.success).toBe(true)
- expect(result.outputs[node.id]).toBe(expectedOutput)
- }
- })
-
- it.each([
- {
- name: 'syntax error',
- code: 'this is not valid lua !!',
- expectedError: true,
- },
- {
- name: 'runtime error',
- code: 'error("Intentional error")',
- expectedError: true,
- },
- ])('should handle Lua error: $name', async ({ code, expectedError }) => {
- const workflow = createWorkflow('w-error', 'Error Test', [
- createNode('n1', 'lua', 'Error Node', { code }),
- ])
-
- const result = await engine.executeWorkflow(workflow, { data: {} })
-
- expect(result.success).toBe(!expectedError)
- if (expectedError) {
- expect(result.error).toBeDefined()
- }
- })
-
- it('should capture Lua security warnings', async () => {
- // Test with code that might trigger security warnings
- const workflow = createWorkflow('w-security', 'Security Test', [
- createNode('n1', 'lua', 'Security Check', {
- code: `
- -- Attempting unsafe operations
- return 42
- `,
- }),
- ])
-
- const result = await engine.executeWorkflow(workflow, { data: {} })
-
- // Should still execute successfully due to sandbox
- expect(result.success).toBe(true)
- expect(result.securityWarnings).toBeDefined()
- })
- })
-
- describe('logging and context', () => {
- it('should capture logs from all nodes', async () => {
- const workflow = createWorkflow('w-log', 'Log Test', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'action', 'Action', { action: 'test' }),
- createNode('n3', 'transform', 'Transform', { transform: '42' }),
- ])
-
- const result = await engine.executeWorkflow(workflow, { data: {} })
-
- expect(result.logs.length).toBeGreaterThan(0)
- expect(result.logs[0]).toContain('Starting workflow')
- expect(result.logs.some(log => log.includes('Executing node'))).toBe(true)
- expect(result.logs[result.logs.length - 1]).toContain('completed successfully')
- })
-
- it('should pass user context to nodes', async () => {
- const user = { id: 'user1', name: 'Test User' }
- const workflow = createWorkflow('w-user', 'User Context', [
- createNode('n1', 'lua', 'Get User', { code: 'return context.user.name' }),
- ])
-
- const result = await engine.executeWorkflow(workflow, { data: {}, user })
-
- expect(result.success).toBe(true)
- expect(result.outputs.n1).toBe('Test User')
- })
- })
-
- describe('complex workflow scenarios', () => {
- it('should execute multi-stage workflow with data flow', async () => {
- // Note: In this workflow, data flows through each node
- // n1: trigger passes through initial data
- // n2: transforms data.value to data.value + 10
- // n3: condition evaluates data > 50, passes through boolean (true)
- // n4: transforms data (now true/1) * 2 = 2
- const workflow = createWorkflow('w-complex', 'Complex Workflow', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'transform', 'Add 10', { transform: 'data.value + 10' }),
- createNode('n3', 'condition', 'Check > 50', { condition: 'data > 50' }),
- createNode('n4', 'transform', 'Double', { transform: 'data * 2' }),
- ])
-
- const result = await engine.executeWorkflow(workflow, { data: { value: 45 } })
-
- expect(result.success).toBe(true)
- expect(result.outputs.n2).toBe(55) // 45 + 10
- expect(result.outputs.n3).toBe(true) // 55 > 50
- // n4 receives `true` (JS coerces to 1) and multiplies by 2
- expect(result.outputs.n4).toBe(2) // true * 2 = 2
- })
-
- it('should handle workflow with early termination', async () => {
- const workflow = createWorkflow('w-early', 'Early Stop', [
- createNode('n1', 'trigger', 'Start'),
- createNode('n2', 'condition', 'Always False', { condition: 'false' }),
- createNode('n3', 'action', 'Never Runs'),
- createNode('n4', 'action', 'Also Never Runs'),
- ])
-
- const result = await engine.executeWorkflow(workflow, { data: {} })
-
- expect(result.success).toBe(true)
- expect(Object.keys(result.outputs)).toHaveLength(2) // Only n1 and n2
- expect(result.outputs.n3).toBeUndefined()
- expect(result.outputs.n4).toBeUndefined()
- })
- })
-})
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'
diff --git a/tools/analysis/test/cli.ts b/tools/analysis/test/cli.ts
new file mode 100644
index 000000000..32521d2e6
--- /dev/null
+++ b/tools/analysis/test/cli.ts
@@ -0,0 +1,8 @@
+#!/usr/bin/env tsx
+
+import { runTestAnalysis } from './report'
+
+runTestAnalysis(process.argv[2]).catch(error => {
+ console.error('Failed to generate test analysis report:', error)
+ process.exit(1)
+})
diff --git a/tools/analysis/test/report.ts b/tools/analysis/test/report.ts
new file mode 100644
index 000000000..a43a658a5
--- /dev/null
+++ b/tools/analysis/test/report.ts
@@ -0,0 +1,36 @@
+import { writeFileSync } from 'fs'
+import { analyzeCoverage, CoverageReport } from './analyze-test-coverage/coverage-runner'
+import { analyzeImplementations } from './analyze-implementation-completeness/analyze-implementations'
+import { summarizeAnalyses } from './analyze-implementation-completeness/summarize-analyses'
+import { AnalysisSummary } from './analyze-implementation-completeness/types'
+
+export interface TestAnalysisReport {
+ coverage: CoverageReport
+ implementationCompleteness: AnalysisSummary
+}
+
+export const buildTestAnalysisReport = async (): Promise => {
+ const [coverage, implementationCompleteness] = await Promise.all([
+ analyzeCoverage(),
+ Promise.resolve(summarizeAnalyses(analyzeImplementations()))
+ ])
+
+ return { coverage, implementationCompleteness }
+}
+
+export const writeTestAnalysisReport = (report: TestAnalysisReport, outputPath = 'test-analysis-report.json'): void => {
+ try {
+ writeFileSync(outputPath, JSON.stringify(report, null, 2))
+ console.error(`Test analysis written to ${outputPath}`)
+ } catch (error) {
+ console.error(`Failed to write test analysis to ${outputPath}:`, error)
+ }
+}
+
+export const runTestAnalysis = async (outputPath?: string): Promise => {
+ const report = await buildTestAnalysisReport()
+ const destination = outputPath ?? 'test-analysis-report.json'
+
+ writeTestAnalysisReport(report, destination)
+ console.log(JSON.stringify(report, null, 2))
+}
diff --git a/tools/generation/generate-test-coverage-report.js b/tools/generation/generate-test-coverage-report.js
deleted file mode 100644
index c6246fc13..000000000
--- a/tools/generation/generate-test-coverage-report.js
+++ /dev/null
@@ -1,199 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Test Coverage Report Generator
- *
- * Generates a comprehensive report of function-to-test mapping
- * Identifies untested functions and provides actionable recommendations
- */
-
-import fs from "fs";
-import path from "path";
-
-function findFiles(dir, pattern, ignore = []) {
- let results = [];
- try {
- const files = fs.readdirSync(dir);
-
- for (const file of files) {
- const filepath = path.join(dir, file);
- const stat = fs.statSync(filepath);
- const relPath = path.relative(process.cwd(), filepath);
-
- if (ignore.some(ign => relPath.includes(ign))) continue;
-
- if (stat.isDirectory()) {
- results = results.concat(findFiles(filepath, pattern, ignore));
- } else if (pattern.test(file)) {
- results.push(filepath);
- }
- }
- } catch (e) {}
- return results;
-}
-
-function extractFunctions(content) {
- const functions = [];
- const lines = content.split("\n");
-
- lines.forEach((line, index) => {
- const lineNum = index + 1;
-
- // export function/const
- const namedMatch = line.match(/export\s+(?:function|const|async\s+function|async\s+const)\s+(\w+)/);
- if (namedMatch) {
- functions.push({
- name: namedMatch[1],
- line: lineNum,
- type: "export",
- });
- }
- });
-
- return functions;
-}
-
-function extractTestCases(content) {
- const testNames = [];
- const lines = content.split("\n");
-
- lines.forEach((line) => {
- // Match describe, it, test
- const testMatch = line.match(/(?:describe|it|test)\s*\(\s*['"`]([^'"`]+)['"`]/);
- if (testMatch) {
- testNames.push(testMatch[1]);
- }
- });
-
- return testNames;
-}
-
-function generateReport() {
- const ignore = ["node_modules", ".next", "build", "dist", ".git"];
-
- // Find all source and test files
- const srcFiles = findFiles("src", /\.(ts|tsx)$/, ignore)
- .concat(findFiles("packages", /\.(ts|tsx)$/, ignore))
- .concat(findFiles("dbal/development", /\.(ts|tsx)$/, ignore))
- .filter(f => !f.includes(".test.") && !f.includes(".spec."));
-
- const testFiles = findFiles(".", /\.(test|spec)\.(ts|tsx)$/, ignore);
-
- // Extract functions and tests
- const functionsByFile = new Map();
- const testsByFile = new Map();
-
- srcFiles.forEach(file => {
- try {
- const content = fs.readFileSync(file, "utf-8");
- const funcs = extractFunctions(content);
- if (funcs.length > 0) {
- functionsByFile.set(file, funcs);
- }
- } catch (e) {}
- });
-
- testFiles.forEach(file => {
- try {
- const content = fs.readFileSync(file, "utf-8");
- const tests = extractTestCases(content);
- if (tests.length > 0) {
- testsByFile.set(file, tests);
- }
- } catch (e) {}
- });
-
- // Generate markdown report
- let report = `# Function-to-Test Coverage Report\n\n`;
- report += `Generated: ${new Date().toISOString()}\n\n`;
-
- const totalFunctions = Array.from(functionsByFile.values()).reduce((sum, funcs) => sum + funcs.length, 0);
- const totalTests = Array.from(testsByFile.values()).reduce((sum, tests) => sum + tests.length, 0);
-
- report += `## Summary\n\n`;
- report += `- **Total Functions**: ${totalFunctions}\n`;
- report += `- **Total Test Cases**: ${totalTests}\n`;
- report += `- **Source Files with Functions**: ${functionsByFile.size}\n`;
- report += `- **Test Files**: ${testFiles.length}\n\n`;
-
- report += `## Files with Function Coverage\n\n`;
-
- const sortedFiles = Array.from(functionsByFile.entries()).sort((a, b) => {
- const aPath = a[0];
- const bPath = b[0];
- return aPath.localeCompare(bPath);
- });
-
- sortedFiles.forEach(([file, funcs]) => {
- const relFile = path.relative(process.cwd(), file);
- const testFile = file.replace(/\.tsx?$/, ".test.ts").replace(/\.tsx?$/, ".test.tsx");
- const hasTest = testFiles.includes(testFile) || testsByFile.has(testFile);
- const status = hasTest ? "✅" : "❌";
-
- report += `### ${status} ${relFile}\n\n`;
- report += `**Functions**: ${funcs.length}\n\n`;
-
- funcs.forEach(func => {
- report += `- \`${func.name}\` (line ${func.line})\n`;
- });
-
- if (hasTest) {
- const tests = testsByFile.get(testFile) || [];
- report += `\n**Test Cases**: ${tests.length}\n\n`;
- }
-
- report += `\n`;
- });
-
- report += `## Test Files\n\n`;
-
- testFiles.forEach(file => {
- const relFile = path.relative(process.cwd(), file);
- const tests = testsByFile.get(file) || [];
-
- report += `### ${relFile}\n\n`;
- report += `**Test Cases**: ${tests.length}\n\n`;
-
- tests.forEach(test => {
- report += `- ${test}\n`;
- });
-
- report += `\n`;
- });
-
- report += `## Recommendations\n\n`;
-
- const untested = Array.from(functionsByFile.entries()).filter(
- ([file]) => !testFiles.some(t => t.endsWith(file.replace(/\.(ts|tsx)$/, ".test.ts")))
- );
-
- if (untested.length > 0) {
- report += `### Files Needing Test Coverage\n\n`;
- untested.forEach(([file, funcs]) => {
- const relFile = path.relative(process.cwd(), file);
- report += `- **${relFile}**: ${funcs.length} functions need tests\n`;
- });
- report += `\n`;
- }
-
- report += `### Best Practices\n\n`;
- report += `1. **Parameterized Tests**: Use \`it.each()\` for testing multiple similar scenarios\n`;
- report += `2. **Test Organization**: Group related tests in \`describe()\` blocks\n`;
- report += `3. **Clear Descriptions**: Use descriptive test names that explain what is being tested\n`;
- report += `4. **Edge Cases**: Include tests for null, undefined, empty values, and boundary conditions\n`;
- report += `5. **Mocking**: Use \`vi.fn()\` and \`vi.mock()\` for external dependencies\n`;
- report += `6. **Async Testing**: Use \`async/await\` and \`act()\` for async operations\n`;
- report += `7. **Setup/Teardown**: Use \`beforeEach\` and \`afterEach\` for test setup and cleanup\n\n`;
-
- return report;
-}
-
-// Write report
-const report = generateReport();
-fs.writeFileSync("FUNCTION_TEST_COVERAGE.md", report);
-
-console.log("✅ Coverage report generated: FUNCTION_TEST_COVERAGE.md");
-console.log("\nTo run all tests:");
-console.log(" npm test -- --run\n");
-console.log("To run tests in watch mode:");
-console.log(" npm test\n");
diff --git a/tools/generation/generate-test-coverage-report.ts b/tools/generation/generate-test-coverage-report.ts
new file mode 100644
index 000000000..6ae348a1c
--- /dev/null
+++ b/tools/generation/generate-test-coverage-report.ts
@@ -0,0 +1,122 @@
+#!/usr/bin/env tsx
+
+import fs from 'fs'
+import path from 'path'
+
+import {
+ DEFAULT_IGNORE_PATHS,
+ SOURCE_DIRECTORIES,
+ type CoverageData,
+ type FunctionInfo,
+} from './report/data'
+import { buildReport } from './report/templates'
+
+const SRC_PATTERN = /\.(ts|tsx)$/
+const TEST_PATTERN = /(test|spec)\.(ts|tsx)$/
+
+const findFiles = (dir: string, pattern: RegExp, ignore: string[] = []) => {
+ let results: string[] = []
+
+ try {
+ const files = fs.readdirSync(dir)
+
+ for (const file of files) {
+ const filepath = path.join(dir, file)
+ const stat = fs.statSync(filepath)
+ const relPath = path.relative(process.cwd(), filepath)
+
+ if (ignore.some(ignored => relPath.includes(ignored))) continue
+
+ if (stat.isDirectory()) {
+ results = results.concat(findFiles(filepath, pattern, ignore))
+ } else if (pattern.test(file)) {
+ results.push(filepath)
+ }
+ }
+ } catch (error) {
+ console.warn(`Skipping unreadable path ${dir}:`, error)
+ }
+
+ return results
+}
+
+const extractFunctions = (content: string): FunctionInfo[] => {
+ const functions: FunctionInfo[] = []
+
+ content.split('\n').forEach((line, index) => {
+ const lineNum = index + 1
+ const namedMatch = line.match(/export\s+(?:function|const|async\s+function|async\s+const)\s+(\w+)/)
+
+ if (namedMatch) {
+ functions.push({
+ name: namedMatch[1],
+ line: lineNum,
+ type: 'export',
+ })
+ }
+ })
+
+ return functions
+}
+
+const extractTestCases = (content: string) => {
+ const testNames: string[] = []
+
+ content.split('\n').forEach(line => {
+ const testMatch = line.match(/(?:describe|it|test)\s*\(\s*['"`]([^'"`]+)['"`]/)
+ if (testMatch) {
+ testNames.push(testMatch[1])
+ }
+ })
+
+ return testNames
+}
+
+const collectCoverageData = (): CoverageData => {
+ const sourceFiles = SOURCE_DIRECTORIES.flatMap(dir => findFiles(dir, SRC_PATTERN, DEFAULT_IGNORE_PATHS)).filter(
+ file => !file.includes('.test.') && !file.includes('.spec.')
+ )
+ const testFiles = findFiles('.', TEST_PATTERN, DEFAULT_IGNORE_PATHS)
+
+ const functionsByFile = new Map()
+ const testsByFile = new Map()
+
+ sourceFiles.forEach(file => {
+ try {
+ const content = fs.readFileSync(file, 'utf-8')
+ const functions = extractFunctions(content)
+ if (functions.length) {
+ functionsByFile.set(file, functions)
+ }
+ } catch (error) {
+ console.warn(`Unable to read source file ${file}:`, error)
+ }
+ })
+
+ testFiles.forEach(file => {
+ try {
+ const content = fs.readFileSync(file, 'utf-8')
+ const tests = extractTestCases(content)
+ if (tests.length) {
+ testsByFile.set(file, tests)
+ }
+ } catch (error) {
+ console.warn(`Unable to read test file ${file}:`, error)
+ }
+ })
+
+ return { functionsByFile, testsByFile, testFiles }
+}
+
+const main = () => {
+ const report = buildReport(collectCoverageData())
+ fs.writeFileSync('FUNCTION_TEST_COVERAGE.md', report)
+
+ console.log('✅ Coverage report generated: FUNCTION_TEST_COVERAGE.md')
+ console.log('\nTo run all tests:')
+ console.log(' npm test -- --run\n')
+ console.log('To run tests in watch mode:')
+ console.log(' npm test\n')
+}
+
+main()
diff --git a/tools/generation/report/data.ts b/tools/generation/report/data.ts
new file mode 100644
index 000000000..c330e7220
--- /dev/null
+++ b/tools/generation/report/data.ts
@@ -0,0 +1,27 @@
+export type FunctionInfo = {
+ name: string
+ line: number
+ type: 'export'
+}
+
+export type CoverageData = {
+ functionsByFile: Map
+ testsByFile: Map
+ testFiles: string[]
+}
+
+export const DEFAULT_IGNORE_PATHS = ['node_modules', '.next', 'build', 'dist', '.git']
+
+export const SOURCE_DIRECTORIES = ['src', 'packages', 'dbal/development']
+
+export const TEST_FILE_SUFFIXES = ['.test.ts', '.test.tsx', '.spec.ts', '.spec.tsx']
+
+export const BEST_PRACTICES = [
+ '**Parameterized Tests**: Use `it.each()` for testing multiple similar scenarios',
+ '**Test Organization**: Group related tests in `describe()` blocks',
+ '**Clear Descriptions**: Use descriptive test names that explain what is being tested',
+ '**Edge Cases**: Include tests for null, undefined, empty values, and boundary conditions',
+ '**Mocking**: Use `vi.fn()` and `vi.mock()` for external dependencies',
+ '**Async Testing**: Use `async/await` and `act()` for async operations',
+ '**Setup/Teardown**: Use `beforeEach` and `afterEach` for test setup and cleanup',
+]
diff --git a/tools/generation/report/templates.ts b/tools/generation/report/templates.ts
new file mode 100644
index 000000000..b63e9fd04
--- /dev/null
+++ b/tools/generation/report/templates.ts
@@ -0,0 +1,110 @@
+import path from 'path'
+
+import { BEST_PRACTICES, TEST_FILE_SUFFIXES, type CoverageData } from './data'
+
+const findMatchingTestFile = (
+ sourceFile: string,
+ testFiles: string[],
+ testsByFile: Map
+) => {
+ const base = sourceFile.replace(/\.(ts|tsx)$/, '')
+
+ return TEST_FILE_SUFFIXES
+ .map(suffix => `${base}${suffix}`)
+ .find(candidate => testFiles.includes(candidate) || testsByFile.has(candidate))
+}
+
+const buildSummary = ({ functionsByFile, testsByFile, testFiles }: CoverageData) => {
+ const totalFunctions = Array.from(functionsByFile.values()).reduce(
+ (sum, funcs) => sum + funcs.length,
+ 0
+ )
+ const totalTests = Array.from(testsByFile.values()).reduce((sum, tests) => sum + tests.length, 0)
+
+ return [
+ '## Summary\n',
+ `- **Total Functions**: ${totalFunctions}`,
+ `- **Total Test Cases**: ${totalTests}`,
+ `- **Source Files with Functions**: ${functionsByFile.size}`,
+ `- **Test Files**: ${testFiles.length}\n`,
+ ].join('\n')
+}
+
+const buildFileCoverageSection = ({ functionsByFile, testsByFile, testFiles }: CoverageData) => {
+ const sortedFiles = Array.from(functionsByFile.entries()).sort(([a], [b]) => a.localeCompare(b))
+
+ const sections = sortedFiles.map(([file, funcs]) => {
+ const relFile = path.relative(process.cwd(), file)
+ const matchingTest = findMatchingTestFile(file, testFiles, testsByFile)
+ const status = matchingTest ? '✅' : '❌'
+
+ const functionDetails = funcs
+ .map(func => `- \`${func.name}\` (line ${func.line})`)
+ .join('\n')
+
+ const testDetails = matchingTest
+ ? [`\n**Test Cases**: ${(testsByFile.get(matchingTest) || []).length}\n`].join('')
+ : ''
+
+ return [
+ `### ${status} ${relFile}\n`,
+ `**Functions**: ${funcs.length}\n`,
+ functionDetails,
+ '\n',
+ testDetails,
+ ].join('\n')
+ })
+
+ return ['## Files with Function Coverage\n', ...sections, ''].join('\n')
+}
+
+const buildTestFilesSection = ({ testFiles, testsByFile }: CoverageData) => {
+ const sections = testFiles.map(file => {
+ const relFile = path.relative(process.cwd(), file)
+ const tests = testsByFile.get(file) || []
+
+ const cases = tests.map(test => `- ${test}`).join('\n')
+
+ return [`### ${relFile}\n`, `**Test Cases**: ${tests.length}\n`, cases, '\n'].join('\n')
+ })
+
+ return ['## Test Files\n', ...sections, ''].join('\n')
+}
+
+const buildRecommendations = ({ functionsByFile, testsByFile, testFiles }: CoverageData) => {
+ const untested = Array.from(functionsByFile.entries()).filter(
+ ([file]) => !findMatchingTestFile(file, testFiles, testsByFile)
+ )
+
+ const untestedLines = untested.length
+ ? [
+ '### Files Needing Test Coverage\n',
+ ...untested.map(([file, funcs]) => {
+ const relFile = path.relative(process.cwd(), file)
+ return `- **${relFile}**: ${funcs.length} functions need tests`
+ }),
+ '\n',
+ ]
+ : []
+
+ const bestPractices = BEST_PRACTICES.map((practice, index) => `${index + 1}. ${practice}`).join('\n')
+
+ return [
+ '## Recommendations\n',
+ ...untestedLines,
+ '### Best Practices\n',
+ `${bestPractices}\n`,
+ ].join('\n')
+}
+
+export const buildReport = (data: CoverageData) => {
+ return [
+ '# Function-to-Test Coverage Report\n',
+ `Generated: ${new Date().toISOString()}\n`,
+ buildSummary(data),
+ '',
+ buildFileCoverageSection(data),
+ buildTestFilesSection(data),
+ buildRecommendations(data),
+ ].join('\n')
+}