diff --git a/dbal/development/src/adapters/acl-adapter.ts b/dbal/development/src/adapters/acl-adapter.ts index a5bfa02e5..9fbcd7d83 100644 --- a/dbal/development/src/adapters/acl-adapter.ts +++ b/dbal/development/src/adapters/acl-adapter.ts @@ -1,3 +1,3 @@ -export { ACLAdapter } from './acl-adapter/index' -export type { User, ACLRule } from './acl/types' +export { ACLAdapter } from './acl-adapter' +export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './acl-adapter/types' export { defaultACLRules } from './acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/acl-adapter.ts b/dbal/development/src/adapters/acl-adapter/acl-adapter.ts new file mode 100644 index 000000000..9d2492451 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/acl-adapter.ts @@ -0,0 +1,86 @@ +import type { AdapterCapabilities, DBALAdapter } from '../adapter' +import type { ListOptions, ListResult } from '../../core/foundation/types' +import { createContext } from './context' +import { createReadStrategy } from './read-strategy' +import { createWriteStrategy } from './write-strategy' +import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' + +export class ACLAdapter implements DBALAdapter { + private readonly context: ACLContext + private readonly readStrategy: ReturnType + private readonly writeStrategy: ReturnType + + constructor(baseAdapter: DBALAdapter, user: User, options?: ACLAdapterOptions) { + this.context = createContext(baseAdapter, user, options) + this.readStrategy = createReadStrategy(this.context) + this.writeStrategy = createWriteStrategy(this.context) + } + + async create(entity: string, data: Record): Promise { + return this.writeStrategy.create(entity, data) + } + + async read(entity: string, id: string): Promise { + return this.readStrategy.read(entity, id) + } + + async update(entity: string, id: string, data: Record): Promise { + return this.writeStrategy.update(entity, id, data) + } + + async delete(entity: string, id: string): Promise { + return this.writeStrategy.delete(entity, id) + } + + async list(entity: string, options?: ListOptions): Promise> { + return this.readStrategy.list(entity, options) + } + + async findFirst(entity: string, filter?: Record): Promise { + return this.readStrategy.findFirst(entity, filter) + } + + async findByField(entity: string, field: string, value: unknown): Promise { + return this.readStrategy.findByField(entity, field, value) + } + + async upsert( + entity: string, + filter: Record, + createData: Record, + updateData: Record, + ): Promise { + return this.writeStrategy.upsert(entity, filter, createData, updateData) + } + + async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { + return this.writeStrategy.updateByField(entity, field, value, data) + } + + async deleteByField(entity: string, field: string, value: unknown): Promise { + return this.writeStrategy.deleteByField(entity, field, value) + } + + async createMany(entity: string, data: Record[]): Promise { + return this.writeStrategy.createMany(entity, data) + } + + async updateMany(entity: string, filter: Record, data: Record): Promise { + return this.writeStrategy.updateMany(entity, filter, data) + } + + async deleteMany(entity: string, filter?: Record): Promise { + return this.writeStrategy.deleteMany(entity, filter) + } + + async getCapabilities(): Promise { + return this.context.baseAdapter.getCapabilities() + } + + async close(): Promise { + await this.context.baseAdapter.close() + } +} + +export type { ACLAdapterOptions, ACLContext, ACLRule, User } +export { defaultACLRules } from '../acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/context.ts b/dbal/development/src/adapters/acl-adapter/context.ts index 9262dd64d..8213926b9 100644 --- a/dbal/development/src/adapters/acl-adapter/context.ts +++ b/dbal/development/src/adapters/acl-adapter/context.ts @@ -1,20 +1,12 @@ import type { DBALAdapter } from '../adapter' -import type { User, ACLRule } from '../acl/types' +import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' import { logAudit } from '../acl/audit-logger' import { defaultACLRules } from '../acl/default-rules' -export interface ACLContext { - baseAdapter: DBALAdapter - user: User - rules: ACLRule[] - auditLog: boolean - logger: (entity: string, operation: string, success: boolean, message?: string) => void -} - export const createContext = ( baseAdapter: DBALAdapter, user: User, - options?: { rules?: ACLRule[]; auditLog?: boolean }, + options?: ACLAdapterOptions, ): ACLContext => { const auditLog = options?.auditLog ?? true const rules = options?.rules || defaultACLRules diff --git a/dbal/development/src/adapters/acl-adapter/guards.ts b/dbal/development/src/adapters/acl-adapter/guards.ts index 8da05a011..be5171354 100644 --- a/dbal/development/src/adapters/acl-adapter/guards.ts +++ b/dbal/development/src/adapters/acl-adapter/guards.ts @@ -1,7 +1,7 @@ import { checkPermission } from '../acl/check-permission' import { checkRowLevelAccess } from '../acl/check-row-level-access' import { resolvePermissionOperation } from '../acl/resolve-permission-operation' -import type { ACLContext } from './context' +import type { ACLContext } from './types' export const enforcePermission = (context: ACLContext, entity: string, operation: string) => { checkPermission(entity, operation, context.user, context.rules, context.logger) diff --git a/dbal/development/src/adapters/acl-adapter/index.ts b/dbal/development/src/adapters/acl-adapter/index.ts index 354fe2a58..b356927a7 100644 --- a/dbal/development/src/adapters/acl-adapter/index.ts +++ b/dbal/development/src/adapters/acl-adapter/index.ts @@ -1,92 +1,3 @@ -import type { AdapterCapabilities, DBALAdapter } from '../adapter' -import type { ListOptions, ListResult } from '../../core/foundation/types' -import type { User, ACLRule } from '../acl/types' -import type { ACLContext } from './context' -import { createContext } from './context' -import { createEntity, deleteEntity, listEntities, readEntity, updateEntity } from './crud' -import { - createMany, - deleteByField, - deleteMany, - findByField, - findFirst, - updateByField, - updateMany, - upsert, -} from './bulk' - -export class ACLAdapter implements DBALAdapter { - private readonly context: ACLContext - - constructor(baseAdapter: DBALAdapter, user: User, options?: { rules?: ACLRule[]; auditLog?: boolean }) { - this.context = createContext(baseAdapter, user, options) - } - - async create(entity: string, data: Record): Promise { - return createEntity(this.context)(entity, data) - } - - async read(entity: string, id: string): Promise { - return readEntity(this.context)(entity, id) - } - - async update(entity: string, id: string, data: Record): Promise { - return updateEntity(this.context)(entity, id, data) - } - - async delete(entity: string, id: string): Promise { - return deleteEntity(this.context)(entity, id) - } - - async list(entity: string, options?: ListOptions): Promise> { - return listEntities(this.context)(entity, options) - } - - async findFirst(entity: string, filter?: Record): Promise { - return findFirst(this.context)(entity, filter) - } - - async findByField(entity: string, field: string, value: unknown): Promise { - return findByField(this.context)(entity, field, value) - } - - async upsert( - entity: string, - filter: Record, - createData: Record, - updateData: Record, - ): Promise { - return upsert(this.context)(entity, filter, createData, updateData) - } - - async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { - return updateByField(this.context)(entity, field, value, data) - } - - async deleteByField(entity: string, field: string, value: unknown): Promise { - return deleteByField(this.context)(entity, field, value) - } - - async createMany(entity: string, data: Record[]): Promise { - return createMany(this.context)(entity, data) - } - - async updateMany(entity: string, filter: Record, data: Record): Promise { - return updateMany(this.context)(entity, filter, data) - } - - async deleteMany(entity: string, filter?: Record): Promise { - return deleteMany(this.context)(entity, filter) - } - - async getCapabilities(): Promise { - return this.context.baseAdapter.getCapabilities() - } - - async close(): Promise { - await this.context.baseAdapter.close() - } -} - -export type { User, ACLRule } from './acl/types' -export { defaultACLRules } from './acl/default-rules' +export { ACLAdapter } from './acl-adapter' +export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' +export { defaultACLRules } from '../acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/read-strategy.ts b/dbal/development/src/adapters/acl-adapter/read-strategy.ts new file mode 100644 index 000000000..da2742e26 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/read-strategy.ts @@ -0,0 +1,48 @@ +import type { ListOptions, ListResult } from '../../core/foundation/types' +import { enforceRowAccess, resolveOperation, withAudit } from './guards' +import type { ACLContext } from './types' + +export const createReadStrategy = (context: ACLContext) => { + const read = async (entity: string, id: string): Promise => { + return withAudit(context, entity, 'read', async () => { + const result = await context.baseAdapter.read(entity, id) + if (result) { + enforceRowAccess(context, entity, 'read', result as Record) + } + return result + }) + } + + const list = async (entity: string, options?: ListOptions): Promise> => { + return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options)) + } + + const findFirst = async (entity: string, filter?: Record): Promise => { + const operation = resolveOperation('findFirst') + return withAudit(context, entity, operation, async () => { + const result = await context.baseAdapter.findFirst(entity, filter) + if (result) { + enforceRowAccess(context, entity, operation, result as Record) + } + return result + }) + } + + const findByField = async (entity: string, field: string, value: unknown): Promise => { + const operation = resolveOperation('findByField') + return withAudit(context, entity, operation, async () => { + const result = await context.baseAdapter.findByField(entity, field, value) + if (result) { + enforceRowAccess(context, entity, operation, result as Record) + } + return result + }) + } + + return { + read, + list, + findFirst, + findByField, + } +} diff --git a/dbal/development/src/adapters/acl-adapter/types.ts b/dbal/development/src/adapters/acl-adapter/types.ts new file mode 100644 index 000000000..ea4cf1857 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/types.ts @@ -0,0 +1,27 @@ +import type { DBALAdapter } from '../adapter' + +export interface User { + id: string + username: string + role: 'user' | 'admin' | 'god' | 'supergod' +} + +export interface ACLRule { + entity: string + roles: string[] + operations: string[] + rowLevelFilter?: (user: User, data: Record) => boolean +} + +export interface ACLAdapterOptions { + rules?: ACLRule[] + auditLog?: boolean +} + +export interface ACLContext { + baseAdapter: DBALAdapter + user: User + rules: ACLRule[] + auditLog: boolean + logger: (entity: string, operation: string, success: boolean, message?: string) => void +} diff --git a/dbal/development/src/adapters/acl-adapter/write-strategy.ts b/dbal/development/src/adapters/acl-adapter/write-strategy.ts new file mode 100644 index 000000000..cf8b6aff5 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/write-strategy.ts @@ -0,0 +1,83 @@ +import { enforceRowAccess, resolveOperation, withAudit } from './guards' +import type { ACLContext } from './types' + +export const createWriteStrategy = (context: ACLContext) => { + const create = async (entity: string, data: Record): Promise => { + return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data)) + } + + const update = async (entity: string, id: string, data: Record): Promise => { + return withAudit(context, entity, 'update', async () => { + const existing = await context.baseAdapter.read(entity, id) + if (existing) { + enforceRowAccess(context, entity, 'update', existing as Record) + } + return context.baseAdapter.update(entity, id, data) + }) + } + + const remove = async (entity: string, id: string): Promise => { + return withAudit(context, entity, 'delete', async () => { + const existing = await context.baseAdapter.read(entity, id) + if (existing) { + enforceRowAccess(context, entity, 'delete', existing as Record) + } + return context.baseAdapter.delete(entity, id) + }) + } + + const upsert = async ( + entity: string, + filter: Record, + createData: Record, + updateData: Record, + ): Promise => { + return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData)) + } + + const updateByField = async ( + entity: string, + field: string, + value: unknown, + data: Record, + ): Promise => { + const operation = resolveOperation('updateByField') + return withAudit(context, entity, operation, () => context.baseAdapter.updateByField(entity, field, value, data)) + } + + const deleteByField = async (entity: string, field: string, value: unknown): Promise => { + const operation = resolveOperation('deleteByField') + return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value)) + } + + const createMany = async (entity: string, data: Record[]): Promise => { + const operation = resolveOperation('createMany') + return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data)) + } + + const updateMany = async ( + entity: string, + filter: Record, + data: Record, + ): Promise => { + const operation = resolveOperation('updateMany') + return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data)) + } + + const deleteMany = async (entity: string, filter?: Record): Promise => { + const operation = resolveOperation('deleteMany') + return withAudit(context, entity, operation, () => context.baseAdapter.deleteMany(entity, filter)) + } + + return { + create, + update, + delete: remove, + upsert, + updateByField, + deleteByField, + createMany, + updateMany, + deleteMany, + } +} diff --git a/dbal/development/src/adapters/acl/audit-logger.ts b/dbal/development/src/adapters/acl/audit-logger.ts index f67a2736d..864c181e4 100644 --- a/dbal/development/src/adapters/acl/audit-logger.ts +++ b/dbal/development/src/adapters/acl/audit-logger.ts @@ -3,7 +3,7 @@ * @description Audit logging for ACL operations */ -import type { User } from './types' +import type { User } from '../acl-adapter/types' /** * Log audit entry for ACL operation diff --git a/dbal/development/src/adapters/acl/check-permission.ts b/dbal/development/src/adapters/acl/check-permission.ts index 3f1fd4a1b..b27a7b12d 100644 --- a/dbal/development/src/adapters/acl/check-permission.ts +++ b/dbal/development/src/adapters/acl/check-permission.ts @@ -4,7 +4,7 @@ */ import { DBALError } from '../../core/foundation/errors' -import type { User, ACLRule } from './types' +import type { ACLRule, User } from '../acl-adapter/types' /** * Check if user has permission to perform operation on entity diff --git a/dbal/development/src/adapters/acl/check-row-level-access.ts b/dbal/development/src/adapters/acl/check-row-level-access.ts index 3b3403205..70ea72fc7 100644 --- a/dbal/development/src/adapters/acl/check-row-level-access.ts +++ b/dbal/development/src/adapters/acl/check-row-level-access.ts @@ -4,7 +4,7 @@ */ import { DBALError } from '../../core/foundation/errors' -import type { User, ACLRule } from './types' +import type { ACLRule, User } from '../acl-adapter/types' /** * Check row-level access for specific data diff --git a/dbal/development/src/adapters/acl/default-rules.ts b/dbal/development/src/adapters/acl/default-rules.ts index a5ff3f3b0..25a6b803e 100644 --- a/dbal/development/src/adapters/acl/default-rules.ts +++ b/dbal/development/src/adapters/acl/default-rules.ts @@ -3,7 +3,7 @@ * @description Default ACL rules for entities */ -import type { ACLRule } from './types' +import type { ACLRule } from '../acl-adapter/types' export const defaultACLRules: ACLRule[] = [ { diff --git a/dbal/development/src/blob/providers/memory-storage/downloads.ts b/dbal/development/src/blob/providers/memory-storage/downloads.ts index eb82fac6a..6a722029f 100644 --- a/dbal/development/src/blob/providers/memory-storage/downloads.ts +++ b/dbal/development/src/blob/providers/memory-storage/downloads.ts @@ -1,17 +1,15 @@ import { DBALError } from '../../core/foundation/errors' import type { DownloadOptions } from '../blob-storage' import type { MemoryStore } from './store' +import { getBlobOrThrow, normalizeKey } from './utils' export const downloadBuffer = ( store: MemoryStore, key: string, options: DownloadOptions = {}, ): Buffer => { - const blob = store.get(key) - - if (!blob) { - throw DBALError.notFound(`Blob not found: ${key}`) - } + const normalizedKey = normalizeKey(key) + const blob = getBlobOrThrow(store, normalizedKey) let data = blob.data diff --git a/dbal/development/src/blob/providers/memory-storage/index.ts b/dbal/development/src/blob/providers/memory-storage/index.ts index 769285344..b1ed68b18 100644 --- a/dbal/development/src/blob/providers/memory-storage/index.ts +++ b/dbal/development/src/blob/providers/memory-storage/index.ts @@ -10,6 +10,7 @@ import { createStore } from './store' import { uploadBuffer, uploadFromStream } from './uploads' import { downloadBuffer, downloadStream } from './downloads' import { copyBlob, deleteBlob, getMetadata, listBlobs, getObjectCount, getTotalSize } from './management' +import { normalizeKey } from './utils' export class MemoryStorage implements BlobStorage { private store = createStore() @@ -43,7 +44,7 @@ export class MemoryStorage implements BlobStorage { } async exists(key: string): Promise { - return this.store.has(key) + return this.store.has(normalizeKey(key)) } async getMetadata(key: string): Promise { diff --git a/dbal/development/src/blob/providers/memory-storage/management.ts b/dbal/development/src/blob/providers/memory-storage/management.ts index 8d2ad4f8e..afff2801e 100644 --- a/dbal/development/src/blob/providers/memory-storage/management.ts +++ b/dbal/development/src/blob/providers/memory-storage/management.ts @@ -1,29 +1,29 @@ import { DBALError } from '../../core/foundation/errors' import type { BlobListOptions, BlobListResult, BlobMetadata } from '../blob-storage' -import { makeBlobMetadata } from './store' import type { MemoryStore } from './store' +import { toBlobMetadata } from './serialization' +import { cleanupStoreEntry, getBlobOrThrow, normalizeKey } from './utils' export const deleteBlob = async (store: MemoryStore, key: string): Promise => { - if (!store.has(key)) { - throw DBALError.notFound(`Blob not found: ${key}`) + const normalizedKey = normalizeKey(key) + + if (!store.has(normalizedKey)) { + throw DBALError.notFound(`Blob not found: ${normalizedKey}`) } - store.delete(key) + cleanupStoreEntry(store, normalizedKey) return true } export const getMetadata = (store: MemoryStore, key: string): BlobMetadata => { - const blob = store.get(key) + const normalizedKey = normalizeKey(key) + const blob = getBlobOrThrow(store, normalizedKey) - if (!blob) { - throw DBALError.notFound(`Blob not found: ${key}`) - } - - return makeBlobMetadata(key, blob) + return toBlobMetadata(normalizedKey, blob) } export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): BlobListResult => { - const prefix = options.prefix || '' + const prefix = options.prefix ? normalizeKey(options.prefix) : '' const maxKeys = options.maxKeys || 1000 const items: BlobMetadata[] = [] @@ -35,7 +35,7 @@ export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): Bl nextToken = key break } - items.push(makeBlobMetadata(key, blob)) + items.push(toBlobMetadata(key, blob)) } } @@ -47,11 +47,9 @@ export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): Bl } export const copyBlob = (store: MemoryStore, sourceKey: string, destKey: string): BlobMetadata => { - const sourceBlob = store.get(sourceKey) - - if (!sourceBlob) { - throw DBALError.notFound(`Source blob not found: ${sourceKey}`) - } + const normalizedSourceKey = normalizeKey(sourceKey) + const normalizedDestKey = normalizeKey(destKey) + const sourceBlob = getBlobOrThrow(store, normalizedSourceKey) const destBlob = { ...sourceBlob, @@ -59,8 +57,8 @@ export const copyBlob = (store: MemoryStore, sourceKey: string, destKey: string) lastModified: new Date(), } - store.set(destKey, destBlob) - return makeBlobMetadata(destKey, destBlob) + store.set(normalizedDestKey, destBlob) + return toBlobMetadata(normalizedDestKey, destBlob) } export const getTotalSize = (store: MemoryStore): number => { diff --git a/dbal/development/src/blob/providers/memory-storage/serialization.ts b/dbal/development/src/blob/providers/memory-storage/serialization.ts new file mode 100644 index 000000000..9b9bcc52c --- /dev/null +++ b/dbal/development/src/blob/providers/memory-storage/serialization.ts @@ -0,0 +1,43 @@ +import { createHash } from 'crypto' +import type { UploadOptions, BlobMetadata } from '../blob-storage' +import type { BlobData } from './store' + +export const generateEtag = (data: Buffer): string => `"${createHash('md5').update(data).digest('hex')}"` + +export const toBlobData = (data: Buffer, options: UploadOptions = {}): BlobData => ({ + data, + contentType: options.contentType || 'application/octet-stream', + etag: generateEtag(data), + lastModified: new Date(), + metadata: options.metadata || {}, +}) + +export const toBlobMetadata = (key: string, blob: BlobData): BlobMetadata => ({ + key, + size: blob.data.length, + contentType: blob.contentType, + etag: blob.etag, + lastModified: blob.lastModified, + customMetadata: blob.metadata, +}) + +export const collectStream = async ( + stream: ReadableStream | NodeJS.ReadableStream, +): Promise => { + const chunks: Buffer[] = [] + + if ('getReader' in stream) { + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(Buffer.from(value)) + } + } else { + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)) + } + } + + return Buffer.concat(chunks) +} diff --git a/dbal/development/src/blob/providers/memory-storage/store.ts b/dbal/development/src/blob/providers/memory-storage/store.ts index d574a84d6..383249c93 100644 --- a/dbal/development/src/blob/providers/memory-storage/store.ts +++ b/dbal/development/src/blob/providers/memory-storage/store.ts @@ -1,6 +1,3 @@ -import type { BlobMetadata } from '../blob-storage' -import { createHash } from 'crypto' - export interface BlobData { data: Buffer contentType: string @@ -12,14 +9,3 @@ export interface BlobData { export type MemoryStore = Map export const createStore = (): MemoryStore => new Map() - -export const generateEtag = (data: Buffer): string => `"${createHash('md5').update(data).digest('hex')}"` - -export const makeBlobMetadata = (key: string, blob: BlobData): BlobMetadata => ({ - key, - size: blob.data.length, - contentType: blob.contentType, - etag: blob.etag, - lastModified: blob.lastModified, - customMetadata: blob.metadata, -}) diff --git a/dbal/development/src/blob/providers/memory-storage/uploads.ts b/dbal/development/src/blob/providers/memory-storage/uploads.ts index f282dc67f..356e37e85 100644 --- a/dbal/development/src/blob/providers/memory-storage/uploads.ts +++ b/dbal/development/src/blob/providers/memory-storage/uploads.ts @@ -1,7 +1,8 @@ import { DBALError } from '../../core/foundation/errors' import type { UploadOptions } from '../blob-storage' -import type { BlobData, MemoryStore } from './store' -import { generateEtag, makeBlobMetadata } from './store' +import type { MemoryStore } from './store' +import { collectStream, toBlobData, toBlobMetadata } from './serialization' +import { normalizeKey } from './utils' export const uploadBuffer = ( store: MemoryStore, @@ -9,43 +10,17 @@ export const uploadBuffer = ( data: Buffer | Uint8Array, options: UploadOptions = {}, ) => { + const normalizedKey = normalizeKey(key) const buffer = Buffer.from(data) - if (!options.overwrite && store.has(key)) { - throw DBALError.conflict(`Blob already exists: ${key}`) + if (!options.overwrite && store.has(normalizedKey)) { + throw DBALError.conflict(`Blob already exists: ${normalizedKey}`) } - const blob: BlobData = { - data: buffer, - contentType: options.contentType || 'application/octet-stream', - etag: generateEtag(buffer), - lastModified: new Date(), - metadata: options.metadata || {}, - } + const blob = toBlobData(buffer, options) - store.set(key, blob) - return makeBlobMetadata(key, blob) -} - -export const collectStream = async ( - stream: ReadableStream | NodeJS.ReadableStream, -): Promise => { - const chunks: Buffer[] = [] - - if ('getReader' in stream) { - const reader = stream.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) break - chunks.push(Buffer.from(value)) - } - } else { - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk)) - } - } - - return Buffer.concat(chunks) + store.set(normalizedKey, blob) + return toBlobMetadata(normalizedKey, blob) } export const uploadFromStream = async ( diff --git a/dbal/development/src/blob/providers/memory-storage/utils.ts b/dbal/development/src/blob/providers/memory-storage/utils.ts new file mode 100644 index 000000000..51e2a8618 --- /dev/null +++ b/dbal/development/src/blob/providers/memory-storage/utils.ts @@ -0,0 +1,18 @@ +import { DBALError } from '../../core/foundation/errors' +import type { BlobData, MemoryStore } from './store' + +export const normalizeKey = (key: string): string => key.replace(/^\/+/, '').trim() + +export const getBlobOrThrow = (store: MemoryStore, key: string): BlobData => { + const blob = store.get(key) + + if (!blob) { + throw DBALError.notFound(`Blob not found: ${key}`) + } + + return blob +} + +export const cleanupStoreEntry = (store: MemoryStore, key: string): void => { + store.delete(key) +} diff --git a/dbal/development/src/blob/providers/tenant-aware-storage.ts b/dbal/development/src/blob/providers/tenant-aware-storage.ts index 33fa59a9d..c8f14cee9 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage.ts @@ -1 +1,5 @@ export { TenantAwareBlobStorage } from './tenant-aware-storage/index' +export type { TenantAwareDeps } from './tenant-aware-storage/context' +export { scopeKey, unscopeKey } from './tenant-aware-storage/context' +export { ensurePermission, resolveTenantContext } from './tenant-aware-storage/tenant-context' +export { auditCopy, auditDeletion, auditUpload } from './tenant-aware-storage/audit-hooks' diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts b/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts new file mode 100644 index 000000000..8aeb80c80 --- /dev/null +++ b/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts @@ -0,0 +1,17 @@ +import type { TenantAwareDeps } from './context' + +const recordUsageChange = async (deps: TenantAwareDeps, bytesChange: number, countChange: number): Promise => { + await deps.tenantManager.updateBlobUsage(deps.tenantId, bytesChange, countChange) +} + +export const auditUpload = async (deps: TenantAwareDeps, sizeBytes: number): Promise => { + await recordUsageChange(deps, sizeBytes, 1) +} + +export const auditDeletion = async (deps: TenantAwareDeps, sizeBytes: number): Promise => { + await recordUsageChange(deps, -sizeBytes, -1) +} + +export const auditCopy = async (deps: TenantAwareDeps, sizeBytes: number): Promise => { + await recordUsageChange(deps, sizeBytes, 1) +} diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/context.ts b/dbal/development/src/blob/providers/tenant-aware-storage/context.ts index 234816666..067d7ff99 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage/context.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage/context.ts @@ -1,5 +1,4 @@ -import { DBALError } from '../../core/foundation/errors' -import type { TenantContext, TenantManager } from '../../core/foundation/tenant-context' +import type { TenantManager } from '../../core/foundation/tenant-context' import type { BlobStorage } from '../blob-storage' export interface TenantAwareDeps { @@ -9,10 +8,6 @@ export interface TenantAwareDeps { userId: string } -export const getContext = async ({ tenantManager, tenantId, userId }: TenantAwareDeps): Promise => { - return tenantManager.getTenantContext(tenantId, userId) -} - export const scopeKey = (key: string, namespace: string): string => { const cleanKey = key.startsWith('/') ? key.substring(1) : key return `${namespace}${cleanKey}` @@ -24,17 +19,3 @@ export const unscopeKey = (scopedKey: string, namespace: string): string => { } return scopedKey } - -export const ensurePermission = (context: TenantContext, action: 'read' | 'write' | 'delete'): void => { - const accessCheck = - action === 'read' ? context.canRead('blob') : action === 'write' ? context.canWrite('blob') : context.canDelete('blob') - - if (!accessCheck) { - const verbs: Record = { - read: 'read', - write: 'write', - delete: 'delete', - } - throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`) - } -} diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts b/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts index 6ec400af4..b518eb1c0 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts @@ -1,10 +1,12 @@ import { DBALError } from '../../core/foundation/errors' import type { BlobMetadata } from '../blob-storage' -import { ensurePermission, getContext, scopeKey } from './context' +import { auditCopy, auditDeletion } from './audit-hooks' import type { TenantAwareDeps } from './context' +import { scopeKey } from './context' +import { ensurePermission, resolveTenantContext } from './tenant-context' export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'delete') const scopedKey = scopeKey(key, context.namespace) @@ -14,7 +16,7 @@ export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) @@ -36,7 +38,7 @@ export const copyBlob = async ( sourceKey: string, destKey: string, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') ensurePermission(context, 'write') @@ -50,7 +52,7 @@ export const copyBlob = async ( const destScoped = scopeKey(destKey, context.namespace) const metadata = await deps.baseStorage.copy(sourceScoped, destScoped) - await deps.tenantManager.updateBlobUsage(deps.tenantId, sourceMetadata.size, 1) + await auditCopy(deps, sourceMetadata.size) return { ...metadata, @@ -59,7 +61,7 @@ export const copyBlob = async ( } export const getStats = async (deps: TenantAwareDeps) => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) return { count: context.quota.currentBlobCount, totalSize: context.quota.currentBlobStorageBytes, diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts b/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts index 5ba718d0d..9fc52a58b 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts @@ -1,9 +1,10 @@ import type { DownloadOptions, BlobMetadata, BlobListOptions, BlobListResult } from '../blob-storage' -import { ensurePermission, getContext, scopeKey, unscopeKey } from './context' import type { TenantAwareDeps } from './context' +import { scopeKey, unscopeKey } from './context' +import { ensurePermission, resolveTenantContext } from './tenant-context' export const downloadBuffer = async (deps: TenantAwareDeps, key: string): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) @@ -15,7 +16,7 @@ export const downloadStream = async ( key: string, options?: DownloadOptions, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) @@ -26,7 +27,7 @@ export const listBlobs = async ( deps: TenantAwareDeps, options: BlobListOptions = {}, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedOptions: BlobListOptions = { @@ -46,7 +47,7 @@ export const listBlobs = async ( } export const getMetadata = async (deps: TenantAwareDeps, key: string): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) @@ -63,7 +64,7 @@ export const generatePresignedUrl = async ( key: string, expiresIn: number, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts b/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts new file mode 100644 index 000000000..acdd36720 --- /dev/null +++ b/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts @@ -0,0 +1,21 @@ +import { DBALError } from '../../core/foundation/errors' +import type { TenantContext } from '../../core/foundation/tenant-context' +import type { TenantAwareDeps } from './context' + +export const resolveTenantContext = async ({ tenantManager, tenantId, userId }: TenantAwareDeps): Promise => { + return tenantManager.getTenantContext(tenantId, userId) +} + +export const ensurePermission = (context: TenantContext, action: 'read' | 'write' | 'delete'): void => { + const accessCheck = + action === 'read' ? context.canRead('blob') : action === 'write' ? context.canWrite('blob') : context.canDelete('blob') + + if (!accessCheck) { + const verbs: Record = { + read: 'read', + write: 'write', + delete: 'delete', + } + throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`) + } +} diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts b/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts index cd787a533..382fc4881 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts @@ -1,7 +1,9 @@ import { DBALError } from '../../core/foundation/errors' -import type { UploadOptions, BlobMetadata } from '../blob-storage' +import { auditUpload } from './audit-hooks' import type { TenantAwareDeps } from './context' -import { ensurePermission, getContext, scopeKey } from './context' +import { scopeKey } from './context' +import { ensurePermission, resolveTenantContext } from './tenant-context' +import type { UploadOptions, BlobMetadata } from '../blob-storage' export const uploadBuffer = async ( deps: TenantAwareDeps, @@ -9,7 +11,7 @@ export const uploadBuffer = async ( data: Buffer, options?: UploadOptions, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'write') if (!context.canUploadBlob(data.length)) { @@ -18,7 +20,7 @@ export const uploadBuffer = async ( const scopedKey = scopeKey(key, context.namespace) const metadata = await deps.baseStorage.upload(scopedKey, data, options) - await deps.tenantManager.updateBlobUsage(deps.tenantId, data.length, 1) + await auditUpload(deps, data.length) return { ...metadata, @@ -33,7 +35,7 @@ export const uploadStream = async ( size: number, options?: UploadOptions, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'write') if (!context.canUploadBlob(size)) { @@ -42,7 +44,7 @@ export const uploadStream = async ( const scopedKey = scopeKey(key, context.namespace) const metadata = await deps.baseStorage.uploadStream(scopedKey, stream, size, options) - await deps.tenantManager.updateBlobUsage(deps.tenantId, size, 1) + await auditUpload(deps, size) return { ...metadata, diff --git a/dbal/development/src/bridges/websocket-bridge/connection-manager.ts b/dbal/development/src/bridges/websocket-bridge/connection-manager.ts new file mode 100644 index 000000000..2e39d36a4 --- /dev/null +++ b/dbal/development/src/bridges/websocket-bridge/connection-manager.ts @@ -0,0 +1,90 @@ +import { DBALError } from '../../core/foundation/errors' +import type { RPCMessage } from '../utils/rpc-types' +import type { BridgeState } from './state' +import type { MessageRouter } from './message-router' + +export interface ConnectionManager { + ensureConnection: () => Promise + send: (message: RPCMessage) => Promise + close: () => Promise +} + +export const createConnectionManager = ( + state: BridgeState, + messageRouter: MessageRouter, +): ConnectionManager => { + let connectionPromise: Promise | null = null + + const resetConnection = () => { + connectionPromise = null + state.ws = null + } + + const rejectPendingRequests = (error: DBALError) => { + state.pendingRequests.forEach(({ reject }) => reject(error)) + state.pendingRequests.clear() + } + + const ensureConnection = async (): Promise => { + if (state.ws?.readyState === WebSocket.OPEN) { + return + } + + if (connectionPromise) { + return connectionPromise + } + + connectionPromise = new Promise((resolve, reject) => { + try { + const ws = new WebSocket(state.endpoint) + state.ws = ws + + ws.onopen = () => resolve() + ws.onerror = error => { + const connectionError = DBALError.internal(`WebSocket connection failed: ${error}`) + rejectPendingRequests(connectionError) + resetConnection() + reject(connectionError) + } + ws.onclose = () => { + rejectPendingRequests(DBALError.internal('WebSocket connection closed')) + resetConnection() + } + ws.onmessage = event => messageRouter.handle(event.data) + } catch (error) { + resetConnection() + const connectionError = + error instanceof DBALError ? error : DBALError.internal('Failed to establish WebSocket connection') + reject(connectionError) + } + }) + + return connectionPromise + } + + const send = async (message: RPCMessage): Promise => { + await ensureConnection() + + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) { + throw DBALError.internal('WebSocket connection not open') + } + + state.ws.send(JSON.stringify(message)) + } + + const close = async (): Promise => { + rejectPendingRequests(DBALError.internal('WebSocket connection closed')) + + if (state.ws) { + state.ws.close() + } + + resetConnection() + } + + return { + ensureConnection, + send, + close, + } +} diff --git a/dbal/development/src/bridges/websocket-bridge/connection.ts b/dbal/development/src/bridges/websocket-bridge/connection.ts deleted file mode 100644 index 9f348f18a..000000000 --- a/dbal/development/src/bridges/websocket-bridge/connection.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { DBALError } from '../../core/foundation/errors' -import { handleMessage } from './message-handler' -import type { BridgeState } from './state' - -export const connect = async (state: BridgeState): Promise => { - if (state.ws?.readyState === WebSocket.OPEN) { - return - } - - return new Promise((resolve, reject) => { - state.ws = new WebSocket(state.endpoint) - - state.ws.onopen = () => resolve() - state.ws.onerror = error => reject(DBALError.internal(`WebSocket connection failed: ${error}`)) - state.ws.onmessage = event => handleMessage(state, event.data) - state.ws.onclose = () => { - state.ws = null - } - }) -} - -export const closeConnection = async (state: BridgeState): Promise => { - if (state.ws) { - state.ws.close() - state.ws = null - } - state.pendingRequests.clear() -} diff --git a/dbal/development/src/bridges/websocket-bridge/index.ts b/dbal/development/src/bridges/websocket-bridge/index.ts index f4ecedcdd..b6f27cbad 100644 --- a/dbal/development/src/bridges/websocket-bridge/index.ts +++ b/dbal/development/src/bridges/websocket-bridge/index.ts @@ -1,16 +1,20 @@ import type { DBALAdapter, AdapterCapabilities } from '../../adapters/adapter' import type { ListOptions, ListResult } from '../../core/types' -import { closeConnection } from './connection' +import { createConnectionManager } from './connection-manager' +import { createMessageRouter } from './message-router' import { createOperations } from './operations' import { createBridgeState } from './state' export class WebSocketBridge implements DBALAdapter { private readonly state: ReturnType + private readonly connectionManager: ReturnType private readonly operations: ReturnType constructor(endpoint: string, auth?: { user: unknown; session: unknown }) { this.state = createBridgeState(endpoint, auth) - this.operations = createOperations(this.state) + const messageRouter = createMessageRouter(this.state) + this.connectionManager = createConnectionManager(this.state, messageRouter) + this.operations = createOperations(this.state, this.connectionManager) } create(entity: string, data: Record): Promise { @@ -75,6 +79,6 @@ export class WebSocketBridge implements DBALAdapter { } async close(): Promise { - await closeConnection(this.state) + await this.connectionManager.close() } } diff --git a/dbal/development/src/bridges/websocket-bridge/message-handler.ts b/dbal/development/src/bridges/websocket-bridge/message-handler.ts deleted file mode 100644 index 78db23362..000000000 --- a/dbal/development/src/bridges/websocket-bridge/message-handler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RPCResponse } from '../utils/rpc-types' -import type { BridgeState } from './state' -import { DBALError } from '../../core/foundation/errors' - -export const handleMessage = (state: BridgeState, data: string): void => { - try { - const response: RPCResponse = JSON.parse(data) - const pending = state.pendingRequests.get(response.id) - - if (!pending) { - return - } - - state.pendingRequests.delete(response.id) - - if (response.error) { - const error = new DBALError(response.error.message, response.error.code, response.error.details) - pending.reject(error) - } else { - pending.resolve(response.result) - } - } catch (error) { - console.error('Failed to parse WebSocket message:', error) - } -} diff --git a/dbal/development/src/bridges/websocket-bridge/message-router.ts b/dbal/development/src/bridges/websocket-bridge/message-router.ts new file mode 100644 index 000000000..0603f2a2a --- /dev/null +++ b/dbal/development/src/bridges/websocket-bridge/message-router.ts @@ -0,0 +1,68 @@ +import { DBALError } from '../../core/foundation/errors' +import type { RPCResponse } from '../utils/rpc-types' +import type { BridgeState } from './state' + +export interface MessageRouter { + handle: (rawMessage: unknown) => void +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isRPCError = (value: unknown): value is NonNullable => + isRecord(value) && + typeof value.code === 'number' && + typeof value.message === 'string' && + (value.details === undefined || isRecord(value.details)) + +const isRPCResponse = (value: unknown): value is RPCResponse => { + if (!isRecord(value)) { + return false + } + + const hasId = typeof value.id === 'string' + const hasResult = Object.prototype.hasOwnProperty.call(value, 'result') + const hasError = isRPCError(value.error) || value.error === undefined + + return hasId && (hasResult || isRPCError(value.error)) && hasError +} + +const parseResponse = (rawMessage: string): RPCResponse => { + const parsed = JSON.parse(rawMessage) as unknown + + if (!isRPCResponse(parsed)) { + throw new Error('Invalid RPC response shape') + } + + return parsed +} + +export const createMessageRouter = (state: BridgeState): MessageRouter => ({ + handle: (rawMessage: unknown) => { + if (typeof rawMessage !== 'string') { + console.warn('Ignoring non-string WebSocket message') + return + } + + try { + const response = parseResponse(rawMessage) + const pending = state.pendingRequests.get(response.id) + + if (!pending) { + console.warn(`No pending request for response ${response.id}`) + return + } + + state.pendingRequests.delete(response.id) + + if (response.error) { + const error = new DBALError(response.error.message, response.error.code, response.error.details) + pending.reject(error) + } else { + pending.resolve(response.result) + } + } catch (error) { + console.error('Failed to process WebSocket message', error) + } + }, +}) diff --git a/dbal/development/src/bridges/websocket-bridge/operations.ts b/dbal/development/src/bridges/websocket-bridge/operations.ts index 05c9a866b..8519082fe 100644 --- a/dbal/development/src/bridges/websocket-bridge/operations.ts +++ b/dbal/development/src/bridges/websocket-bridge/operations.ts @@ -1,31 +1,36 @@ import type { AdapterCapabilities } from '../../adapters/adapter' import type { ListOptions, ListResult } from '../../core/types' +import type { ConnectionManager } from './connection-manager' import type { BridgeState } from './state' import { rpcCall } from './rpc' -export const createOperations = (state: BridgeState) => ({ - create: (entity: string, data: Record) => rpcCall(state, 'create', entity, data), - read: (entity: string, id: string) => rpcCall(state, 'read', entity, id), - update: (entity: string, id: string, data: Record) => rpcCall(state, 'update', entity, id, data), - delete: (entity: string, id: string) => rpcCall(state, 'delete', entity, id) as Promise, - list: (entity: string, options?: ListOptions) => rpcCall(state, 'list', entity, options) as Promise>, - findFirst: (entity: string, filter?: Record) => rpcCall(state, 'findFirst', entity, filter), - findByField: (entity: string, field: string, value: unknown) => rpcCall(state, 'findByField', entity, field, value), +export const createOperations = (state: BridgeState, connectionManager: ConnectionManager) => ({ + create: (entity: string, data: Record) => rpcCall(state, connectionManager, 'create', entity, data), + read: (entity: string, id: string) => rpcCall(state, connectionManager, 'read', entity, id), + update: (entity: string, id: string, data: Record) => + rpcCall(state, connectionManager, 'update', entity, id, data), + delete: (entity: string, id: string) => rpcCall(state, connectionManager, 'delete', entity, id) as Promise, + list: (entity: string, options?: ListOptions) => + rpcCall(state, connectionManager, 'list', entity, options) as Promise>, + findFirst: (entity: string, filter?: Record) => + rpcCall(state, connectionManager, 'findFirst', entity, filter), + findByField: (entity: string, field: string, value: unknown) => + rpcCall(state, connectionManager, 'findByField', entity, field, value), upsert: ( entity: string, filter: Record, createData: Record, updateData: Record, - ) => rpcCall(state, 'upsert', entity, filter, createData, updateData), + ) => rpcCall(state, connectionManager, 'upsert', entity, filter, createData, updateData), updateByField: (entity: string, field: string, value: unknown, data: Record) => - rpcCall(state, 'updateByField', entity, field, value, data), + rpcCall(state, connectionManager, 'updateByField', entity, field, value, data), deleteByField: (entity: string, field: string, value: unknown) => - rpcCall(state, 'deleteByField', entity, field, value) as Promise, + rpcCall(state, connectionManager, 'deleteByField', entity, field, value) as Promise, deleteMany: (entity: string, filter?: Record) => - rpcCall(state, 'deleteMany', entity, filter) as Promise, + rpcCall(state, connectionManager, 'deleteMany', entity, filter) as Promise, createMany: (entity: string, data: Record[]) => - rpcCall(state, 'createMany', entity, data) as Promise, + rpcCall(state, connectionManager, 'createMany', entity, data) as Promise, updateMany: (entity: string, filter: Record, data: Record) => - rpcCall(state, 'updateMany', entity, filter, data) as Promise, - getCapabilities: () => rpcCall(state, 'getCapabilities') as Promise, + rpcCall(state, connectionManager, 'updateMany', entity, filter, data) as Promise, + getCapabilities: () => rpcCall(state, connectionManager, 'getCapabilities') as Promise, }) diff --git a/dbal/development/src/bridges/websocket-bridge/rpc.ts b/dbal/development/src/bridges/websocket-bridge/rpc.ts index 2462558b4..3de06a550 100644 --- a/dbal/development/src/bridges/websocket-bridge/rpc.ts +++ b/dbal/development/src/bridges/websocket-bridge/rpc.ts @@ -1,25 +1,28 @@ import { DBALError } from '../../core/foundation/errors' import { generateRequestId } from '../utils/generate-request-id' import type { RPCMessage } from '../utils/rpc-types' -import { connect } from './connection' +import type { ConnectionManager } from './connection-manager' import type { BridgeState } from './state' -export const rpcCall = async (state: BridgeState, method: string, ...params: unknown[]): Promise => { - await connect(state) - +export const rpcCall = async ( + state: BridgeState, + connectionManager: ConnectionManager, + method: string, + ...params: unknown[] +): Promise => { const id = generateRequestId() const message: RPCMessage = { id, method, params } return new Promise((resolve, reject) => { state.pendingRequests.set(id, { resolve, reject }) - if (state.ws?.readyState === WebSocket.OPEN) { - state.ws.send(JSON.stringify(message)) - } else { - state.pendingRequests.delete(id) - reject(DBALError.internal('WebSocket connection not open')) - return - } + connectionManager + .send(message) + .catch(error => { + state.pendingRequests.delete(id) + reject(error) + return + }) setTimeout(() => { if (state.pendingRequests.has(id)) { diff --git a/dbal/development/src/core/client.ts b/dbal/development/src/core/client.ts new file mode 100644 index 000000000..789cabfc1 --- /dev/null +++ b/dbal/development/src/core/client.ts @@ -0,0 +1,8 @@ +import type { DBALConfig } from '../runtime/config' +import { DBALClient } from './client/client' +export { buildAdapter, buildEntityOperations } from './client/builders' +export { normalizeClientConfig, validateClientConfig } from './client/mappers' + +export const createDBALClient = (config: DBALConfig) => new DBALClient(config) + +export { DBALClient } diff --git a/dbal/development/src/core/client/builders.ts b/dbal/development/src/core/client/builders.ts new file mode 100644 index 000000000..56fcf095f --- /dev/null +++ b/dbal/development/src/core/client/builders.ts @@ -0,0 +1,24 @@ +import type { DBALAdapter } from '../../adapters/adapter' +import type { DBALConfig } from '../../runtime/config' +import { createAdapter } from './adapter-factory' +import { + createComponentOperations, + createLuaScriptOperations, + createPackageOperations, + createPageOperations, + createSessionOperations, + createUserOperations, + createWorkflowOperations +} from '../entities' + +export const buildAdapter = (config: DBALConfig): DBALAdapter => createAdapter(config) + +export const buildEntityOperations = (adapter: DBALAdapter) => ({ + users: createUserOperations(adapter), + pages: createPageOperations(adapter), + components: createComponentOperations(adapter), + workflows: createWorkflowOperations(adapter), + luaScripts: createLuaScriptOperations(adapter), + packages: createPackageOperations(adapter), + sessions: createSessionOperations(adapter) +}) diff --git a/dbal/development/src/core/client/client.ts b/dbal/development/src/core/client/client.ts index b57eb6ea9..6c9a98691 100644 --- a/dbal/development/src/core/client/client.ts +++ b/dbal/development/src/core/client/client.ts @@ -1,7 +1,7 @@ /** * @file client.ts * @description DBAL Client - Main interface for database operations - * + * * Provides CRUD operations for all entities through modular operation handlers. * Each entity type has its own dedicated operations module following the * single-responsibility pattern. @@ -9,82 +9,67 @@ import type { DBALConfig } from '../../runtime/config' import type { DBALAdapter } from '../../adapters/adapter' -import { createAdapter } from './adapter-factory' -import { - createUserOperations, - createPageOperations, - createComponentOperations, - createWorkflowOperations, - createLuaScriptOperations, - createPackageOperations, - createSessionOperations, -} from '../entities' +import { buildAdapter, buildEntityOperations } from './builders' +import { normalizeClientConfig, validateClientConfig } from './mappers' export class DBALClient { private adapter: DBALAdapter private config: DBALConfig + private operations: ReturnType constructor(config: DBALConfig) { - this.config = config - - // Validate configuration - if (!config.adapter) { - throw new Error('Adapter type must be specified') - } - if (config.mode !== 'production' && !config.database?.url) { - throw new Error('Database URL must be specified for non-production mode') - } - - this.adapter = createAdapter(config) + this.config = normalizeClientConfig(validateClientConfig(config)) + this.adapter = buildAdapter(this.config) + this.operations = buildEntityOperations(this.adapter) } /** * User entity operations */ get users() { - return createUserOperations(this.adapter) + return this.operations.users } /** * Page entity operations */ get pages() { - return createPageOperations(this.adapter) + return this.operations.pages } /** * Component hierarchy entity operations */ get components() { - return createComponentOperations(this.adapter) + return this.operations.components } /** * Workflow entity operations */ get workflows() { - return createWorkflowOperations(this.adapter) + return this.operations.workflows } /** * Lua script entity operations */ get luaScripts() { - return createLuaScriptOperations(this.adapter) + return this.operations.luaScripts } /** * Package entity operations */ get packages() { - return createPackageOperations(this.adapter) + return this.operations.packages } /** * Session entity operations */ get sessions() { - return createSessionOperations(this.adapter) + return this.operations.sessions } /** diff --git a/dbal/development/src/core/client/mappers.ts b/dbal/development/src/core/client/mappers.ts new file mode 100644 index 000000000..b9abc9661 --- /dev/null +++ b/dbal/development/src/core/client/mappers.ts @@ -0,0 +1,25 @@ +import type { DBALConfig } from '../../runtime/config' +import { DBALError } from '../foundation/errors' + +export const validateClientConfig = (config: DBALConfig): DBALConfig => { + if (!config.adapter) { + throw DBALError.validationError('Adapter type must be specified', []) + } + + if (config.mode !== 'production' && !config.database?.url) { + throw DBALError.validationError('Database URL must be specified for non-production mode', []) + } + + return config +} + +export const normalizeClientConfig = (config: DBALConfig): DBALConfig => ({ + ...config, + security: { + sandbox: config.security?.sandbox ?? 'strict', + enableAuditLog: config.security?.enableAuditLog ?? true + }, + performance: { + ...config.performance + } +}) diff --git a/dbal/development/src/core/entities/operations/core/user-operations.ts b/dbal/development/src/core/entities/operations/core/user-operations.ts index d5e29f59c..5d1da503e 100644 --- a/dbal/development/src/core/entities/operations/core/user-operations.ts +++ b/dbal/development/src/core/entities/operations/core/user-operations.ts @@ -1,2 +1,11 @@ export { createUserOperations } from './user' export type { UserOperations } from './user' + +export { createUser } from './user/create' +export { deleteUser } from './user/delete' +export { updateUser } from './user/update' +export { + assertValidUserCreate, + assertValidUserId, + assertValidUserUpdate, +} from './user/validation' diff --git a/dbal/development/src/core/entities/operations/core/user/create.ts b/dbal/development/src/core/entities/operations/core/user/create.ts new file mode 100644 index 000000000..4543fe4e2 --- /dev/null +++ b/dbal/development/src/core/entities/operations/core/user/create.ts @@ -0,0 +1,20 @@ +import type { DBALAdapter } from '../../../../adapters/adapter' +import { DBALError } from '../../../../foundation/errors' +import type { User } from '../../../../foundation/types' +import { assertValidUserCreate } from './validation' + +export const createUser = async ( + adapter: DBALAdapter, + data: Omit, +): Promise => { + assertValidUserCreate(data) + + try { + return adapter.create('User', data) as Promise + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict('User with username or email already exists') + } + throw error + } +} diff --git a/dbal/development/src/core/entities/operations/core/user/delete.ts b/dbal/development/src/core/entities/operations/core/user/delete.ts new file mode 100644 index 000000000..07484d1a6 --- /dev/null +++ b/dbal/development/src/core/entities/operations/core/user/delete.ts @@ -0,0 +1,13 @@ +import type { DBALAdapter } from '../../../../adapters/adapter' +import { DBALError } from '../../../../foundation/errors' +import { assertValidUserId } from './validation' + +export const deleteUser = async (adapter: DBALAdapter, id: string): Promise => { + assertValidUserId(id) + + const result = await adapter.delete('User', id) + if (!result) { + throw DBALError.notFound(`User not found: ${id}`) + } + return result +} diff --git a/dbal/development/src/core/entities/operations/core/user/index.ts b/dbal/development/src/core/entities/operations/core/user/index.ts index 200efa017..a5f410c72 100644 --- a/dbal/development/src/core/entities/operations/core/user/index.ts +++ b/dbal/development/src/core/entities/operations/core/user/index.ts @@ -1,6 +1,8 @@ import type { DBALAdapter } from '../../../../adapters/adapter' import type { User, ListOptions, ListResult } from '../../../../foundation/types' -import { createUser, deleteUser, updateUser } from './mutations' +import { createUser } from './create' +import { deleteUser } from './delete' +import { updateUser } from './update' import { createManyUsers, deleteManyUsers, updateManyUsers } from './batch' import { listUsers, readUser } from './reads' diff --git a/dbal/development/src/core/entities/operations/core/user/mutations.ts b/dbal/development/src/core/entities/operations/core/user/mutations.ts deleted file mode 100644 index 8e80c7be8..000000000 --- a/dbal/development/src/core/entities/operations/core/user/mutations.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { DBALAdapter } from '../../../../adapters/adapter' -import type { User } from '../../../../foundation/types' -import { DBALError } from '../../../../foundation/errors' -import { validateUserCreate, validateUserUpdate, validateId } from '../../../../foundation/validation' - -export const createUser = async ( - adapter: DBALAdapter, - data: Omit, -): Promise => { - const validationErrors = validateUserCreate(data) - if (validationErrors.length > 0) { - throw DBALError.validationError('Invalid user data', validationErrors.map(error => ({ field: 'user', error }))) - } - - try { - return adapter.create('User', data) as Promise - } catch (error) { - if (error instanceof DBALError && error.code === 409) { - throw DBALError.conflict('User with username or email already exists') - } - throw error - } -} - -export const updateUser = async (adapter: DBALAdapter, id: string, data: Partial): Promise => { - const idErrors = validateId(id) - if (idErrors.length > 0) { - throw DBALError.validationError('Invalid user ID', idErrors.map(error => ({ field: 'id', error }))) - } - - const validationErrors = validateUserUpdate(data) - if (validationErrors.length > 0) { - throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error }))) - } - - try { - return adapter.update('User', id, data) as Promise - } catch (error) { - if (error instanceof DBALError && error.code === 409) { - throw DBALError.conflict('Username or email already exists') - } - throw error - } -} - -export const deleteUser = async (adapter: DBALAdapter, id: string): Promise => { - const validationErrors = validateId(id) - if (validationErrors.length > 0) { - throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error }))) - } - - const result = await adapter.delete('User', id) - if (!result) { - throw DBALError.notFound(`User not found: ${id}`) - } - return result -} diff --git a/dbal/development/src/core/entities/operations/core/user/update.ts b/dbal/development/src/core/entities/operations/core/user/update.ts new file mode 100644 index 000000000..ca0ae185d --- /dev/null +++ b/dbal/development/src/core/entities/operations/core/user/update.ts @@ -0,0 +1,22 @@ +import type { DBALAdapter } from '../../../../adapters/adapter' +import { DBALError } from '../../../../foundation/errors' +import type { User } from '../../../../foundation/types' +import { assertValidUserId, assertValidUserUpdate } from './validation' + +export const updateUser = async ( + adapter: DBALAdapter, + id: string, + data: Partial, +): Promise => { + assertValidUserId(id) + assertValidUserUpdate(data) + + try { + return adapter.update('User', id, data) as Promise + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict('Username or email already exists') + } + throw error + } +} diff --git a/dbal/development/src/core/entities/operations/core/user/validation.ts b/dbal/development/src/core/entities/operations/core/user/validation.ts new file mode 100644 index 000000000..0b57322d5 --- /dev/null +++ b/dbal/development/src/core/entities/operations/core/user/validation.ts @@ -0,0 +1,24 @@ +import { DBALError } from '../../../../foundation/errors' +import type { User } from '../../../../foundation/types' +import { validateId, validateUserCreate, validateUserUpdate } from '../../../../foundation/validation' + +export const assertValidUserId = (id: string): void => { + const validationErrors = validateId(id) + if (validationErrors.length > 0) { + throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error }))) + } +} + +export const assertValidUserCreate = (data: Omit): void => { + const validationErrors = validateUserCreate(data) + if (validationErrors.length > 0) { + throw DBALError.validationError('Invalid user data', validationErrors.map(error => ({ field: 'user', error }))) + } +} + +export const assertValidUserUpdate = (data: Partial): void => { + const validationErrors = validateUserUpdate(data) + if (validationErrors.length > 0) { + throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error }))) + } +} diff --git a/dbal/development/src/core/entities/operations/system/package-operations.ts b/dbal/development/src/core/entities/operations/system/package-operations.ts index 1f5d45352..886ac9b16 100644 --- a/dbal/development/src/core/entities/operations/system/package-operations.ts +++ b/dbal/development/src/core/entities/operations/system/package-operations.ts @@ -1,2 +1 @@ -export { createPackageOperations } from './package' -export type { PackageOperations } from './package' +export * from './package' diff --git a/dbal/development/src/core/entities/operations/system/package/index.ts b/dbal/development/src/core/entities/operations/system/package/index.ts index 7dce526f8..b70a4a145 100644 --- a/dbal/development/src/core/entities/operations/system/package/index.ts +++ b/dbal/development/src/core/entities/operations/system/package/index.ts @@ -2,9 +2,15 @@ import type { DBALAdapter } from '../../../../adapters/adapter' import type { Package, ListOptions, ListResult } from '../../../../foundation/types' import { createManyPackages, deleteManyPackages, updateManyPackages } from './batch' import { createPackage, deletePackage, updatePackage } from './mutations' +import { publishPackage } from './publish' import { listPackages, readPackage } from './reads' +import { unpublishPackage } from './unpublish' +import { validatePackage } from './validate' export interface PackageOperations { + validate: (data: Partial) => string[] + publish: (data: Omit) => Promise + unpublish: (id: string) => Promise create: (data: Omit) => Promise read: (id: string) => Promise update: (id: string, data: Partial) => Promise @@ -16,6 +22,9 @@ export interface PackageOperations { } export const createPackageOperations = (adapter: DBALAdapter): PackageOperations => ({ + validate: data => validatePackage(data), + publish: data => publishPackage(adapter, data), + unpublish: id => unpublishPackage(adapter, id), create: data => createPackage(adapter, data), read: id => readPackage(adapter, id), update: (id, data) => updatePackage(adapter, id, data), @@ -25,3 +34,7 @@ export const createPackageOperations = (adapter: DBALAdapter): PackageOperations updateMany: (filter, data) => updateManyPackages(adapter, filter, data), deleteMany: filter => deleteManyPackages(adapter, filter), }) + +export { publishPackage } from './publish' +export { unpublishPackage } from './unpublish' +export { validatePackage } from './validate' diff --git a/dbal/development/src/core/entities/operations/system/package/publish.ts b/dbal/development/src/core/entities/operations/system/package/publish.ts new file mode 100644 index 000000000..f59f721ae --- /dev/null +++ b/dbal/development/src/core/entities/operations/system/package/publish.ts @@ -0,0 +1,10 @@ +import type { DBALAdapter } from '../../../../adapters/adapter' +import type { Package } from '../../../../foundation/types' +import { createPackage } from './mutations' + +export const publishPackage = ( + adapter: DBALAdapter, + data: Omit, +): Promise => { + return createPackage(adapter, data) +} diff --git a/dbal/development/src/core/entities/operations/system/package/unpublish.ts b/dbal/development/src/core/entities/operations/system/package/unpublish.ts new file mode 100644 index 000000000..27a5da97f --- /dev/null +++ b/dbal/development/src/core/entities/operations/system/package/unpublish.ts @@ -0,0 +1,6 @@ +import type { DBALAdapter } from '../../../../adapters/adapter' +import { deletePackage } from './mutations' + +export const unpublishPackage = (adapter: DBALAdapter, id: string): Promise => { + return deletePackage(adapter, id) +} diff --git a/dbal/development/src/core/entities/operations/system/package/validate.ts b/dbal/development/src/core/entities/operations/system/package/validate.ts new file mode 100644 index 000000000..868033e9e --- /dev/null +++ b/dbal/development/src/core/entities/operations/system/package/validate.ts @@ -0,0 +1,6 @@ +import type { Package } from '../../../../foundation/types' +import { validatePackageCreate } from '../../../../foundation/validation' + +export const validatePackage = (data: Partial): string[] => { + return validatePackageCreate(data) +} diff --git a/dbal/development/src/core/foundation/types/entities.ts b/dbal/development/src/core/foundation/types/entities.ts new file mode 100644 index 000000000..dcd20b271 --- /dev/null +++ b/dbal/development/src/core/foundation/types/entities.ts @@ -0,0 +1,19 @@ +export type EntityId = string + +export interface BaseEntity { + id: EntityId + createdAt: Date + updatedAt: Date +} + +export interface SoftDeletableEntity extends BaseEntity { + deletedAt?: Date +} + +export interface TenantScopedEntity extends BaseEntity { + tenantId: string +} + +export interface EntityMetadata { + metadata?: Record +} diff --git a/dbal/development/src/core/foundation/types/events.ts b/dbal/development/src/core/foundation/types/events.ts new file mode 100644 index 000000000..5679fb156 --- /dev/null +++ b/dbal/development/src/core/foundation/types/events.ts @@ -0,0 +1,13 @@ +import type { OperationContext } from './operations' + +export interface DomainEvent> { + id: string + name: string + occurredAt: Date + payload: TPayload + context?: OperationContext +} + +export interface EventHandler> { + (event: DomainEvent): void | Promise +} diff --git a/dbal/development/src/core/foundation/types/index.ts b/dbal/development/src/core/foundation/types/index.ts index 4c2264d64..293944c9a 100644 --- a/dbal/development/src/core/foundation/types/index.ts +++ b/dbal/development/src/core/foundation/types/index.ts @@ -4,3 +4,6 @@ export * from './content' export * from './automation' export * from './packages' export * from './shared' +export * from './entities' +export * from './operations' +export * from './events' diff --git a/dbal/development/src/core/foundation/types/operations.ts b/dbal/development/src/core/foundation/types/operations.ts new file mode 100644 index 000000000..c411a9ea9 --- /dev/null +++ b/dbal/development/src/core/foundation/types/operations.ts @@ -0,0 +1,19 @@ +export interface OperationContext { + tenantId?: string + userId?: string + correlationId?: string + traceId?: string + metadata?: Record +} + +export interface OperationOptions { + timeoutMs?: number + retryCount?: number + dryRun?: boolean +} + +export interface OperationAuditTrail { + performedAt: Date + performedBy?: string + context?: OperationContext +} diff --git a/dbal/development/src/index.ts b/dbal/development/src/index.ts index 7acf658e0..e98734f17 100644 --- a/dbal/development/src/index.ts +++ b/dbal/development/src/index.ts @@ -1,4 +1,4 @@ -export { DBALClient } from './core/client/client' +export { DBALClient, createDBALClient } from './core/client' export type { DBALConfig } from './runtime/config' export type * from './core/foundation/types' export { DBALError, DBALErrorCode } from './core/foundation/errors' diff --git a/dbal/shared/tools/cpp-build-assistant.ts b/dbal/shared/tools/cpp-build-assistant.ts index fcd23a64b..7becf72bc 100644 --- a/dbal/shared/tools/cpp-build-assistant.ts +++ b/dbal/shared/tools/cpp-build-assistant.ts @@ -1,12 +1,15 @@ import path from 'path' -import { CppBuildAssistant, runCppBuildAssistant } from './cpp-build-assistant/index' +import { runCppBuildAssistant } from './cpp-build-assistant/runner' -export { CppBuildAssistant, runCppBuildAssistant } +export { CppBuildAssistant, createAssistant } from './cpp-build-assistant' +export { createCppBuildAssistantConfig } from './cpp-build-assistant/config' +export { runCppBuildAssistant } from './cpp-build-assistant/runner' if (require.main === module) { const args = process.argv.slice(2) + const projectRoot = path.join(__dirname, '..') - runCppBuildAssistant(args, path.join(__dirname, '..')) + runCppBuildAssistant(args, projectRoot) .then(success => { process.exit(success ? 0 : 1) }) diff --git a/dbal/shared/tools/cpp-build-assistant/cli.ts b/dbal/shared/tools/cpp-build-assistant/cli.ts new file mode 100644 index 000000000..c29c525a8 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/cli.ts @@ -0,0 +1,125 @@ +import os from 'os' +import { BuildType } from './config' +import { COLORS, log } from './logging' +import { CppBuildAssistant } from './index' + +export type CliCommand = + | 'check' + | 'init' + | 'install' + | 'configure' + | 'build' + | 'test' + | 'clean' + | 'rebuild' + | 'full' + | 'help' + +export interface ParsedCliArgs { + command: CliCommand + buildType: BuildType + jobs: number + target?: string + options: string[] +} + +const parseBuildType = (options: string[]): BuildType => (options.includes('--debug') ? 'Debug' : 'Release') + +const parseJobs = (options: string[]): number => { + const jobsArg = options.find(option => option.startsWith('--jobs=')) + const parsedJobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : Number.NaN + + return Number.isNaN(parsedJobs) ? os.cpus().length : parsedJobs +} + +const parseTarget = (command: CliCommand, options: string[]): string | undefined => { + if (command !== 'build') return undefined + + return options.find(option => !option.startsWith('--')) || 'all' +} + +export const parseCliArgs = (args: string[]): ParsedCliArgs => { + const command = (args[0] as CliCommand | undefined) || 'help' + const options = args.slice(1) + + return { + command, + buildType: parseBuildType(options), + jobs: parseJobs(options), + target: parseTarget(command, options), + options, + } +} + +export const showHelp = (): void => { + console.log(` +${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper + +${COLORS.cyan}USAGE:${COLORS.reset} + npm run cpp:build [command] [options] + +${COLORS.cyan}COMMANDS:${COLORS.reset} + ${COLORS.green}check${COLORS.reset} Check if all dependencies are installed + ${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing) + ${COLORS.green}install${COLORS.reset} Install Conan dependencies + ${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator + ${COLORS.green}build${COLORS.reset} [target] Build the project (default: all) + ${COLORS.green}test${COLORS.reset} Run tests with CTest + ${COLORS.green}clean${COLORS.reset} Remove build artifacts + ${COLORS.green}rebuild${COLORS.reset} Clean and rebuild + ${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build + ${COLORS.green}help${COLORS.reset} Show this help message + +${COLORS.cyan}OPTIONS:${COLORS.reset} + --debug Use Debug build type + --release Use Release build type (default) + --jobs=N Number of parallel build jobs (default: CPU count) + +${COLORS.cyan}EXAMPLES:${COLORS.reset} + npm run cpp:build check + npm run cpp:build full + npm run cpp:build build dbal_daemon + npm run cpp:build build -- --debug + npm run cpp:build test +`) +} + +export const runCli = async (args: string[], assistant: CppBuildAssistant): Promise => { + const parsed = parseCliArgs(args) + + switch (parsed.command) { + case 'check': + return assistant.checkDependencies() + case 'init': + return assistant.createConanfile() + case 'install': + if (!assistant.checkDependencies()) return false + return assistant.installConanDeps() + case 'configure': + if (!assistant.checkDependencies()) return false + return assistant.configureCMake(parsed.buildType) + case 'build': + if (!assistant.checkDependencies()) return false + return assistant.build(parsed.target, parsed.jobs) + case 'test': + return assistant.test() + case 'clean': + return assistant.clean() + case 'rebuild': + assistant.clean() + if (!assistant.checkDependencies()) return false + if (!assistant.configureCMake(parsed.buildType)) return false + return assistant.build('all', parsed.jobs) + case 'full': + log.section('Full Build Workflow') + if (!assistant.checkDependencies()) return false + if (!assistant.createConanfile()) return false + if (!assistant.installConanDeps()) return false + if (!assistant.configureCMake(parsed.buildType)) return false + return assistant.build('all', parsed.jobs) + case 'help': + default: + showHelp() + return true + } +} diff --git a/dbal/shared/tools/cpp-build-assistant/config.ts b/dbal/shared/tools/cpp-build-assistant/config.ts new file mode 100644 index 000000000..f160b8cc4 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/config.ts @@ -0,0 +1,20 @@ +import path from 'path' + +export type BuildType = 'Debug' | 'Release' + +export interface CppBuildAssistantConfig { + projectRoot: string + cppDir: string + buildDir: string +} + +export const createCppBuildAssistantConfig = (projectRoot?: string): CppBuildAssistantConfig => { + const resolvedProjectRoot = projectRoot || path.join(__dirname, '..') + const cppDir = path.join(resolvedProjectRoot, 'cpp') + + return { + projectRoot: resolvedProjectRoot, + cppDir, + buildDir: path.join(cppDir, 'build'), + } +} diff --git a/dbal/shared/tools/cpp-build-assistant/index.ts b/dbal/shared/tools/cpp-build-assistant/index.ts index 6810db714..a4f7500b8 100644 --- a/dbal/shared/tools/cpp-build-assistant/index.ts +++ b/dbal/shared/tools/cpp-build-assistant/index.ts @@ -1,18 +1,27 @@ import os from 'os' import path from 'path' +import { CppBuildAssistantConfig, BuildType, createCppBuildAssistantConfig } from './config' import { COLORS, log } from './logging' import { checkDependencies } from './dependencies' import { cleanBuild, configureCMake, ensureConanFile, execCommand, installConanDeps, buildTarget, runTests } from './workflow' export class CppBuildAssistant { - private projectRoot: string - private cppDir: string - private buildDir: string + private config: CppBuildAssistantConfig - constructor(projectRoot?: string) { - this.projectRoot = projectRoot || path.join(__dirname, '..') - this.cppDir = path.join(this.projectRoot, 'cpp') - this.buildDir = path.join(this.cppDir, 'build') + constructor(config?: CppBuildAssistantConfig) { + this.config = config || createCppBuildAssistantConfig() + } + + get projectRoot(): string { + return this.config.projectRoot + } + + get cppDir(): string { + return this.config.cppDir + } + + get buildDir(): string { + return this.config.buildDir } checkDependencies(): boolean { @@ -27,7 +36,7 @@ export class CppBuildAssistant { return installConanDeps(this.cppDir, execCommand) } - configureCMake(buildType: 'Debug' | 'Release' = 'Release'): boolean { + configureCMake(buildType: BuildType = 'Release'): boolean { return configureCMake(this.cppDir, buildType, execCommand) } @@ -42,88 +51,11 @@ export class CppBuildAssistant { clean(): boolean { return cleanBuild(this.buildDir) } - - async run(args: string[]): Promise { - const command = args[0] || 'help' - const options = args.slice(1) - - const buildType = options.includes('--debug') ? 'Debug' : 'Release' - const jobsArg = options.find(option => option.startsWith('--jobs=')) - const jobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : os.cpus().length - - switch (command) { - case 'check': - return this.checkDependencies() - case 'init': - return this.createConanfile() - case 'install': - if (!this.checkDependencies()) return false - return this.installConanDeps() - case 'configure': - if (!this.checkDependencies()) return false - return this.configureCMake(buildType as 'Debug' | 'Release') - case 'build': - if (!this.checkDependencies()) return false - const target = options.find(option => !option.startsWith('--')) || 'all' - return this.build(target, jobs) - case 'test': - return this.test() - case 'clean': - return this.clean() - case 'rebuild': - this.clean() - if (!this.checkDependencies()) return false - if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false - return this.build('all', jobs) - case 'full': - log.section('Full Build Workflow') - if (!this.checkDependencies()) return false - if (!this.createConanfile()) return false - if (!this.installConanDeps()) return false - if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false - return this.build('all', jobs) - case 'help': - default: - this.showHelp() - return true - } - } - - private showHelp(): void { - console.log(` -${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper - -${COLORS.cyan}USAGE:${COLORS.reset} - npm run cpp:build [command] [options] - -${COLORS.cyan}COMMANDS:${COLORS.reset} - ${COLORS.green}check${COLORS.reset} Check if all dependencies are installed - ${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing) - ${COLORS.green}install${COLORS.reset} Install Conan dependencies - ${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator - ${COLORS.green}build${COLORS.reset} [target] Build the project (default: all) - ${COLORS.green}test${COLORS.reset} Run tests with CTest - ${COLORS.green}clean${COLORS.reset} Remove build artifacts - ${COLORS.green}rebuild${COLORS.reset} Clean and rebuild - ${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build - ${COLORS.green}help${COLORS.reset} Show this help message - -${COLORS.cyan}OPTIONS:${COLORS.reset} - --debug Use Debug build type - --release Use Release build type (default) - --jobs=N Number of parallel build jobs (default: CPU count) - -${COLORS.cyan}EXAMPLES:${COLORS.reset} - npm run cpp:build check - npm run cpp:build full - npm run cpp:build build dbal_daemon - npm run cpp:build build -- --debug - npm run cpp:build test -`) - } } -export const runCppBuildAssistant = async (args: string[], projectRoot?: string) => { - const assistant = new CppBuildAssistant(projectRoot || path.join(__dirname, '..')) - return assistant.run(args) +export const createAssistant = (projectRoot?: string): CppBuildAssistant => { + const config = createCppBuildAssistantConfig(projectRoot || path.join(__dirname, '..')) + return new CppBuildAssistant(config) } + +export { BuildType, CppBuildAssistantConfig, COLORS, log } diff --git a/dbal/shared/tools/cpp-build-assistant/runner.ts b/dbal/shared/tools/cpp-build-assistant/runner.ts new file mode 100644 index 000000000..5d84827d2 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/runner.ts @@ -0,0 +1,10 @@ +import { CppBuildAssistant } from './index' +import { createCppBuildAssistantConfig } from './config' +import { runCli } from './cli' + +export const runCppBuildAssistant = async (args: string[], projectRoot?: string): Promise => { + const config = createCppBuildAssistantConfig(projectRoot) + const assistant = new CppBuildAssistant(config) + + return runCli(args, assistant) +} diff --git a/detection/detectors/class-detector.ts b/detection/detectors/class-detector.ts new file mode 100644 index 000000000..2fb08b909 --- /dev/null +++ b/detection/detectors/class-detector.ts @@ -0,0 +1,64 @@ +import * as ts from 'typescript' +import { Detector, DetectionFinding, DetectorContext } from '..' + +const getLocation = (sourceFile: ts.SourceFile, node: ts.Node) => { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()) + + return { + line: line + 1, + column: character + 1 + } +} + +const getClassName = ( + node: ts.ClassDeclaration | ts.ClassExpression, + sourceFile: ts.SourceFile +): string => { + if (node.name) { + return node.name.getText(sourceFile) + } + + const parent = node.parent + + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + return parent.name.text + } + + return 'anonymous' +} + +const collectClasses = (context: DetectorContext): DetectionFinding[] => { + const sourceFile = ts.createSourceFile( + context.filePath, + context.source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ) + + const findings: DetectionFinding[] = [] + + const visit = (node: ts.Node) => { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + const name = getClassName(node, sourceFile) + findings.push({ + detectorId: 'class-detector', + name, + message: `Class detected: ${name}`, + location: getLocation(sourceFile, node) + }) + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + return findings +} + +export const classDetector: Detector = { + id: 'class-detector', + description: 'Detects class declarations and expressions within a TypeScript/TSX source file.', + detect: collectClasses +} diff --git a/detection/detectors/function-detector.ts b/detection/detectors/function-detector.ts new file mode 100644 index 000000000..7bfffafd1 --- /dev/null +++ b/detection/detectors/function-detector.ts @@ -0,0 +1,78 @@ +import * as ts from 'typescript' +import { Detector, DetectionFinding, DetectorContext } from '..' + +type FunctionLike = + | ts.FunctionDeclaration + | ts.FunctionExpression + | ts.ArrowFunction + | ts.MethodDeclaration + | ts.ConstructorDeclaration + +const getLocation = (sourceFile: ts.SourceFile, node: ts.Node) => { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()) + + return { + line: line + 1, + column: character + 1 + } +} + +const getFunctionName = (node: FunctionLike, sourceFile: ts.SourceFile): string => { + if ('name' in node && node.name) { + return node.name.getText(sourceFile) + } + + const parent = node.parent + + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + return parent.name.text + } + + if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) { + return parent.name.text + } + + return 'anonymous' +} + +const collectFunctions = (context: DetectorContext): DetectionFinding[] => { + const sourceFile = ts.createSourceFile( + context.filePath, + context.source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ) + + const findings: DetectionFinding[] = [] + + const visit = (node: ts.Node) => { + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) + ) { + const name = getFunctionName(node, sourceFile) + findings.push({ + detectorId: 'function-detector', + name, + message: `Function detected: ${name}`, + location: getLocation(sourceFile, node) + }) + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + return findings +} + +export const functionDetector: Detector = { + id: 'function-detector', + description: 'Detects functions and methods within a TypeScript/TSX source file.', + detect: collectFunctions +} diff --git a/detection/index.ts b/detection/index.ts new file mode 100644 index 000000000..7f5eb21f4 --- /dev/null +++ b/detection/index.ts @@ -0,0 +1,45 @@ +import { classDetector } from './detectors/class-detector' +import { functionDetector } from './detectors/function-detector' + +export type DetectorContext = { + filePath: string + source: string +} + +export type DetectionFinding = { + detectorId: string + name: string + message: string + location?: { + line: number + column: number + } +} + +export interface Detector { + id: string + description: string + detect: (context: DetectorContext) => DetectionFinding[] +} + +export class DetectorRegistry { + private readonly detectors: Detector[] = [] + + register(detector: Detector): void { + this.detectors.push(detector) + } + + list(): Detector[] { + return [...this.detectors] + } + + run(context: DetectorContext): DetectionFinding[] { + return this.detectors.flatMap((detector) => detector.detect(context)) + } +} + +export const registry = new DetectorRegistry() + +const builtInDetectors: Detector[] = [functionDetector, classDetector] + +builtInDetectors.forEach((detector) => registry.register(detector)) diff --git a/frontends/nextjs/src/app/codegen/CodegenStudioClient.tsx b/frontends/nextjs/src/app/codegen/CodegenStudioClient.tsx index ee56df809..f67d800a4 100644 --- a/frontends/nextjs/src/app/codegen/CodegenStudioClient.tsx +++ b/frontends/nextjs/src/app/codegen/CodegenStudioClient.tsx @@ -1,6 +1,5 @@ 'use client' -import type { CodegenManifest } from '@/lib/codegen/codegen-types' import { useMemo, useState, type ChangeEvent } from 'react' import { @@ -16,6 +15,10 @@ import { Typography, } from '@mui/material' +import Header from './components/Header' +import Sidebar from './components/Sidebar' +import { useCodegenData, type CodegenRequest } from './hooks/useCodegenData' + const runtimeOptions = [ { value: 'web', label: 'Next.js web' }, { value: 'cli', label: 'Command line' }, @@ -24,7 +27,7 @@ const runtimeOptions = [ { value: 'server', label: 'Server service' }, ] -const initialFormState = { +const initialFormState: CodegenRequest = { projectName: 'nebula-launch', packageId: 'codegen_studio', runtime: 'web', @@ -32,51 +35,11 @@ const initialFormState = { brief: 'Modern web interface with CLI companions', } -type FormState = (typeof initialFormState) - -type FetchStatus = 'idle' | 'loading' | 'success' - -const createFilename = (header: string | null, fallback: string) => { - const match = header?.match(/filename="?([^"]+)"?/) ?? null - return match ? match[1] : fallback -} - -const downloadBlob = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = filename - document.body.appendChild(anchor) - anchor.click() - anchor.remove() - URL.revokeObjectURL(url) -} - -const fetchZip = async (values: FormState) => { - const response = await fetch('/api/codegen/studio', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(values), - }) - if (!response.ok) { - throw new Error('Codegen Studio service returned an error') - } - const blob = await response.blob() - const filename = createFilename(response.headers.get('content-disposition'), `${values.projectName}.zip`) - downloadBlob(blob, filename) - const manifestHeader = response.headers.get('x-codegen-manifest') - const manifest = manifestHeader - ? (JSON.parse(decodeURIComponent(manifestHeader)) as CodegenManifest) - : null - return { filename, manifest } -} +type FormState = typeof initialFormState export default function CodegenStudioClient() { const [form, setForm] = useState(initialFormState) - const [status, setStatus] = useState('idle') - const [message, setMessage] = useState(null) - const [error, setError] = useState(null) - const [manifest, setManifest] = useState(null) + const { status, message, error, manifest, generate } = useCodegenData() const runtimeDescription = useMemo(() => { switch (form.runtime) { @@ -112,125 +75,62 @@ export default function CodegenStudioClient() { setForm((prev) => ({ ...prev, [key]: event.target.value })) } - const handleSubmit = async () => { - setStatus('loading') - setError(null) - setMessage(null) - try { - const { filename, manifest } = await fetchZip(form) - setMessage(`Zip ${filename} created successfully.`) - setManifest(manifest) - setStatus('success') - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to generate the zip') - setManifest(null) - setStatus('idle') - } - } + const handleSubmit = () => generate(form) return ( - - - - - Codegen Studio Export - - - Configure a starter bundle for MetaBuilder packages and download it instantly. - - - - - + + +
- - - {runtimeOptions.map((option) => ( - - {option.label} - - ))} - - - {runtimeDescription} - - - - - - - - {message && {message}} - {error && {error}} - {manifest && ( - - - Manifest preview + + + + + + {runtimeOptions.map((option) => ( + + {option.label} + + ))} + + + {runtimeDescription} - - - Project: {manifest.projectName} - - - Package: {manifest.packageId} - - - Runtime: {manifest.runtime} - - - Tone: {manifest.tone ?? 'adaptive'} - - - Generated at: {new Date(manifest.generatedAt).toLocaleString()} - - - - )} - - Bundle contents - {previewFiles.map((entry) => ( - - • {entry} - - ))} + + + + + + + {message && {message}} + {error && {error}} + + + + diff --git a/frontends/nextjs/src/app/codegen/components/Header.tsx b/frontends/nextjs/src/app/codegen/components/Header.tsx new file mode 100644 index 000000000..29dcd934f --- /dev/null +++ b/frontends/nextjs/src/app/codegen/components/Header.tsx @@ -0,0 +1,21 @@ +'use client' + +import { Stack, Typography } from '@mui/material' + +interface HeaderProps { + title: string + subtitle: string +} + +export default function Header({ title, subtitle }: HeaderProps) { + return ( + + + {title} + + + {subtitle} + + + ) +} diff --git a/frontends/nextjs/src/app/codegen/components/Sidebar.tsx b/frontends/nextjs/src/app/codegen/components/Sidebar.tsx new file mode 100644 index 000000000..27172ca38 --- /dev/null +++ b/frontends/nextjs/src/app/codegen/components/Sidebar.tsx @@ -0,0 +1,51 @@ +'use client' + +import type { CodegenManifest } from '@/lib/codegen/codegen-types' +import { Paper, Stack, Typography } from '@mui/material' + +interface SidebarProps { + manifest: CodegenManifest | null + previewFiles: string[] +} + +export default function Sidebar({ manifest, previewFiles }: SidebarProps) { + return ( + + {manifest && ( + + + Manifest preview + + + + Project: {manifest.projectName} + + + Package: {manifest.packageId} + + + Runtime: {manifest.runtime} + + + Tone: {manifest.tone ?? 'adaptive'} + + + Generated at: {new Date(manifest.generatedAt).toLocaleString()} + + + + )} + + Bundle contents + {previewFiles.map((entry) => ( + + • {entry} + + ))} + + + ) +} diff --git a/frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts b/frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts new file mode 100644 index 000000000..61d831c7c --- /dev/null +++ b/frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts @@ -0,0 +1,74 @@ +'use client' + +import type { CodegenManifest } from '@/lib/codegen/codegen-types' +import { useCallback, useState } from 'react' + +export type CodegenRequest = { + projectName: string + packageId: string + runtime: string + tone: string + brief: string +} + +export type FetchStatus = 'idle' | 'loading' | 'success' + +const createFilename = (header: string | null, fallback: string) => { + const match = header?.match(/filename="?([^"]+)"?/) ?? null + return match ? match[1] : fallback +} + +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) +} + +const fetchZip = async (values: CodegenRequest) => { + const response = await fetch('/api/codegen/studio', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(values), + }) + if (!response.ok) { + throw new Error('Codegen Studio service returned an error') + } + const blob = await response.blob() + const filename = createFilename(response.headers.get('content-disposition'), `${values.projectName}.zip`) + downloadBlob(blob, filename) + const manifestHeader = response.headers.get('x-codegen-manifest') + const manifest = manifestHeader + ? (JSON.parse(decodeURIComponent(manifestHeader)) as CodegenManifest) + : null + return { filename, manifest } +} + +export function useCodegenData() { + const [status, setStatus] = useState('idle') + const [message, setMessage] = useState(null) + const [error, setError] = useState(null) + const [manifest, setManifest] = useState(null) + + const generate = useCallback(async (values: CodegenRequest) => { + setStatus('loading') + setError(null) + setMessage(null) + try { + const { filename, manifest: manifestResult } = await fetchZip(values) + setMessage(`Zip ${filename} created successfully.`) + setManifest(manifestResult) + setStatus('success') + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to generate the zip') + setManifest(null) + setStatus('idle') + } + }, []) + + return { status, message, error, manifest, generate } +} diff --git a/frontends/nextjs/src/components/auth/god-credentials/Form.tsx b/frontends/nextjs/src/components/auth/god-credentials/Form.tsx new file mode 100644 index 000000000..88700c036 --- /dev/null +++ b/frontends/nextjs/src/components/auth/god-credentials/Form.tsx @@ -0,0 +1,83 @@ +import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui' + +export interface GodCredentialsFormProps { + duration: number + unit: 'minutes' | 'hours' + onDurationChange: (value: number) => void + onUnitChange: (unit: 'minutes' | 'hours') => void + onSave: () => void + onResetExpiry: () => void + onClearExpiry: () => void +} + +export function GodCredentialsForm({ + duration, + unit, + onDurationChange, + onUnitChange, + onSave, + onResetExpiry, + onClearExpiry, +}: GodCredentialsFormProps) { + return ( +
+
+
+ +
+ onDurationChange(Number(e.target.value))} + className="flex-1" + /> + +
+

+ Set the duration for how long credentials are visible (1 minute to 24 hours) +

+
+ +
+ +
+
+ +
+
+ +

+ Reset or clear the current expiry timer +

+
+ +
+ + +
+ +

+ Reset Timer: Restart the countdown using the configured duration
+ Clear Expiry: Remove expiry time (credentials will show on next page load) +

+
+
+ ) +} diff --git a/frontends/nextjs/src/components/auth/god-credentials/Summary.tsx b/frontends/nextjs/src/components/auth/god-credentials/Summary.tsx new file mode 100644 index 000000000..84cba9aa9 --- /dev/null +++ b/frontends/nextjs/src/components/auth/god-credentials/Summary.tsx @@ -0,0 +1,42 @@ +import { Alert, AlertDescription, Badge } from '@/components/ui' +import { CheckCircle, WarningCircle } from '@phosphor-icons/react' + +export interface GodCredentialsSummaryProps { + isActive: boolean + expiryTime: number + timeRemaining: string +} + +export function GodCredentialsSummary({ isActive, expiryTime, timeRemaining }: GodCredentialsSummaryProps) { + if (isActive) { + return ( + + + +
+

+ God credentials are currently visible + Active +

+

+ Time remaining: {timeRemaining} +

+
+
+
+ ) + } + + if (!isActive && expiryTime > 0) { + return ( + + + +

God credentials have expired or been hidden

+
+
+ ) + } + + return null +} diff --git a/frontends/nextjs/src/components/auth/unified-login/LoginForm.tsx b/frontends/nextjs/src/components/auth/unified-login/LoginForm.tsx new file mode 100644 index 000000000..9e3a81491 --- /dev/null +++ b/frontends/nextjs/src/components/auth/unified-login/LoginForm.tsx @@ -0,0 +1,48 @@ +import { Button, Input, Label, Alert, AlertDescription } from '@/components/ui' +import { SignIn } from '@phosphor-icons/react' + +export interface LoginFormProps { + username: string + password: string + onUsernameChange: (value: string) => void + onPasswordChange: (value: string) => void + onSubmit: () => void +} + +export function LoginForm({ username, password, onUsernameChange, onPasswordChange, onSubmit }: LoginFormProps) { + return ( +
+
+ + onUsernameChange(e.target.value)} + placeholder="Enter username" + onKeyDown={(e) => e.key === 'Enter' && onSubmit()} + /> +
+
+ + onPasswordChange(e.target.value)} + placeholder="Enter password" + onKeyDown={(e) => e.key === 'Enter' && onSubmit()} + /> +
+ + + +

Test Credentials:

+

Check browser console for default user passwords (they are scrambled on first run)

+
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/auth/unified-login/ProviderList.tsx b/frontends/nextjs/src/components/auth/unified-login/ProviderList.tsx new file mode 100644 index 000000000..3bfd01527 --- /dev/null +++ b/frontends/nextjs/src/components/auth/unified-login/ProviderList.tsx @@ -0,0 +1,50 @@ +import { Button, Separator } from '@/components/ui' +import { GoogleLogo, GithubLogo, IconProps } from '@phosphor-icons/react' + +export interface Provider { + name: string + description?: string + icon?: React.ComponentType +} + +export interface ProviderListProps { + providers: Provider[] + onSelect?: (provider: Provider) => void +} + +const FALLBACK_PROVIDERS: Provider[] = [ + { name: 'Google', description: 'Use your Google Workspace account', icon: GoogleLogo }, + { name: 'GitHub', description: 'Developer SSO via GitHub', icon: GithubLogo }, +] + +export function ProviderList({ providers, onSelect }: ProviderListProps) { + const entries = providers.length > 0 ? providers : FALLBACK_PROVIDERS + + return ( +
+ +

Or continue with

+
+ {entries.map((provider) => { + const Icon = provider.icon + return ( + + ) + })} +
+
+ ) +} 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" - /> -
-
- ))} -
-
- ))} + +
- - - -
- - - Card Example - This is a card description - - -

Card content with muted text

-
-
-
- +
diff --git a/frontends/nextjs/src/components/editors/json/SchemaSection.tsx b/frontends/nextjs/src/components/editors/json/SchemaSection.tsx new file mode 100644 index 000000000..b77ca8486 --- /dev/null +++ b/frontends/nextjs/src/components/editors/json/SchemaSection.tsx @@ -0,0 +1,26 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' + +interface SchemaSectionProps { + schema?: unknown +} + +export function SchemaSection({ schema }: SchemaSectionProps) { + if (!schema) return null + + const formattedSchema = + typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2) + + return ( + + + Schema + Reference for the expected JSON structure + + +
+          {formattedSchema}
+        
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/json/Toolbar.tsx b/frontends/nextjs/src/components/editors/json/Toolbar.tsx new file mode 100644 index 000000000..a642ce6fc --- /dev/null +++ b/frontends/nextjs/src/components/editors/json/Toolbar.tsx @@ -0,0 +1,31 @@ +import { Button, DialogFooter } from '@/components/ui' +import { FloppyDisk, ShieldCheck, X } from '@phosphor-icons/react' + +interface ToolbarProps { + onScan: () => void + onFormat: () => void + onCancel: () => void + onSave: () => void +} + +export function Toolbar({ onScan, onFormat, onCancel, onSave }: ToolbarProps) { + return ( + + + + + + + ) +} 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 ( + + + + + + + + + + + + + + + } + /> + + +
{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} - - - - - ))} - - - ))} - - - - ) - - 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 = () => ( - - - - - - - - {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()} + + + + + + + + {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} + + + + + ))} + + + ))} + + + - {renderWorkspace()} + - - - - - - - - - - - - - - - - - - - } - /> - - -
{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} - - )} -
-
- - {onInsertSnippet && ( - - )} -
-
-
- )) - )} -
-
- ))} +
- setSelectedSnippet(null)}> - - -
-
- {selectedSnippet?.name} - {selectedSnippet?.description} -
- {selectedSnippet?.category} -
-
- -
- {selectedSnippet?.tags && selectedSnippet.tags.length > 0 && ( -
- {selectedSnippet.tags.map((tag) => ( - - - {tag} - - ))} -
- )} - - {selectedSnippet?.parameters && selectedSnippet.parameters.length > 0 && ( -
-

- - Parameters -

-
- {selectedSnippet.parameters.map((param) => ( -
-
- - {param.name} - - - {param.type} - -
-

{param.description}

-
- ))} -
-
- )} - - - -
-

Code

-
-
-                  {selectedSnippet?.code}
-                
-
-
- -
- - {onInsertSnippet && ( - - )} -
-
-
-
+ { + handleInsertSnippet(snippet) + setSelectedSnippet(null) + } + : undefined + } + onClose={() => setSelectedSnippet(null)} + /> ) } diff --git a/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SearchBar.tsx b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SearchBar.tsx new file mode 100644 index 000000000..5be89b841 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SearchBar.tsx @@ -0,0 +1,44 @@ +import { MagnifyingGlass } from '@phosphor-icons/react' +import { Input, ScrollArea, TabsList, TabsTrigger } from '@/components/ui' +import { LUA_SNIPPET_CATEGORIES } from '@/lib/lua-snippets' + +interface SearchBarProps { + searchQuery: string + onSearchChange: (value: string) => void + selectedCategory: string + onCategoryChange: (category: string) => void +} + +export function SearchBar({ + searchQuery, + onSearchChange, + selectedCategory, + onCategoryChange, +}: SearchBarProps) { + return ( +
+
+ + onSearchChange(e.target.value)} + className="pl-10" + /> +
+ + + + {LUA_SNIPPET_CATEGORIES.map((category) => ( + + {category} + + ))} + + +
+ ) +} diff --git a/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetDialog.tsx b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetDialog.tsx new file mode 100644 index 000000000..0111ba403 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/LuaSnippetLibrary/SnippetDialog.tsx @@ -0,0 +1,116 @@ +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Separator, +} from '@/components/ui' +import { ArrowRight, Check, Code, Copy, Tag } from '@phosphor-icons/react' +import { type LuaSnippet } from '@/lib/lua-snippets' + +interface SnippetDialogProps { + snippet: LuaSnippet | null + copiedId: string | null + onCopy: (snippet: LuaSnippet) => void + onInsert?: (snippet: LuaSnippet) => void + onClose: () => void +} + +export function SnippetDialog({ + snippet, + copiedId, + onCopy, + onInsert, + onClose, +}: SnippetDialogProps) { + return ( + !isOpen && onClose()}> + + +
+
+ {snippet?.name} + {snippet?.description} +
+ {snippet?.category} +
+
+ +
+ {snippet?.tags && snippet.tags.length > 0 && ( +
+ {snippet.tags.map((tag) => ( + + + {tag} + + ))} +
+ )} + + {snippet?.parameters && snippet.parameters.length > 0 && ( +
+

+ + Parameters +

+
+ {snippet.parameters.map((param) => ( +
+
+ {param.name} + + {param.type} + +
+

{param.description}

+
+ ))} +
+
+ )} + + + +
+

Code

+
+
+                {snippet?.code}
+              
+
+
+ +
+ + {onInsert && ( + + )} +
+
+
+
+ ) +} 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} + + )} +
+
+ + {onInsertSnippet && ( + + )} +
+
+
+ )) + )} +
+
+ ))} + + ) +} diff --git a/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts b/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts index 04143e9b2..786b6f586 100644 --- a/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts +++ b/frontends/nextjs/src/components/editors/lua/blocks/grouping.ts @@ -1,14 +1,15 @@ import type { BlockCategory, BlockDefinition } from '../types' -export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => { - const categories: Record = { - Basics: [], - Logic: [], - Loops: [], - Data: [], - Functions: [], - } +const createCategoryIndex = (): Record => ({ + Basics: [], + Logic: [], + Loops: [], + Data: [], + Functions: [], +}) +export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => { + const categories = createCategoryIndex() definitions.forEach((definition) => { categories[definition.category].push(definition) }) diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlockEditorState.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlockEditorState.ts new file mode 100644 index 000000000..618b5f491 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlockEditorState.ts @@ -0,0 +1,26 @@ +import type { LuaScript } from '@/lib/level-types' +import { useBlockDefinitions } from './useBlockDefinitions' +import { useLuaBlocksState } from './useLuaBlocksState' + +interface UseLuaBlockEditorStateProps { + scripts: LuaScript[] + onScriptsChange: (scripts: LuaScript[]) => void +} + +export function useLuaBlockEditorState({ scripts, onScriptsChange }: UseLuaBlockEditorStateProps) { + const blockDefinitionState = useBlockDefinitions() + + const luaBlockState = useLuaBlocksState({ + scripts, + onScriptsChange, + buildLuaFromBlocks: blockDefinitionState.buildLuaFromBlocks, + createBlock: blockDefinitionState.createBlock, + cloneBlock: blockDefinitionState.cloneBlock, + decodeBlocksMetadata: blockDefinitionState.decodeBlocksMetadata, + }) + + return { + ...blockDefinitionState, + ...luaBlockState, + } +} diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState.ts index 4c671447b..4f844dd02 100644 --- a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState.ts +++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState.ts @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useState, type MouseEvent } from 'react' -import { toast } from 'sonner' +import { useEffect, useMemo, useState } from 'react' import type { LuaScript } from '@/lib/level-types' -import type { BlockSlot, LuaBlock, LuaBlockType } from '../types' +import type { LuaBlock, LuaBlockType } from '../types' +import { createLuaBlocksActions, type MenuTarget } from './useLuaBlocksState/actions' +import { selectActiveBlocks, selectSelectedScript } from './useLuaBlocksState/selectors' interface UseLuaBlocksStateProps { scripts: LuaScript[] @@ -12,108 +13,6 @@ interface UseLuaBlocksStateProps { decodeBlocksMetadata: (code: string) => LuaBlock[] | null } -interface MenuTarget { - parentId: string | null - slot: BlockSlot -} - -const addBlockToTree = ( - blocks: LuaBlock[], - parentId: string | null, - slot: BlockSlot, - newBlock: LuaBlock -): LuaBlock[] => { - if (slot === 'root' || !parentId) { - return [...blocks, newBlock] - } - - return blocks.map((block) => { - if (block.id === parentId) { - const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? [] - const updated = [...current, newBlock] - if (slot === 'children') { - return { ...block, children: updated } - } - return { ...block, elseChildren: updated } - } - - const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children - const elseChildren = block.elseChildren - ? addBlockToTree(block.elseChildren, parentId, slot, newBlock) - : block.elseChildren - - if (children !== block.children || elseChildren !== block.elseChildren) { - return { ...block, children, elseChildren } - } - - return block - }) -} - -const updateBlockInTree = ( - blocks: LuaBlock[], - blockId: string, - updater: (block: LuaBlock) => LuaBlock -): LuaBlock[] => - blocks.map((block) => { - if (block.id === blockId) { - return updater(block) - } - - const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children - const elseChildren = block.elseChildren - ? updateBlockInTree(block.elseChildren, blockId, updater) - : block.elseChildren - - if (children !== block.children || elseChildren !== block.elseChildren) { - return { ...block, children, elseChildren } - } - - return block - }) - -const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] => - blocks - .filter((block) => block.id !== blockId) - .map((block) => { - const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children - const elseChildren = block.elseChildren - ? removeBlockFromTree(block.elseChildren, blockId) - : block.elseChildren - - if (children !== block.children || elseChildren !== block.elseChildren) { - return { ...block, children, elseChildren } - } - - return block - }) - -const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => { - const index = blocks.findIndex((block) => block.id === blockId) - if (index !== -1) { - const targetIndex = direction === 'up' ? index - 1 : index + 1 - if (targetIndex < 0 || targetIndex >= blocks.length) return blocks - - const updated = [...blocks] - const [moved] = updated.splice(index, 1) - updated.splice(targetIndex, 0, moved) - return updated - } - - return blocks.map((block) => { - const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children - const elseChildren = block.elseChildren - ? moveBlockInTree(block.elseChildren, blockId, direction) - : block.elseChildren - - if (children !== block.children || elseChildren !== block.elseChildren) { - return { ...block, children, elseChildren } - } - - return block - }) -} - export function useLuaBlocksState({ scripts, onScriptsChange, @@ -156,178 +55,35 @@ export function useLuaBlocksState({ })) }, [blocksByScript, decodeBlocksMetadata, scripts, selectedScriptId]) - const selectedScript = scripts.find((script) => script.id === selectedScriptId) || null - const activeBlocks = selectedScriptId ? blocksByScript[selectedScriptId] || [] : [] + const selectedScript = selectSelectedScript(scripts, selectedScriptId) + const activeBlocks = selectActiveBlocks(blocksByScript, selectedScriptId) const generatedCode = useMemo(() => buildLuaFromBlocks(activeBlocks), [activeBlocks, buildLuaFromBlocks]) - const handleAddScript = () => { - const starterBlocks = [createBlock('log')] - const newScript: LuaScript = { - id: `lua_${Date.now()}`, - name: 'Block Script', - description: 'Built with Lua blocks', - code: buildLuaFromBlocks(starterBlocks), - parameters: [], - } - - onScriptsChange([...scripts, newScript]) - setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks })) - setSelectedScriptId(newScript.id) - toast.success('Block script created') - } - - const handleDeleteScript = (scriptId: string) => { - const remaining = scripts.filter((script) => script.id !== scriptId) - onScriptsChange(remaining) - - setBlocksByScript((prev) => { - const { [scriptId]: _, ...rest } = prev - return rest - }) - - if (selectedScriptId === scriptId) { - setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null) - } - - toast.success('Script deleted') - } - - const handleUpdateScript = (updates: Partial) => { - if (!selectedScript) return - onScriptsChange( - scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script)) - ) - } - - const handleApplyCode = () => { - if (!selectedScript) return - handleUpdateScript({ code: generatedCode }) - toast.success('Lua code updated from blocks') - } - - const handleCopyCode = async () => { - try { - await navigator.clipboard.writeText(generatedCode) - toast.success('Lua code copied to clipboard') - } catch (error) { - toast.error('Unable to copy code') - } - } - - const handleReloadFromCode = () => { - if (!selectedScript) return - const parsed = decodeBlocksMetadata(selectedScript.code) - if (!parsed) { - toast.warning('No block metadata found in this script') - return - } - setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed })) - toast.success('Blocks loaded from script') - } - - const handleRequestAddBlock = ( - event: MouseEvent, - target: { parentId: string | null; slot: BlockSlot } - ) => { - setMenuAnchor(event.currentTarget) - setMenuTarget(target) - } - - const handleAddBlock = (type: LuaBlockType, target?: { parentId: string | null; slot: BlockSlot }) => { - const resolvedTarget = target ?? menuTarget - if (!selectedScriptId || !resolvedTarget) return - - const newBlock = createBlock(type) - setBlocksByScript((prev) => ({ - ...prev, - [selectedScriptId]: addBlockToTree( - prev[selectedScriptId] || [], - resolvedTarget.parentId, - resolvedTarget.slot, - newBlock - ), - })) - - setMenuAnchor(null) - setMenuTarget(null) - } - - const handleCloseMenu = () => { - setMenuAnchor(null) - setMenuTarget(null) - } - - const handleUpdateField = (blockId: string, fieldName: string, value: string) => { - if (!selectedScriptId) return - setBlocksByScript((prev) => ({ - ...prev, - [selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({ - ...block, - fields: { - ...block.fields, - [fieldName]: value, - }, - })), - })) - } - - const handleRemoveBlock = (blockId: string) => { - if (!selectedScriptId) return - setBlocksByScript((prev) => ({ - ...prev, - [selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId), - })) - } - - const handleDuplicateBlock = (blockId: string) => { - if (!selectedScriptId) return - - setBlocksByScript((prev) => { - const blocks = prev[selectedScriptId] || [] - let duplicated: LuaBlock | null = null - - const updated = updateBlockInTree(blocks, blockId, (block) => { - duplicated = cloneBlock(block) - return block - }) - - if (!duplicated) return prev - - return { - ...prev, - [selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated), - } - }) - } - - const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => { - if (!selectedScriptId) return - setBlocksByScript((prev) => ({ - ...prev, - [selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction), - })) - } + const actions = createLuaBlocksActions({ + scripts, + selectedScript, + selectedScriptId, + generatedCode, + menuTarget, + buildLuaFromBlocks, + createBlock, + cloneBlock, + decodeBlocksMetadata, + onScriptsChange, + setBlocksByScript, + setMenuAnchor, + setMenuTarget, + setSelectedScriptId, + }) return { activeBlocks, generatedCode, - handleAddBlock, - handleAddScript, - handleApplyCode, - handleCloseMenu, - handleCopyCode, - handleDeleteScript, - handleDuplicateBlock, - handleMoveBlock, - handleReloadFromCode, - handleRemoveBlock, - handleRequestAddBlock, - handleUpdateField, - handleUpdateScript, menuAnchor, menuTarget, selectedScript, selectedScriptId, setSelectedScriptId, + ...actions, } } diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/actions.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/actions.ts new file mode 100644 index 000000000..f03cce58a --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/actions.ts @@ -0,0 +1,208 @@ +import type { Dispatch, MouseEvent, SetStateAction } from 'react' +import { toast } from 'sonner' +import type { LuaScript } from '@/lib/level-types' +import type { BlockSlot, LuaBlock, LuaBlockType } from '../../types' +import { addBlockToTree, moveBlockInTree, removeBlockFromTree, updateBlockInTree } from './storage' + +export interface MenuTarget { + parentId: string | null + slot: BlockSlot +} + +interface LuaBlocksActionConfig { + scripts: LuaScript[] + selectedScript: LuaScript | null + selectedScriptId: string | null + generatedCode: string + menuTarget: MenuTarget | null + buildLuaFromBlocks: (blocks: LuaBlock[]) => string + createBlock: (type: LuaBlockType) => LuaBlock + cloneBlock: (block: LuaBlock) => LuaBlock + decodeBlocksMetadata: (code: string) => LuaBlock[] | null + onScriptsChange: (scripts: LuaScript[]) => void + setBlocksByScript: Dispatch>> + setMenuAnchor: Dispatch> + setMenuTarget: Dispatch> + setSelectedScriptId: Dispatch> +} + +export const createLuaBlocksActions = ({ + scripts, + selectedScript, + selectedScriptId, + generatedCode, + menuTarget, + buildLuaFromBlocks, + createBlock, + cloneBlock, + decodeBlocksMetadata, + onScriptsChange, + setBlocksByScript, + setMenuAnchor, + setMenuTarget, + setSelectedScriptId, +}: LuaBlocksActionConfig) => { + const handleAddScript = () => { + const starterBlocks = [createBlock('log')] + const newScript: LuaScript = { + id: `lua_${Date.now()}`, + name: 'Block Script', + description: 'Built with Lua blocks', + code: buildLuaFromBlocks(starterBlocks), + parameters: [], + } + + onScriptsChange([...scripts, newScript]) + setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks })) + setSelectedScriptId(newScript.id) + toast.success('Block script created') + } + + const handleDeleteScript = (scriptId: string) => { + const remaining = scripts.filter((script) => script.id !== scriptId) + onScriptsChange(remaining) + + setBlocksByScript((prev) => { + const { [scriptId]: _, ...rest } = prev + return rest + }) + + if (selectedScriptId === scriptId) { + setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null) + } + + toast.success('Script deleted') + } + + const handleUpdateScript = (updates: Partial) => { + if (!selectedScript) return + onScriptsChange( + scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script)) + ) + } + + const handleApplyCode = () => { + if (!selectedScript) return + handleUpdateScript({ code: generatedCode }) + toast.success('Lua code updated from blocks') + } + + const handleCopyCode = async () => { + try { + await navigator.clipboard.writeText(generatedCode) + toast.success('Lua code copied to clipboard') + } catch (error) { + toast.error('Unable to copy code') + } + } + + const handleReloadFromCode = () => { + if (!selectedScript) return + const parsed = decodeBlocksMetadata(selectedScript.code) + if (!parsed) { + toast.warning('No block metadata found in this script') + return + } + setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed })) + toast.success('Blocks loaded from script') + } + + const handleRequestAddBlock = ( + event: MouseEvent, + target: { parentId: string | null; slot: BlockSlot } + ) => { + setMenuAnchor(event.currentTarget) + setMenuTarget(target) + } + + const handleAddBlock = (type: LuaBlockType, target?: MenuTarget) => { + const resolvedTarget = target ?? menuTarget + if (!selectedScriptId || !resolvedTarget) return + + const newBlock = createBlock(type) + setBlocksByScript((prev) => ({ + ...prev, + [selectedScriptId]: addBlockToTree( + prev[selectedScriptId] || [], + resolvedTarget.parentId, + resolvedTarget.slot, + newBlock + ), + })) + + setMenuAnchor(null) + setMenuTarget(null) + } + + const handleCloseMenu = () => { + setMenuAnchor(null) + setMenuTarget(null) + } + + const handleUpdateField = (blockId: string, fieldName: string, value: string) => { + if (!selectedScriptId) return + setBlocksByScript((prev) => ({ + ...prev, + [selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({ + ...block, + fields: { + ...block.fields, + [fieldName]: value, + }, + })), + })) + } + + const handleRemoveBlock = (blockId: string) => { + if (!selectedScriptId) return + setBlocksByScript((prev) => ({ + ...prev, + [selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId), + })) + } + + const handleDuplicateBlock = (blockId: string) => { + if (!selectedScriptId) return + + setBlocksByScript((prev) => { + const blocks = prev[selectedScriptId] || [] + let duplicated: LuaBlock | null = null + + const updated = updateBlockInTree(blocks, blockId, (block) => { + duplicated = cloneBlock(block) + return block + }) + + if (!duplicated) return prev + + return { + ...prev, + [selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated), + } + }) + } + + const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => { + if (!selectedScriptId) return + setBlocksByScript((prev) => ({ + ...prev, + [selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction), + })) + } + + return { + handleAddBlock, + handleAddScript, + handleApplyCode, + handleCloseMenu, + handleCopyCode, + handleDeleteScript, + handleDuplicateBlock, + handleMoveBlock, + handleReloadFromCode, + handleRemoveBlock, + handleRequestAddBlock, + handleUpdateField, + handleUpdateScript, + } +} diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/selectors.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/selectors.ts new file mode 100644 index 000000000..a669f3f78 --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/selectors.ts @@ -0,0 +1,12 @@ +import type { LuaScript } from '@/lib/level-types' +import type { LuaBlock } from '../../types' + +export const selectSelectedScript = ( + scripts: LuaScript[], + selectedScriptId: string | null +): LuaScript | null => scripts.find((script) => script.id === selectedScriptId) || null + +export const selectActiveBlocks = ( + blocksByScript: Record, + selectedScriptId: string | null +): LuaBlock[] => (selectedScriptId ? blocksByScript[selectedScriptId] || [] : []) diff --git a/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/storage.ts b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/storage.ts new file mode 100644 index 000000000..c26c7a31b --- /dev/null +++ b/frontends/nextjs/src/components/editors/lua/hooks/useLuaBlocksState/storage.ts @@ -0,0 +1,98 @@ +import type { BlockSlot, LuaBlock } from '../../types' + +export const addBlockToTree = ( + blocks: LuaBlock[], + parentId: string | null, + slot: BlockSlot, + newBlock: LuaBlock +): LuaBlock[] => { + if (slot === 'root' || !parentId) { + return [...blocks, newBlock] + } + + return blocks.map((block) => { + if (block.id === parentId) { + const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? [] + const updated = [...current, newBlock] + if (slot === 'children') { + return { ...block, children: updated } + } + return { ...block, elseChildren: updated } + } + + const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children + const elseChildren = block.elseChildren + ? addBlockToTree(block.elseChildren, parentId, slot, newBlock) + : block.elseChildren + + if (children !== block.children || elseChildren !== block.elseChildren) { + return { ...block, children, elseChildren } + } + + return block + }) +} + +export const updateBlockInTree = ( + blocks: LuaBlock[], + blockId: string, + updater: (block: LuaBlock) => LuaBlock +): LuaBlock[] => + blocks.map((block) => { + if (block.id === blockId) { + return updater(block) + } + + const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children + const elseChildren = block.elseChildren + ? updateBlockInTree(block.elseChildren, blockId, updater) + : block.elseChildren + + if (children !== block.children || elseChildren !== block.elseChildren) { + return { ...block, children, elseChildren } + } + + return block + }) + +export const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] => + blocks + .filter((block) => block.id !== blockId) + .map((block) => { + const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children + const elseChildren = block.elseChildren + ? removeBlockFromTree(block.elseChildren, blockId) + : block.elseChildren + + if (children !== block.children || elseChildren !== block.elseChildren) { + return { ...block, children, elseChildren } + } + + return block + }) + +export const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => { + const index = blocks.findIndex((block) => block.id === blockId) + if (index !== -1) { + const targetIndex = direction === 'up' ? index - 1 : index + 1 + if (targetIndex < 0 || targetIndex >= blocks.length) return blocks + + const updated = [...blocks] + const [moved] = updated.splice(index, 1) + updated.splice(targetIndex, 0, moved) + return updated + } + + return blocks.map((block) => { + const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children + const elseChildren = block.elseChildren + ? moveBlockInTree(block.elseChildren, blockId, direction) + : block.elseChildren + + if (children !== block.children || elseChildren !== block.elseChildren) { + return { ...block, children, elseChildren } + } + + return block + }) +} diff --git a/frontends/nextjs/src/components/editors/schema/SchemaEditorLevel4.tsx b/frontends/nextjs/src/components/editors/schema/SchemaEditorLevel4.tsx index f6a56a682..7acbee432 100644 --- a/frontends/nextjs/src/components/editors/schema/SchemaEditorLevel4.tsx +++ b/frontends/nextjs/src/components/editors/schema/SchemaEditorLevel4.tsx @@ -1,19 +1,9 @@ -import { useState } from 'react' import { Button } from '@/components/ui' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Input } from '@/components/ui' -import { Label } from '@/components/ui' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui' -import { Switch } from '@/components/ui' +import { SchemaTabs } from '@/components/schema/level4/Tabs' +import { useSchemaLevel4 } from '@/components/schema/level4/useSchemaLevel4' +import type { ModelSchema } from '@/lib/schema-types' import { Plus, Trash } from '@phosphor-icons/react' -import { toast } from 'sonner' -import type { ModelSchema, FieldSchema, FieldType } from '@/lib/schema-types' interface SchemaEditorLevel4Props { schemas: ModelSchema[] @@ -21,74 +11,17 @@ interface SchemaEditorLevel4Props { } export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLevel4Props) { - const [selectedModel, setSelectedModel] = useState( - schemas.length > 0 ? schemas[0].name : null - ) - - const currentModel = schemas.find(s => s.name === selectedModel) - - const handleAddModel = () => { - const newModel: ModelSchema = { - name: `Model_${Date.now()}`, - label: 'New Model', - fields: [], - } - onSchemasChange([...schemas, newModel]) - setSelectedModel(newModel.name) - toast.success('Model created') - } - - const handleDeleteModel = (modelName: string) => { - onSchemasChange(schemas.filter(s => s.name !== modelName)) - if (selectedModel === modelName) { - setSelectedModel(schemas.length > 1 ? schemas[0].name : null) - } - toast.success('Model deleted') - } - - const handleUpdateModel = (updates: Partial) => { - if (!currentModel) return - - onSchemasChange( - schemas.map(s => s.name === selectedModel ? { ...s, ...updates } : s) - ) - } - - const handleAddField = () => { - if (!currentModel) return - - const newField: FieldSchema = { - name: `field_${Date.now()}`, - type: 'string', - label: 'New Field', - required: false, - editable: true, - } - - handleUpdateModel({ - fields: [...currentModel.fields, newField], - }) - toast.success('Field added') - } - - const handleDeleteField = (fieldName: string) => { - if (!currentModel) return - - handleUpdateModel({ - fields: currentModel.fields.filter(f => f.name !== fieldName), - }) - toast.success('Field deleted') - } - - const handleUpdateField = (fieldName: string, updates: Partial) => { - if (!currentModel) return - - handleUpdateModel({ - fields: currentModel.fields.map(f => - f.name === fieldName ? { ...f, ...updates } : f - ), - }) - } + const { + currentModel, + selectedModel, + selectModel, + handleAddField, + handleAddModel, + handleDeleteField, + handleDeleteModel, + handleUpdateField, + handleUpdateModel, + } = useSchemaLevel4({ schemas, onSchemasChange }) return (
@@ -117,7 +50,7 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev ? 'bg-accent border-accent-foreground' : 'hover:bg-muted border-border' }`} - onClick={() => setSelectedModel(schema.name)} + onClick={() => selectModel(schema.name)} >
{schema.label || schema.name}
@@ -150,179 +83,13 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev
) : ( - <> - - Edit Model: {currentModel.label} - Configure model properties and fields - - -
-
- - handleUpdateModel({ name: e.target.value })} - placeholder="user_model" - /> -
-
- - handleUpdateModel({ label: e.target.value })} - placeholder="User" - /> -
-
- - handleUpdateModel({ labelPlural: e.target.value })} - placeholder="Users" - /> -
-
- - handleUpdateModel({ icon: e.target.value })} - placeholder="Users" - /> -
-
- -
-
- - -
- -
- {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" - /> -
-
- -
- -
-
- - 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

+
+
+ + + + +
+ + + Card Example + This is a card description + + +

Card content with muted text

+
+
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/theme/constants.ts b/frontends/nextjs/src/components/editors/theme/constants.ts new file mode 100644 index 000000000..7e7b8f8b2 --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/constants.ts @@ -0,0 +1,41 @@ +import { ThemeColors } from './types' + +export const DEFAULT_LIGHT_THEME: ThemeColors = { + background: 'oklch(0.92 0.03 290)', + foreground: 'oklch(0.25 0.02 260)', + card: 'oklch(1 0 0)', + cardForeground: 'oklch(0.25 0.02 260)', + primary: 'oklch(0.55 0.18 290)', + primaryForeground: 'oklch(0.98 0 0)', + secondary: 'oklch(0.35 0.02 260)', + secondaryForeground: 'oklch(0.90 0.01 260)', + muted: 'oklch(0.95 0.02 290)', + mutedForeground: 'oklch(0.50 0.02 260)', + accent: 'oklch(0.70 0.17 195)', + accentForeground: 'oklch(0.2 0.02 260)', + destructive: 'oklch(0.55 0.22 25)', + destructiveForeground: 'oklch(0.98 0 0)', + border: 'oklch(0.85 0.02 290)', + input: 'oklch(0.85 0.02 290)', + ring: 'oklch(0.70 0.17 195)', +} + +export const DEFAULT_DARK_THEME: ThemeColors = { + background: 'oklch(0.145 0 0)', + foreground: 'oklch(0.985 0 0)', + card: 'oklch(0.205 0 0)', + cardForeground: 'oklch(0.985 0 0)', + primary: 'oklch(0.922 0 0)', + primaryForeground: 'oklch(0.205 0 0)', + secondary: 'oklch(0.269 0 0)', + secondaryForeground: 'oklch(0.985 0 0)', + muted: 'oklch(0.269 0 0)', + mutedForeground: 'oklch(0.708 0 0)', + accent: 'oklch(0.269 0 0)', + accentForeground: 'oklch(0.985 0 0)', + destructive: 'oklch(0.704 0.191 22.216)', + destructiveForeground: 'oklch(0.98 0 0)', + border: 'oklch(1 0 0 / 10%)', + input: 'oklch(1 0 0 / 15%)', + ring: 'oklch(0.556 0 0)', +} diff --git a/frontends/nextjs/src/components/editors/theme/types.ts b/frontends/nextjs/src/components/editors/theme/types.ts new file mode 100644 index 000000000..8440c06d8 --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/types.ts @@ -0,0 +1,25 @@ +export interface ThemeColors { + background: string + foreground: string + card: string + cardForeground: string + primary: string + primaryForeground: string + secondary: string + secondaryForeground: string + muted: string + mutedForeground: string + accent: string + accentForeground: string + destructive: string + destructiveForeground: string + border: string + input: string + ring: string +} + +export interface ThemeConfig { + light: ThemeColors + dark: ThemeColors + radius: string +} diff --git a/frontends/nextjs/src/components/examples/contact-form/FormConfig.ts b/frontends/nextjs/src/components/examples/contact-form/FormConfig.ts new file mode 100644 index 000000000..6fb66cf33 --- /dev/null +++ b/frontends/nextjs/src/components/examples/contact-form/FormConfig.ts @@ -0,0 +1,60 @@ +export type ContactFormFieldType = 'text' | 'email' | 'textarea' + +export interface ContactFormField { + name: 'name' | 'email' | 'message' + label: string + placeholder: string + type: ContactFormFieldType + required?: boolean + helperText?: string +} + +export interface ContactFormConfig { + title: string + description: string + submitLabel: string + successTitle: string + successMessage: string + fields: ContactFormField[] +} + +export const contactFormConfig: ContactFormConfig = { + title: 'Contact form', + description: 'Collect a name, email, and short message with simple validation.', + submitLabel: 'Send message', + successTitle: 'Message sent', + successMessage: 'Thanks for reaching out. We will get back to you shortly.', + fields: [ + { + name: 'name', + label: 'Name', + placeholder: 'Your name', + type: 'text', + required: true, + }, + { + name: 'email', + label: 'Email', + placeholder: 'you@example.com', + type: 'email', + required: true, + helperText: 'We will only use this to reply to your note.', + }, + { + name: 'message', + label: 'Message', + placeholder: 'How can we help?', + type: 'textarea', + required: true, + }, + ], +} + +export type ContactFormState = Record + +export function createInitialContactFormState(): ContactFormState { + return contactFormConfig.fields.reduce((state, field) => { + state[field.name] = '' + return state + }, {} as ContactFormState) +} diff --git a/frontends/nextjs/src/components/examples/contact-form/Preview.tsx b/frontends/nextjs/src/components/examples/contact-form/Preview.tsx new file mode 100644 index 000000000..b097be962 --- /dev/null +++ b/frontends/nextjs/src/components/examples/contact-form/Preview.tsx @@ -0,0 +1,145 @@ +import { ChangeEvent, FormEvent, useMemo, useState } from 'react' +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Textarea, +} from '@/components/ui' + +import { + contactFormConfig, + ContactFormField, + ContactFormState, + createInitialContactFormState, +} from './FormConfig' + +type ValidationErrors = Partial> + +function validateContactForm(values: ContactFormState): ValidationErrors { + const errors: ValidationErrors = {} + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + contactFormConfig.fields.forEach(field => { + const value = values[field.name]?.trim() ?? '' + + if (field.required && !value) { + errors[field.name] = `${field.label} is required` + return + } + + if (field.type === 'email' && value && !emailPattern.test(value)) { + errors[field.name] = 'Enter a valid email address' + } + }) + + return errors +} + +export function ContactFormPreview() { + const [formValues, setFormValues] = useState( + createInitialContactFormState() + ) + const [errors, setErrors] = useState({}) + const [submitted, setSubmitted] = useState(false) + + const hasErrors = useMemo(() => Object.keys(errors).length > 0, [errors]) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + + const validationErrors = validateContactForm(formValues) + + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors) + setSubmitted(false) + return + } + + setErrors({}) + setSubmitted(true) + setFormValues(createInitialContactFormState()) + setTimeout(() => setSubmitted(false), 3200) + } + + const renderField = (field: ContactFormField) => { + const commonProps = { + id: field.name, + name: field.name, + value: formValues[field.name], + onChange: (event: ChangeEvent) => { + const { value } = event.target + setFormValues(current => ({ ...current, [field.name]: value })) + }, + 'aria-describedby': errors[field.name] ? `${field.name}-error` : undefined, + placeholder: field.placeholder, + } + + if (field.type === 'textarea') { + return ( +