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/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/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/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) => (
-
- ))}
-
-
- {runtimeDescription}
-
-
-
-
- : null}
- >
- {status === 'loading' ? 'Generating...' : 'Generate ZIP'}
-
-
-
- {message && {message}}
- {error && {error}}
- {manifest && (
-
-
- Manifest preview
+
+
+
+
+
+ {runtimeOptions.map((option) => (
+
+ ))}
+
+
+ {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/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.
+
+
+ )}
+
+
+
-
-
-
-
-
-
+
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
-
-
-
-
-
- setLocalRadius(e.target.value)}
- placeholder="e.g., 0.5rem"
- className="mt-1.5"
- />
-
-
- {colorGroups.map((group) => (
-
-
{group.title}
-
- {group.colors.map(({ key, label }) => (
-
-
-
-
-
handleColorChange(key, e.target.value)}
- placeholder="oklch(...)"
- className="font-mono text-sm"
- />
-
-
- ))}
-
-
- ))}
+
+
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
-
- )}
-
-
-
- ))
- )}
-
-
- ))}
+
-
+ {
+ 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 (
+
+ )
+}
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/BlockItem.tsx b/frontends/nextjs/src/components/editors/lua/blocks/BlockItem.tsx
new file mode 100644
index 000000000..938a26451
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/blocks/BlockItem.tsx
@@ -0,0 +1,218 @@
+import type { MouseEvent } from 'react'
+import {
+ Box,
+ Button,
+ IconButton,
+ MenuItem,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import {
+ Add as AddIcon,
+ ArrowDownward,
+ ArrowUpward,
+ ContentCopy,
+ Delete as DeleteIcon,
+} from '@mui/icons-material'
+import type { BlockDefinition, BlockSlot, LuaBlock } from '../types'
+import styles from '../LuaBlocksEditor.module.scss'
+
+interface BlockItemProps {
+ block: LuaBlock
+ definition: BlockDefinition
+ index: number
+ total: number
+ onRequestAddBlock: (
+ event: MouseEvent,
+ target: { parentId: string | null; slot: BlockSlot }
+ ) => void
+ onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
+ onDuplicateBlock: (blockId: string) => void
+ onRemoveBlock: (blockId: string) => void
+ onUpdateField: (blockId: string, fieldName: string, value: string) => void
+ renderNestedList: (blocks?: LuaBlock[]) => JSX.Element
+}
+
+interface BlockSectionProps {
+ title: string
+ blocks: LuaBlock[] | undefined
+ parentId: string
+ slot: BlockSlot
+ onRequestAddBlock: (
+ event: MouseEvent,
+ target: { parentId: string | null; slot: BlockSlot }
+ ) => void
+ renderNestedList: (blocks?: LuaBlock[]) => JSX.Element
+}
+
+const BlockSection = ({
+ title,
+ blocks,
+ parentId,
+ slot,
+ onRequestAddBlock,
+ renderNestedList,
+}: BlockSectionProps) => (
+
+
+ {title}
+ onRequestAddBlock(event, { parentId, slot })}
+ startIcon={}
+ >
+ Add block
+
+
+
+ {blocks && blocks.length > 0 ? (
+ renderNestedList(blocks)
+ ) : (
+ Drop blocks here to build this section.
+ )}
+
+
+)
+
+const BlockFields = ({
+ block,
+ definition,
+ onUpdateField,
+}: {
+ block: LuaBlock
+ definition: BlockDefinition
+ onUpdateField: (blockId: string, fieldName: string, value: string) => void
+}) => {
+ if (definition.fields.length === 0) return null
+
+ return (
+
+ {definition.fields.map((field) => (
+
+ {field.label}
+ {field.type === 'select' ? (
+ onUpdateField(block.id, field.name, event.target.value)}
+ fullWidth
+ variant="outlined"
+ InputProps={{
+ sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
+ }}
+ >
+ {field.options?.map((option) => (
+
+ ))}
+
+ ) : (
+ onUpdateField(block.id, field.name, event.target.value)}
+ placeholder={field.placeholder}
+ fullWidth
+ variant="outlined"
+ type={field.type === 'number' ? 'number' : 'text'}
+ InputProps={{
+ sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
+ }}
+ />
+ )}
+
+ ))}
+
+ )
+}
+
+export const BlockItem = ({
+ block,
+ definition,
+ index,
+ total,
+ onRequestAddBlock,
+ onMoveBlock,
+ onDuplicateBlock,
+ onRemoveBlock,
+ onUpdateField,
+ renderNestedList,
+}: BlockItemProps) => (
+
+
+ {definition.label}
+
+
+
+ onMoveBlock(block.id, 'up')}
+ disabled={index === 0}
+ sx={{ color: 'rgba(255,255,255,0.85)' }}
+ >
+
+
+
+
+
+
+ onMoveBlock(block.id, 'down')}
+ disabled={index === total - 1}
+ sx={{ color: 'rgba(255,255,255,0.85)' }}
+ >
+
+
+
+
+
+ onDuplicateBlock(block.id)}
+ sx={{ color: 'rgba(255,255,255,0.85)' }}
+ >
+
+
+
+
+ onRemoveBlock(block.id)}
+ sx={{ color: 'rgba(255,255,255,0.85)' }}
+ >
+
+
+
+
+
+
+
+
+ {definition.hasChildren && (
+
+ )}
+
+ {definition.hasElseChildren && (
+
+ )}
+
+)
diff --git a/frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx b/frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
index afe08e023..1c4c3052b 100644
--- a/frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
+++ b/frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
@@ -1,22 +1,8 @@
import type { MouseEvent } from 'react'
-import {
- Box,
- Button,
- IconButton,
- MenuItem,
- TextField,
- Tooltip,
- Typography,
-} from '@mui/material'
-import {
- Add as AddIcon,
- ArrowDownward,
- ArrowUpward,
- ContentCopy,
- Delete as DeleteIcon,
-} from '@mui/icons-material'
+import { Box } from '@mui/material'
import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from '../types'
import styles from '../LuaBlocksEditor.module.scss'
+import { BlockItem } from './BlockItem'
interface BlockListProps {
blocks: LuaBlock[]
@@ -31,89 +17,6 @@ interface BlockListProps {
onUpdateField: (blockId: string, fieldName: string, value: string) => void
}
-const renderBlockFields = (
- block: LuaBlock,
- definition: BlockDefinition,
- onUpdateField: (blockId: string, fieldName: string, value: string) => void
-) => {
- if (definition.fields.length === 0) return null
-
- return (
-
- {definition.fields.map((field) => (
-
- {field.label}
- {field.type === 'select' ? (
- onUpdateField(block.id, field.name, event.target.value)}
- fullWidth
- variant="outlined"
- InputProps={{
- sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
- }}
- >
- {field.options?.map((option) => (
-
- ))}
-
- ) : (
- onUpdateField(block.id, field.name, event.target.value)}
- placeholder={field.placeholder}
- fullWidth
- variant="outlined"
- type={field.type === 'number' ? 'number' : 'text'}
- InputProps={{
- sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
- }}
- />
- )}
-
- ))}
-
- )
-}
-
-const renderBlockSection = (
- title: string,
- blocks: LuaBlock[] | undefined,
- parentId: string | null,
- slot: BlockSlot,
- onRequestAddBlock: (
- event: MouseEvent,
- target: { parentId: string | null; slot: BlockSlot }
- ) => void,
- renderBlockCard: (block: LuaBlock, index: number, total: number) => JSX.Element | null
-) => (
-
-
- {title}
- onRequestAddBlock(event, { parentId, slot })}
- startIcon={}
- >
- Add block
-
-
-
- {blocks && blocks.length > 0 ? (
- blocks.map((child, index) => renderBlockCard(child, index, blocks.length))
- ) : (
- Drop blocks here to build this section.
- )}
-
-
-)
-
export const BlockList = ({
blocks,
blockDefinitionMap,
@@ -123,78 +26,40 @@ export const BlockList = ({
onRemoveBlock,
onUpdateField,
}: BlockListProps) => {
- const renderBlockCard = (block: LuaBlock, index: number, total: number) => {
- const definition = blockDefinitionMap.get(block.type)
- if (!definition) return null
-
- return (
-
-
- {definition.label}
-
-
-
- onMoveBlock(block.id, 'up')}
- disabled={index === 0}
- sx={{ color: 'rgba(255,255,255,0.85)' }}
- >
-
-
-
-
-
-
- onMoveBlock(block.id, 'down')}
- disabled={index === total - 1}
- sx={{ color: 'rgba(255,255,255,0.85)' }}
- >
-
-
-
-
-
- onDuplicateBlock(block.id)}
- sx={{ color: 'rgba(255,255,255,0.85)' }}
- >
-
-
-
-
- onRemoveBlock(block.id)}
- sx={{ color: 'rgba(255,255,255,0.85)' }}
- >
-
-
-
-
-
- {renderBlockFields(block, definition, onUpdateField)}
- {definition.hasChildren &&
- renderBlockSection('Then', block.children, block.id, 'children', onRequestAddBlock, renderBlockCard)}
- {definition.hasElseChildren &&
- renderBlockSection(
- 'Else',
- block.elseChildren,
- block.id,
- 'elseChildren',
- onRequestAddBlock,
- renderBlockCard
- )}
-
- )
- }
+ const renderNestedList = (childBlocks?: LuaBlock[]) => (
+
+ )
return (
- {blocks.map((block, index) => renderBlockCard(block, index, blocks.length))}
+ {blocks.map((block, index) => {
+ const definition = blockDefinitionMap.get(block.type)
+ if (!definition) return null
+
+ return (
+
+ )
+ })}
)
}
diff --git a/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts b/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts
new file mode 100644
index 000000000..786b6f586
--- /dev/null
+++ b/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts
@@ -0,0 +1,20 @@
+import type { BlockCategory, BlockDefinition } from '../types'
+
+const createCategoryIndex = (): Record => ({
+ Basics: [],
+ Logic: [],
+ Loops: [],
+ Data: [],
+ Functions: [],
+})
+
+export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
+ const categories = createCategoryIndex()
+ definitions.forEach((definition) => {
+ categories[definition.category].push(definition)
+ })
+ return categories
+}
+
+export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) =>
+ new Map(definitions.map((definition) => [definition.type, definition]))
diff --git a/frontends/nextjs/src/components/editors/lua/blocks/index.ts b/frontends/nextjs/src/components/editors/lua/blocks/index.ts
index 33cf9167d..b9706fcc0 100644
--- a/frontends/nextjs/src/components/editors/lua/blocks/index.ts
+++ b/frontends/nextjs/src/components/editors/lua/blocks/index.ts
@@ -1,4 +1,4 @@
-import type { BlockCategory, BlockDefinition } from '../types'
+import type { BlockDefinition } from '../types'
import { basicBlocks } from './basics'
import { dataBlocks } from './data'
import { functionBlocks } from './functions'
@@ -13,21 +13,4 @@ export const BLOCK_DEFINITIONS: BlockDefinition[] = [
...functionBlocks,
]
-const createCategoryIndex = (): Record => ({
- Basics: [],
- Logic: [],
- Loops: [],
- Data: [],
- Functions: [],
-})
-
-export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
- const categories = createCategoryIndex()
- definitions.forEach((definition) => {
- categories[definition.category].push(definition)
- })
- return categories
-}
-
-export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) =>
- new Map(definitions.map((definition) => [definition.type, definition]))
+export { buildBlockDefinitionMap, groupBlockDefinitionsByCategory } from './grouping'
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
-
-
-
-
-
-
-
-
-
- Add Field
-
-
-
-
- {currentModel.fields.length === 0 ? (
-
- No fields yet. Add a field to start.
-
- ) : (
- currentModel.fields.map((field) => (
-
-
-
-
-
-
-
- handleUpdateField(field.name, { name: e.target.value })
- }
- placeholder="email"
- />
-
-
-
-
- handleUpdateField(field.name, { label: e.target.value })
- }
- placeholder="Email Address"
- />
-
-
-
-
-
-
-
-
- handleUpdateField(field.name, { default: e.target.value })
- }
- placeholder="Default"
- />
-
-
-
handleDeleteField(field.name)}
- >
-
-
-
-
-
-
-
- handleUpdateField(field.name, { required: checked })
- }
- />
-
-
-
-
- handleUpdateField(field.name, { unique: checked })
- }
- />
-
-
-
-
- handleUpdateField(field.name, { editable: checked })
- }
- />
-
-
-
-
- handleUpdateField(field.name, { searchable: checked })
- }
- />
-
-
-
-
-
- ))
- )}
-
-
-
- >
+
)}
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 (
+
+
+
+
+ onRadiusChange(e.target.value)}
+ placeholder="e.g., 0.5rem"
+ className="mt-1.5"
+ />
+
+
+
+ {colorGroups.map((group) => (
+
+
{group.title}
+
+ {group.colors.map(({ key, 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/level/levels/Level4.tsx b/frontends/nextjs/src/components/level/levels/Level4.tsx
index be74a350a..ee10645e9 100644
--- a/frontends/nextjs/src/components/level/levels/Level4.tsx
+++ b/frontends/nextjs/src/components/level/levels/Level4.tsx
@@ -55,6 +55,7 @@ export function Level4({ user, onLogout, onNavigate, onPreview }: Level4Props) {
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/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() {
)}
-
+
)
}
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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setFormData({ ...formData, requiresAuth: checked })}
- />
-
-
+
-
-
- 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/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 (
+
+ )
+}
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/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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onChange({ ...formData, requiresAuth: checked })}
+ />
+
+
+
+
+ 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/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"
+ />
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+ 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 (
+
+
+ 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 (
+
+
+
+
+ )
+}
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,
+ }
+}