mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 14:25:02 +00:00
Compare commits
69 Commits
codex/crea
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ab7aac63e | |||
| 1f7c2e637e | |||
| 9c354fdac5 | |||
| f57b41f86d | |||
| 1e9a6271ea | |||
| 7989c700b9 | |||
| 02e7188b20 | |||
| 1523cf735c | |||
| adedf5f70c | |||
| c069bd0540 | |||
| 871b84ebf4 | |||
| db8c01de1b | |||
| 85afb870e8 | |||
| 57a6bd32d6 | |||
| afacdb82cc | |||
| b9350f0da9 | |||
| 4f2bff3a47 | |||
| de605d4809 | |||
| 67c7509bb9 | |||
| ecd04fa1a0 | |||
| f00d345fe8 | |||
| d161f0f9cd | |||
| a72299176c | |||
| a26666199c | |||
| 7932581ec3 | |||
| 4d8394acc0 | |||
| 704c1bca86 | |||
| ee76be73f2 | |||
| e0c556c279 | |||
| 73a53c4715 | |||
| 6d4b786150 | |||
| 7c061b43ca | |||
| adcd9c69de | |||
| 4bd98918cc | |||
| 97d461b667 | |||
| d322e425cb | |||
| 7ae32965cf | |||
| c0f1b5af14 | |||
| a7fde7cd0d | |||
| cea8211297 | |||
| 66f9d2cfe6 | |||
| 366ffb5de9 | |||
| e848a7bac5 | |||
| b10bef82a9 | |||
| 1e3dff83fa | |||
| 901a5438dd | |||
| d84c55cfe1 | |||
| 9331a1b7f7 | |||
| bcac86fce9 | |||
| 824a1f4487 | |||
| af4a2246c0 | |||
| fcd0e55125 | |||
| 4b3d5f4043 | |||
| a47085dc67 | |||
| 756c48fc83 | |||
| ac45fb171c | |||
| 7562c4184d | |||
| fcd7322861 | |||
| 7a64fa6b7e | |||
| 9d3a39f6cc | |||
| d9a8e75fbf | |||
| 5cb1e9f63e | |||
| 53d365f07d | |||
| a320a85353 | |||
| 01ae4c753f | |||
| c04d8923b3 | |||
| 658bd1e196 | |||
| 149ee90339 | |||
| eea561c225 |
@@ -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'
|
||||
|
||||
86
dbal/development/src/adapters/acl-adapter/acl-adapter.ts
Normal file
86
dbal/development/src/adapters/acl-adapter/acl-adapter.ts
Normal file
@@ -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<typeof createReadStrategy>
|
||||
private readonly writeStrategy: ReturnType<typeof createWriteStrategy>
|
||||
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
return this.writeStrategy.create(entity, data)
|
||||
}
|
||||
|
||||
async read(entity: string, id: string): Promise<unknown | null> {
|
||||
return this.readStrategy.read(entity, id)
|
||||
}
|
||||
|
||||
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
return this.writeStrategy.update(entity, id, data)
|
||||
}
|
||||
|
||||
async delete(entity: string, id: string): Promise<boolean> {
|
||||
return this.writeStrategy.delete(entity, id)
|
||||
}
|
||||
|
||||
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
|
||||
return this.readStrategy.list(entity, options)
|
||||
}
|
||||
|
||||
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
|
||||
return this.readStrategy.findFirst(entity, filter)
|
||||
}
|
||||
|
||||
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
|
||||
return this.readStrategy.findByField(entity, field, value)
|
||||
}
|
||||
|
||||
async upsert(
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return this.writeStrategy.upsert(entity, filter, createData, updateData)
|
||||
}
|
||||
|
||||
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
|
||||
return this.writeStrategy.updateByField(entity, field, value, data)
|
||||
}
|
||||
|
||||
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
|
||||
return this.writeStrategy.deleteByField(entity, field, value)
|
||||
}
|
||||
|
||||
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
return this.writeStrategy.createMany(entity, data)
|
||||
}
|
||||
|
||||
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
|
||||
return this.writeStrategy.updateMany(entity, filter, data)
|
||||
}
|
||||
|
||||
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
|
||||
return this.writeStrategy.deleteMany(entity, filter)
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AdapterCapabilities> {
|
||||
return this.context.baseAdapter.getCapabilities()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.context.baseAdapter.close()
|
||||
}
|
||||
}
|
||||
|
||||
export type { ACLAdapterOptions, ACLContext, ACLRule, User }
|
||||
export { defaultACLRules } from '../acl/default-rules'
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown>): Promise<unknown> {
|
||||
return createEntity(this.context)(entity, data)
|
||||
}
|
||||
|
||||
async read(entity: string, id: string): Promise<unknown | null> {
|
||||
return readEntity(this.context)(entity, id)
|
||||
}
|
||||
|
||||
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
return updateEntity(this.context)(entity, id, data)
|
||||
}
|
||||
|
||||
async delete(entity: string, id: string): Promise<boolean> {
|
||||
return deleteEntity(this.context)(entity, id)
|
||||
}
|
||||
|
||||
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
|
||||
return listEntities(this.context)(entity, options)
|
||||
}
|
||||
|
||||
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
|
||||
return findFirst(this.context)(entity, filter)
|
||||
}
|
||||
|
||||
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
|
||||
return findByField(this.context)(entity, field, value)
|
||||
}
|
||||
|
||||
async upsert(
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return upsert(this.context)(entity, filter, createData, updateData)
|
||||
}
|
||||
|
||||
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
|
||||
return updateByField(this.context)(entity, field, value, data)
|
||||
}
|
||||
|
||||
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
|
||||
return deleteByField(this.context)(entity, field, value)
|
||||
}
|
||||
|
||||
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
return createMany(this.context)(entity, data)
|
||||
}
|
||||
|
||||
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
|
||||
return updateMany(this.context)(entity, filter, data)
|
||||
}
|
||||
|
||||
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
|
||||
return deleteMany(this.context)(entity, filter)
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AdapterCapabilities> {
|
||||
return this.context.baseAdapter.getCapabilities()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
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'
|
||||
|
||||
48
dbal/development/src/adapters/acl-adapter/read-strategy.ts
Normal file
48
dbal/development/src/adapters/acl-adapter/read-strategy.ts
Normal file
@@ -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<unknown | null> => {
|
||||
return withAudit(context, entity, 'read', async () => {
|
||||
const result = await context.baseAdapter.read(entity, id)
|
||||
if (result) {
|
||||
enforceRowAccess(context, entity, 'read', result as Record<string, unknown>)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
const list = async (entity: string, options?: ListOptions): Promise<ListResult<unknown>> => {
|
||||
return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options))
|
||||
}
|
||||
|
||||
const findFirst = async (entity: string, filter?: Record<string, unknown>): Promise<unknown | null> => {
|
||||
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<string, unknown>)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
const findByField = async (entity: string, field: string, value: unknown): Promise<unknown | null> => {
|
||||
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<string, unknown>)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
list,
|
||||
findFirst,
|
||||
findByField,
|
||||
}
|
||||
}
|
||||
27
dbal/development/src/adapters/acl-adapter/types.ts
Normal file
27
dbal/development/src/adapters/acl-adapter/types.ts
Normal file
@@ -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<string, unknown>) => 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
|
||||
}
|
||||
83
dbal/development/src/adapters/acl-adapter/write-strategy.ts
Normal file
83
dbal/development/src/adapters/acl-adapter/write-strategy.ts
Normal file
@@ -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<string, unknown>): Promise<unknown> => {
|
||||
return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data))
|
||||
}
|
||||
|
||||
const update = async (entity: string, id: string, data: Record<string, unknown>): Promise<unknown> => {
|
||||
return withAudit(context, entity, 'update', async () => {
|
||||
const existing = await context.baseAdapter.read(entity, id)
|
||||
if (existing) {
|
||||
enforceRowAccess(context, entity, 'update', existing as Record<string, unknown>)
|
||||
}
|
||||
return context.baseAdapter.update(entity, id, data)
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (entity: string, id: string): Promise<boolean> => {
|
||||
return withAudit(context, entity, 'delete', async () => {
|
||||
const existing = await context.baseAdapter.read(entity, id)
|
||||
if (existing) {
|
||||
enforceRowAccess(context, entity, 'delete', existing as Record<string, unknown>)
|
||||
}
|
||||
return context.baseAdapter.delete(entity, id)
|
||||
})
|
||||
}
|
||||
|
||||
const upsert = async (
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>,
|
||||
): Promise<unknown> => {
|
||||
return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData))
|
||||
}
|
||||
|
||||
const updateByField = async (
|
||||
entity: string,
|
||||
field: string,
|
||||
value: unknown,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<unknown> => {
|
||||
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<boolean> => {
|
||||
const operation = resolveOperation('deleteByField')
|
||||
return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value))
|
||||
}
|
||||
|
||||
const createMany = async (entity: string, data: Record<string, unknown>[]): Promise<number> => {
|
||||
const operation = resolveOperation('createMany')
|
||||
return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data))
|
||||
}
|
||||
|
||||
const updateMany = async (
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<number> => {
|
||||
const operation = resolveOperation('updateMany')
|
||||
return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data))
|
||||
}
|
||||
|
||||
const deleteMany = async (entity: string, filter?: Record<string, unknown>): Promise<number> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
return this.store.has(key)
|
||||
return this.store.has(normalizeKey(key))
|
||||
}
|
||||
|
||||
async getMetadata(key: string): Promise<BlobMetadata> {
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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 => {
|
||||
|
||||
@@ -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<Buffer> => {
|
||||
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)
|
||||
}
|
||||
@@ -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<string, BlobData>
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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<Buffer> => {
|
||||
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 (
|
||||
|
||||
18
dbal/development/src/blob/providers/memory-storage/utils.ts
Normal file
18
dbal/development/src/blob/providers/memory-storage/utils.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { TenantAwareDeps } from './context'
|
||||
|
||||
const recordUsageChange = async (deps: TenantAwareDeps, bytesChange: number, countChange: number): Promise<void> => {
|
||||
await deps.tenantManager.updateBlobUsage(deps.tenantId, bytesChange, countChange)
|
||||
}
|
||||
|
||||
export const auditUpload = async (deps: TenantAwareDeps, sizeBytes: number): Promise<void> => {
|
||||
await recordUsageChange(deps, sizeBytes, 1)
|
||||
}
|
||||
|
||||
export const auditDeletion = async (deps: TenantAwareDeps, sizeBytes: number): Promise<void> => {
|
||||
await recordUsageChange(deps, -sizeBytes, -1)
|
||||
}
|
||||
|
||||
export const auditCopy = async (deps: TenantAwareDeps, sizeBytes: number): Promise<void> => {
|
||||
await recordUsageChange(deps, sizeBytes, 1)
|
||||
}
|
||||
@@ -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<TenantContext> => {
|
||||
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<typeof action, string> = {
|
||||
read: 'read',
|
||||
write: 'write',
|
||||
delete: 'delete',
|
||||
}
|
||||
throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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<bo
|
||||
const deleted = await deps.baseStorage.delete(scopedKey)
|
||||
|
||||
if (deleted) {
|
||||
await deps.tenantManager.updateBlobUsage(deps.tenantId, -metadata.size, -1)
|
||||
await auditDeletion(deps, metadata.size)
|
||||
}
|
||||
|
||||
return deleted
|
||||
@@ -24,7 +26,7 @@ export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise<bo
|
||||
}
|
||||
|
||||
export const exists = async (deps: TenantAwareDeps, key: string): Promise<boolean> => {
|
||||
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<BlobMetadata> => {
|
||||
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,
|
||||
|
||||
@@ -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<Buffer> => {
|
||||
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<ReadableStream | NodeJS.ReadableStream> => {
|
||||
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<BlobListResult> => {
|
||||
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<BlobMetadata> => {
|
||||
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<string> => {
|
||||
const context = await getContext(deps)
|
||||
const context = await resolveTenantContext(deps)
|
||||
ensurePermission(context, 'read')
|
||||
|
||||
const scopedKey = scopeKey(key, context.namespace)
|
||||
|
||||
@@ -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<TenantContext> => {
|
||||
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<typeof action, string> = {
|
||||
read: 'read',
|
||||
write: 'write',
|
||||
delete: 'delete',
|
||||
}
|
||||
throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`)
|
||||
}
|
||||
}
|
||||
@@ -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<BlobMetadata> => {
|
||||
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<BlobMetadata> => {
|
||||
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,
|
||||
|
||||
@@ -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<void>
|
||||
send: (message: RPCMessage) => Promise<void>
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
export const createConnectionManager = (
|
||||
state: BridgeState,
|
||||
messageRouter: MessageRouter,
|
||||
): ConnectionManager => {
|
||||
let connectionPromise: Promise<void> | 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
rejectPendingRequests(DBALError.internal('WebSocket connection closed'))
|
||||
|
||||
if (state.ws) {
|
||||
state.ws.close()
|
||||
}
|
||||
|
||||
resetConnection()
|
||||
}
|
||||
|
||||
return {
|
||||
ensureConnection,
|
||||
send,
|
||||
close,
|
||||
}
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
if (state.ws) {
|
||||
state.ws.close()
|
||||
state.ws = null
|
||||
}
|
||||
state.pendingRequests.clear()
|
||||
}
|
||||
@@ -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<typeof createBridgeState>
|
||||
private readonly connectionManager: ReturnType<typeof createConnectionManager>
|
||||
private readonly operations: ReturnType<typeof createOperations>
|
||||
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
@@ -75,6 +79,6 @@ export class WebSocketBridge implements DBALAdapter {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await closeConnection(this.state)
|
||||
await this.connectionManager.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
const isRPCError = (value: unknown): value is NonNullable<RPCResponse['error']> =>
|
||||
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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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<string, unknown>) => rpcCall(state, 'create', entity, data),
|
||||
read: (entity: string, id: string) => rpcCall(state, 'read', entity, id),
|
||||
update: (entity: string, id: string, data: Record<string, unknown>) => rpcCall(state, 'update', entity, id, data),
|
||||
delete: (entity: string, id: string) => rpcCall(state, 'delete', entity, id) as Promise<boolean>,
|
||||
list: (entity: string, options?: ListOptions) => rpcCall(state, 'list', entity, options) as Promise<ListResult<unknown>>,
|
||||
findFirst: (entity: string, filter?: Record<string, unknown>) => 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<string, unknown>) => rpcCall(state, connectionManager, 'create', entity, data),
|
||||
read: (entity: string, id: string) => rpcCall(state, connectionManager, 'read', entity, id),
|
||||
update: (entity: string, id: string, data: Record<string, unknown>) =>
|
||||
rpcCall(state, connectionManager, 'update', entity, id, data),
|
||||
delete: (entity: string, id: string) => rpcCall(state, connectionManager, 'delete', entity, id) as Promise<boolean>,
|
||||
list: (entity: string, options?: ListOptions) =>
|
||||
rpcCall(state, connectionManager, 'list', entity, options) as Promise<ListResult<unknown>>,
|
||||
findFirst: (entity: string, filter?: Record<string, unknown>) =>
|
||||
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<string, unknown>,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>,
|
||||
) => rpcCall(state, 'upsert', entity, filter, createData, updateData),
|
||||
) => rpcCall(state, connectionManager, 'upsert', entity, filter, createData, updateData),
|
||||
updateByField: (entity: string, field: string, value: unknown, data: Record<string, unknown>) =>
|
||||
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<boolean>,
|
||||
rpcCall(state, connectionManager, 'deleteByField', entity, field, value) as Promise<boolean>,
|
||||
deleteMany: (entity: string, filter?: Record<string, unknown>) =>
|
||||
rpcCall(state, 'deleteMany', entity, filter) as Promise<number>,
|
||||
rpcCall(state, connectionManager, 'deleteMany', entity, filter) as Promise<number>,
|
||||
createMany: (entity: string, data: Record<string, unknown>[]) =>
|
||||
rpcCall(state, 'createMany', entity, data) as Promise<number>,
|
||||
rpcCall(state, connectionManager, 'createMany', entity, data) as Promise<number>,
|
||||
updateMany: (entity: string, filter: Record<string, unknown>, data: Record<string, unknown>) =>
|
||||
rpcCall(state, 'updateMany', entity, filter, data) as Promise<number>,
|
||||
getCapabilities: () => rpcCall(state, 'getCapabilities') as Promise<AdapterCapabilities>,
|
||||
rpcCall(state, connectionManager, 'updateMany', entity, filter, data) as Promise<number>,
|
||||
getCapabilities: () => rpcCall(state, connectionManager, 'getCapabilities') as Promise<AdapterCapabilities>,
|
||||
})
|
||||
|
||||
@@ -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<unknown> => {
|
||||
await connect(state)
|
||||
|
||||
export const rpcCall = async (
|
||||
state: BridgeState,
|
||||
connectionManager: ConnectionManager,
|
||||
method: string,
|
||||
...params: unknown[]
|
||||
): Promise<unknown> => {
|
||||
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)) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<User, 'id' | 'createdAt' | 'updatedAt'>,
|
||||
): Promise<User> => {
|
||||
assertValidUserCreate(data)
|
||||
|
||||
try {
|
||||
return adapter.create('User', data) as Promise<User>
|
||||
} catch (error) {
|
||||
if (error instanceof DBALError && error.code === 409) {
|
||||
throw DBALError.conflict('User with username or email already exists')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> => {
|
||||
assertValidUserId(id)
|
||||
|
||||
const result = await adapter.delete('User', id)
|
||||
if (!result) {
|
||||
throw DBALError.notFound(`User not found: ${id}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<User, 'id' | 'createdAt' | 'updatedAt'>,
|
||||
): Promise<User> => {
|
||||
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<User>
|
||||
} 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<User>): Promise<User> => {
|
||||
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<User>
|
||||
} 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<boolean> => {
|
||||
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
|
||||
}
|
||||
@@ -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<User>,
|
||||
): Promise<User> => {
|
||||
assertValidUserId(id)
|
||||
assertValidUserUpdate(data)
|
||||
|
||||
try {
|
||||
return adapter.update('User', id, data) as Promise<User>
|
||||
} catch (error) {
|
||||
if (error instanceof DBALError && error.code === 409) {
|
||||
throw DBALError.conflict('Username or email already exists')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -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<User, 'id' | 'createdAt' | 'updatedAt'>): 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<User>): void => {
|
||||
const validationErrors = validateUserUpdate(data)
|
||||
if (validationErrors.length > 0) {
|
||||
throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error })))
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { createPackageOperations } from './package'
|
||||
export type { PackageOperations } from './package'
|
||||
export * from './package'
|
||||
|
||||
@@ -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<Package>) => string[]
|
||||
publish: (data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>) => Promise<Package>
|
||||
unpublish: (id: string) => Promise<boolean>
|
||||
create: (data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>) => Promise<Package>
|
||||
read: (id: string) => Promise<Package | null>
|
||||
update: (id: string, data: Partial<Package>) => Promise<Package>
|
||||
@@ -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'
|
||||
|
||||
@@ -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<Package, 'id' | 'createdAt' | 'updatedAt'>,
|
||||
): Promise<Package> => {
|
||||
return createPackage(adapter, data)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { DBALAdapter } from '../../../../adapters/adapter'
|
||||
import { deletePackage } from './mutations'
|
||||
|
||||
export const unpublishPackage = (adapter: DBALAdapter, id: string): Promise<boolean> => {
|
||||
return deletePackage(adapter, id)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Package } from '../../../../foundation/types'
|
||||
import { validatePackageCreate } from '../../../../foundation/validation'
|
||||
|
||||
export const validatePackage = (data: Partial<Package>): string[] => {
|
||||
return validatePackageCreate(data)
|
||||
}
|
||||
19
dbal/development/src/core/foundation/types/entities.ts
Normal file
19
dbal/development/src/core/foundation/types/entities.ts
Normal file
@@ -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<string, unknown>
|
||||
}
|
||||
13
dbal/development/src/core/foundation/types/events.ts
Normal file
13
dbal/development/src/core/foundation/types/events.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { OperationContext } from './operations'
|
||||
|
||||
export interface DomainEvent<TPayload = Record<string, unknown>> {
|
||||
id: string
|
||||
name: string
|
||||
occurredAt: Date
|
||||
payload: TPayload
|
||||
context?: OperationContext
|
||||
}
|
||||
|
||||
export interface EventHandler<TPayload = Record<string, unknown>> {
|
||||
(event: DomainEvent<TPayload>): void | Promise<void>
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
19
dbal/development/src/core/foundation/types/operations.ts
Normal file
19
dbal/development/src/core/foundation/types/operations.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface OperationContext {
|
||||
tenantId?: string
|
||||
userId?: string
|
||||
correlationId?: string
|
||||
traceId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface OperationOptions {
|
||||
timeoutMs?: number
|
||||
retryCount?: number
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export interface OperationAuditTrail {
|
||||
performedAt: Date
|
||||
performedBy?: string
|
||||
context?: OperationContext
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
125
dbal/shared/tools/cpp-build-assistant/cli.ts
Normal file
125
dbal/shared/tools/cpp-build-assistant/cli.ts
Normal file
@@ -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<boolean> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
20
dbal/shared/tools/cpp-build-assistant/config.ts
Normal file
20
dbal/shared/tools/cpp-build-assistant/config.ts
Normal file
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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 }
|
||||
|
||||
10
dbal/shared/tools/cpp-build-assistant/runner.ts
Normal file
10
dbal/shared/tools/cpp-build-assistant/runner.ts
Normal file
@@ -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<boolean> => {
|
||||
const config = createCppBuildAssistantConfig(projectRoot)
|
||||
const assistant = new CppBuildAssistant(config)
|
||||
|
||||
return runCli(args, assistant)
|
||||
}
|
||||
@@ -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<FormState>(initialFormState)
|
||||
const [status, setStatus] = useState<FetchStatus>('idle')
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [manifest, setManifest] = useState<CodegenManifest | null>(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 (
|
||||
<Container maxWidth="md" className="py-16">
|
||||
<Paper elevation={8} className="p-8 space-y-6">
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h3" component="h1">
|
||||
Codegen Studio Export
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Configure a starter bundle for MetaBuilder packages and download it instantly.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
label="Project name"
|
||||
value={form.projectName}
|
||||
onChange={handleChange('projectName')}
|
||||
fullWidth
|
||||
<Container maxWidth="lg" className="py-16">
|
||||
<Paper elevation={8} className="p-8">
|
||||
<Stack spacing={5}>
|
||||
<Header
|
||||
title="Codegen Studio Export"
|
||||
subtitle="Configure a starter bundle for MetaBuilder packages and download it instantly."
|
||||
/>
|
||||
<TextField
|
||||
label="Package id"
|
||||
value={form.packageId}
|
||||
onChange={handleChange('packageId')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Runtime"
|
||||
value={form.runtime}
|
||||
onChange={handleChange('runtime')}
|
||||
fullWidth
|
||||
>
|
||||
{runtimeOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{runtimeDescription}
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Tone"
|
||||
value={form.tone}
|
||||
onChange={handleChange('tone')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Creative brief"
|
||||
value={form.brief}
|
||||
onChange={handleChange('brief')}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={status === 'loading'}
|
||||
startIcon={status === 'loading' ? <CircularProgress size={16} /> : null}
|
||||
>
|
||||
{status === 'loading' ? 'Generating...' : 'Generate ZIP'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{message && <Alert severity="success">{message}</Alert>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
{manifest && (
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{ border: '1px dashed', borderColor: 'divider', p: 2, backgroundColor: 'background.default' }}
|
||||
>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Manifest preview
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={5} alignItems="flex-start">
|
||||
<Stack spacing={3} flex={1} width="100%">
|
||||
<TextField
|
||||
label="Project name"
|
||||
value={form.projectName}
|
||||
onChange={handleChange('projectName')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField label="Package id" value={form.packageId} onChange={handleChange('packageId')} fullWidth />
|
||||
<TextField select label="Runtime" value={form.runtime} onChange={handleChange('runtime')} fullWidth>
|
||||
{runtimeOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{runtimeDescription}
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Project: {manifest.projectName}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Package: {manifest.packageId}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Runtime: {manifest.runtime}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tone: {manifest.tone ?? 'adaptive'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Generated at: {new Date(manifest.generatedAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="subtitle2">Bundle contents</Typography>
|
||||
{previewFiles.map((entry) => (
|
||||
<Typography key={entry} variant="body2" color="text.secondary">
|
||||
• {entry}
|
||||
</Typography>
|
||||
))}
|
||||
<TextField label="Tone" value={form.tone} onChange={handleChange('tone')} fullWidth />
|
||||
<TextField
|
||||
label="Creative brief"
|
||||
value={form.brief}
|
||||
onChange={handleChange('brief')}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={status === 'loading'}
|
||||
startIcon={status === 'loading' ? <CircularProgress size={16} /> : null}
|
||||
>
|
||||
{status === 'loading' ? 'Generating...' : 'Generate ZIP'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{message && <Alert severity="success">{message}</Alert>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Stack>
|
||||
<Box width={{ xs: '100%', md: 320 }} flexShrink={0}>
|
||||
<Sidebar manifest={manifest} previewFiles={previewFiles} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
21
frontends/nextjs/src/app/codegen/components/Header.tsx
Normal file
21
frontends/nextjs/src/app/codegen/components/Header.tsx
Normal file
@@ -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 (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h3" component="h1">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
51
frontends/nextjs/src/app/codegen/components/Sidebar.tsx
Normal file
51
frontends/nextjs/src/app/codegen/components/Sidebar.tsx
Normal file
@@ -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 (
|
||||
<Stack spacing={3}>
|
||||
{manifest && (
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{ border: '1px dashed', borderColor: 'divider', p: 2, backgroundColor: 'background.default' }}
|
||||
>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Manifest preview
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Project: {manifest.projectName}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Package: {manifest.packageId}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Runtime: {manifest.runtime}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tone: {manifest.tone ?? 'adaptive'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Generated at: {new Date(manifest.generatedAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Bundle contents</Typography>
|
||||
{previewFiles.map((entry) => (
|
||||
<Typography key={entry} variant="body2" color="text.secondary">
|
||||
• {entry}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
74
frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts
Normal file
74
frontends/nextjs/src/app/codegen/hooks/useCodegenData.ts
Normal file
@@ -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<FetchStatus>('idle')
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [manifest, setManifest] = useState<CodegenManifest | null>(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 }
|
||||
}
|
||||
@@ -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<LuaBlockType, BlockDefinition>
|
||||
onRequestAddBlock: (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
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<LuaScript>) => void
|
||||
selectedScript: LuaScript | null
|
||||
}
|
||||
|
||||
export function BlockListView({
|
||||
activeBlocks,
|
||||
blockDefinitionMap,
|
||||
onRequestAddBlock,
|
||||
onMoveBlock,
|
||||
onDuplicateBlock,
|
||||
onRemoveBlock,
|
||||
onUpdateField,
|
||||
onUpdateScript,
|
||||
selectedScript,
|
||||
}: BlockListViewProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Block workspace"
|
||||
subheader="Stack blocks to generate Lua code"
|
||||
action={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={(event) => onRequestAddBlock(event, { parentId: null, slot: 'root' })}
|
||||
disabled={!selectedScript}
|
||||
>
|
||||
Add block
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
{!selectedScript ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Select a script to start building blocks.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2} direction={{ xs: 'column', md: 'row' }}>
|
||||
<TextField
|
||||
label="Script name"
|
||||
value={selectedScript.name}
|
||||
onChange={(event) => onUpdateScript({ name: event.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={selectedScript.description || ''}
|
||||
onChange={(event) => onUpdateScript({ description: event.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Box className={styles.workspaceSurface}>
|
||||
{activeBlocks.length > 0 ? (
|
||||
<BlockList
|
||||
blocks={activeBlocks}
|
||||
blockDefinitionMap={blockDefinitionMap}
|
||||
onRequestAddBlock={onRequestAddBlock}
|
||||
onMoveBlock={onMoveBlock}
|
||||
onDuplicateBlock={onDuplicateBlock}
|
||||
onRemoveBlock={onRemoveBlock}
|
||||
onUpdateField={onUpdateField}
|
||||
/>
|
||||
) : (
|
||||
<Box className={styles.blockEmpty}>Add a block to start building Lua logic.</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Blocks are saved in the script as metadata, so you can reload them later.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
73
frontends/nextjs/src/components/editors/lua/CodePreview.tsx
Normal file
73
frontends/nextjs/src/components/editors/lua/CodePreview.tsx
Normal file
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Lua preview"
|
||||
subheader="Generated code from your blocks"
|
||||
action={
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Reload blocks from script">
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon fontSize="small" />}
|
||||
onClick={onReloadFromCode}
|
||||
disabled={!selectedScript}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Copy code">
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ContentCopy fontSize="small" />}
|
||||
onClick={onCopyCode}
|
||||
disabled={!selectedScript}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon fontSize="small" />}
|
||||
onClick={onApplyCode}
|
||||
disabled={!selectedScript}
|
||||
>
|
||||
Apply to script
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Box className={styles.codePreview}>
|
||||
<pre>{generatedCode}</pre>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<Card>
|
||||
<CardHeader title="Block library" subheader="Click a block to add it" />
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
{Object.entries(blocksByCategory).map(([category, blocks]) => (
|
||||
<Box key={category}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{blocks.map((block) => (
|
||||
<Paper
|
||||
key={block.type}
|
||||
className={styles.libraryBlock}
|
||||
data-category={block.category}
|
||||
onClick={() => handleAddBlock(block.type, { parentId: null, slot: 'root' })}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography className={styles.libraryBlockTitle}>{block.label}</Typography>
|
||||
<Typography className={styles.libraryBlockDesc}>{block.description}</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleAddBlock(block.type, { parentId: null, slot: 'root' })
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const renderWorkspace = () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Block workspace"
|
||||
subheader="Stack blocks to generate Lua code"
|
||||
action={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={(event) => handleRequestAddBlock(event, { parentId: null, slot: 'root' })}
|
||||
disabled={!selectedScript}
|
||||
>
|
||||
Add block
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
{!selectedScript ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Select a script to start building blocks.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2} direction={{ xs: 'column', md: 'row' }}>
|
||||
<TextField
|
||||
label="Script name"
|
||||
value={selectedScript.name}
|
||||
onChange={(event) => handleUpdateScript({ name: event.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={selectedScript.description || ''}
|
||||
onChange={(event) => handleUpdateScript({ description: event.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Box className={styles.workspaceSurface}>
|
||||
{activeBlocks.length > 0 ? (
|
||||
<BlockList
|
||||
blocks={activeBlocks}
|
||||
blockDefinitionMap={blockDefinitionMap}
|
||||
onRequestAddBlock={handleRequestAddBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onDuplicateBlock={handleDuplicateBlock}
|
||||
onRemoveBlock={handleRemoveBlock}
|
||||
onUpdateField={handleUpdateField}
|
||||
/>
|
||||
) : (
|
||||
<Box className={styles.blockEmpty}>Add a block to start building Lua logic.</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Blocks are saved in the script as metadata, so you can reload them later.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const renderScriptList = () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Lua block scripts"
|
||||
subheader="Create scripts using Scratch-style blocks"
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddScript}>
|
||||
New block script
|
||||
</Button>
|
||||
<Divider />
|
||||
<List disablePadding>
|
||||
{scripts.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No scripts yet. Create a block script to begin.
|
||||
</Typography>
|
||||
)}
|
||||
{scripts.map((script) => (
|
||||
<ListItemButton
|
||||
key={script.id}
|
||||
selected={script.id === selectedScriptId}
|
||||
onClick={() => setSelectedScriptId(script.id)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={script.name}
|
||||
secondary={script.description || 'No description'}
|
||||
primaryTypographyProps={{ fontWeight: 600 }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<Tooltip title="Delete script">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleDeleteScript(script.id)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
} = useLuaBlockEditorState({ scripts, onScriptsChange })
|
||||
|
||||
return (
|
||||
<Box className={styles.root}>
|
||||
@@ -242,55 +63,121 @@ export function LuaBlocksEditor({ scripts, onScriptsChange }: LuaBlocksEditorPro
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{renderScriptList()}
|
||||
{renderBlockLibrary()}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Lua block scripts"
|
||||
subheader="Create scripts using Scratch-style blocks"
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddScript}>
|
||||
New block script
|
||||
</Button>
|
||||
<Divider />
|
||||
<List disablePadding>
|
||||
{scripts.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No scripts yet. Create a block script to begin.
|
||||
</Typography>
|
||||
)}
|
||||
{scripts.map((script) => (
|
||||
<ListItemButton
|
||||
key={script.id}
|
||||
selected={script.id === selectedScriptId}
|
||||
onClick={() => setSelectedScriptId(script.id)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={script.name}
|
||||
secondary={script.description || 'No description'}
|
||||
primaryTypographyProps={{ fontWeight: 600 }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<Tooltip title="Delete script">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleDeleteScript(script.id)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Block library" subheader="Click a block to add it" />
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
{Object.entries(blocksByCategory).map(([category, blocks]) => (
|
||||
<Box key={category}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{blocks.map((block) => (
|
||||
<Paper
|
||||
key={block.type}
|
||||
className={styles.libraryBlock}
|
||||
data-category={block.category}
|
||||
onClick={() => handleAddBlock(block.type, { parentId: null, slot: 'root' })}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography className={styles.libraryBlockTitle}>{block.label}</Typography>
|
||||
<Typography className={styles.libraryBlockDesc}>{block.description}</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleAddBlock(block.type, { parentId: null, slot: 'root' })
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{renderWorkspace()}
|
||||
<BlockListView
|
||||
activeBlocks={activeBlocks}
|
||||
blockDefinitionMap={blockDefinitionMap}
|
||||
onRequestAddBlock={handleRequestAddBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onDuplicateBlock={handleDuplicateBlock}
|
||||
onRemoveBlock={handleRemoveBlock}
|
||||
onUpdateField={handleUpdateField}
|
||||
onUpdateScript={handleUpdateScript}
|
||||
selectedScript={selectedScript}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Lua preview"
|
||||
subheader="Generated code from your blocks"
|
||||
action={
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Reload blocks from script">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleReloadFromCode}
|
||||
disabled={!selectedScript}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Copy code">
|
||||
<span>
|
||||
<IconButton size="small" onClick={handleCopyCode} disabled={!selectedScript}>
|
||||
<ContentCopy fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon fontSize="small" />}
|
||||
onClick={handleApplyCode}
|
||||
disabled={!selectedScript}
|
||||
>
|
||||
Apply to script
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Box className={styles.codePreview}>
|
||||
<pre>{generatedCode}</pre>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CodePreview
|
||||
generatedCode={generatedCode}
|
||||
onApplyCode={handleApplyCode}
|
||||
onCopyCode={handleCopyCode}
|
||||
onReloadFromCode={handleReloadFromCode}
|
||||
selectedScript={selectedScript}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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<LuaSnippet | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(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) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<MagnifyingGlass size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search snippets by name, description, or tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<ScrollArea className="w-full whitespace-nowrap">
|
||||
<TabsList className="inline-flex w-auto">
|
||||
{LUA_SNIPPET_CATEGORIES.map((category) => (
|
||||
<TabsTrigger key={category} value={category} className="text-xs">
|
||||
{category}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
<SearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
/>
|
||||
|
||||
{LUA_SNIPPET_CATEGORIES.map((category) => (
|
||||
<TabsContent key={category} value={category} className="mt-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{displayedSnippets.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<Code size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>No snippets found</p>
|
||||
{searchQuery && (
|
||||
<p className="text-sm mt-2">Try a different search term</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
displayedSnippets.map((snippet) => (
|
||||
<Card
|
||||
key={snippet.id}
|
||||
className="hover:border-primary transition-colors cursor-pointer group"
|
||||
onClick={() => setSelectedSnippet(snippet)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base font-semibold mb-1 truncate group-hover:text-primary transition-colors">
|
||||
{snippet.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs line-clamp-2">
|
||||
{snippet.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{snippet.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{snippet.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{snippet.tags.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{snippet.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopySnippet(snippet)
|
||||
}}
|
||||
>
|
||||
{copiedId === snippet.id ? (
|
||||
<>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} className="mr-1.5" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{onInsertSnippet && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleInsertSnippet(snippet)
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} className="mr-1.5" />
|
||||
Insert
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
<SnippetList
|
||||
snippets={displayedSnippets}
|
||||
searchQuery={searchQuery}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectSnippet={setSelectedSnippet}
|
||||
onCopySnippet={handleCopySnippet}
|
||||
onInsertSnippet={onInsertSnippet ? handleInsertSnippet : undefined}
|
||||
copiedId={copiedId}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={!!selectedSnippet} onOpenChange={() => setSelectedSnippet(null)}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<DialogTitle className="text-xl mb-2">{selectedSnippet?.name}</DialogTitle>
|
||||
<DialogDescription>{selectedSnippet?.description}</DialogDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{selectedSnippet?.category}</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-4">
|
||||
{selectedSnippet?.tags && selectedSnippet.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedSnippet.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSnippet?.parameters && selectedSnippet.parameters.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Code size={16} />
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedSnippet.parameters.map((param) => (
|
||||
<div key={param.name} className="bg-muted/50 rounded-lg p-3 border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<code className="text-sm font-mono font-semibold text-primary">
|
||||
{param.name}
|
||||
</code>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{param.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{param.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Code</h4>
|
||||
<div className="bg-slate-950 text-slate-50 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-xs font-mono leading-relaxed">
|
||||
<code>{selectedSnippet?.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => selectedSnippet && handleCopySnippet(selectedSnippet)}
|
||||
>
|
||||
{copiedId === selectedSnippet?.id ? (
|
||||
<>
|
||||
<Check size={16} className="mr-2" />
|
||||
Copied to Clipboard
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={16} className="mr-2" />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{onInsertSnippet && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (selectedSnippet) {
|
||||
handleInsertSnippet(selectedSnippet)
|
||||
setSelectedSnippet(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={16} className="mr-2" />
|
||||
Insert into Editor
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SnippetDialog
|
||||
snippet={selectedSnippet}
|
||||
copiedId={copiedId}
|
||||
onCopy={handleCopySnippet}
|
||||
onInsert={
|
||||
onInsertSnippet
|
||||
? (snippet) => {
|
||||
handleInsertSnippet(snippet)
|
||||
setSelectedSnippet(null)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={() => setSelectedSnippet(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<MagnifyingGlass
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search snippets by name, description, or tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="w-full whitespace-nowrap">
|
||||
<TabsList className="inline-flex w-auto">
|
||||
{LUA_SNIPPET_CATEGORIES.map((category) => (
|
||||
<TabsTrigger key={category} value={category} className="text-xs">
|
||||
{category}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={!!snippet} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<DialogTitle className="text-xl mb-2">{snippet?.name}</DialogTitle>
|
||||
<DialogDescription>{snippet?.description}</DialogDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{snippet?.category}</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-4">
|
||||
{snippet?.tags && snippet.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{snippet.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snippet?.parameters && snippet.parameters.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Code size={16} />
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{snippet.parameters.map((param) => (
|
||||
<div key={param.name} className="bg-muted/50 rounded-lg p-3 border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<code className="text-sm font-mono font-semibold text-primary">{param.name}</code>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{param.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{param.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Code</h4>
|
||||
<div className="bg-slate-950 text-slate-50 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-xs font-mono leading-relaxed">
|
||||
<code>{snippet?.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" onClick={() => snippet && onCopy(snippet)}>
|
||||
{copiedId === snippet?.id ? (
|
||||
<>
|
||||
<Check size={16} className="mr-2" />
|
||||
Copied to Clipboard
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={16} className="mr-2" />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{onInsert && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => snippet && onInsert(snippet)}
|
||||
>
|
||||
<ArrowRight size={16} className="mr-2" />
|
||||
Insert into Editor
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
<TabsContent key={category} value={category} className="mt-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{snippets.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<Code size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>No snippets found</p>
|
||||
{searchQuery && <p className="text-sm mt-2">Try a different search term</p>}
|
||||
</div>
|
||||
) : (
|
||||
snippets.map((snippet) => (
|
||||
<Card
|
||||
key={`${selectedCategory}-${snippet.id}`}
|
||||
className="hover:border-primary transition-colors cursor-pointer group"
|
||||
onClick={() => onSelectSnippet(snippet)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base font-semibold mb-1 truncate group-hover:text-primary transition-colors">
|
||||
{snippet.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs line-clamp-2">
|
||||
{snippet.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{snippet.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{snippet.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{snippet.tags.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{snippet.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCopySnippet(snippet)
|
||||
}}
|
||||
>
|
||||
{copiedId === snippet.id ? (
|
||||
<>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} className="mr-1.5" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{onInsertSnippet && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onInsertSnippet(snippet)
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} className="mr-1.5" />
|
||||
Insert
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
218
frontends/nextjs/src/components/editors/lua/blocks/BlockItem.tsx
Normal file
218
frontends/nextjs/src/components/editors/lua/blocks/BlockItem.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Add as AddIcon,
|
||||
ArrowDownward,
|
||||
ArrowUpward,
|
||||
ContentCopy,
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material'
|
||||
import type { BlockDefinition, BlockSlot, LuaBlock } from '../types'
|
||||
import styles from '../LuaBlocksEditor.module.scss'
|
||||
|
||||
interface BlockItemProps {
|
||||
block: LuaBlock
|
||||
definition: BlockDefinition
|
||||
index: number
|
||||
total: number
|
||||
onRequestAddBlock: (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => void
|
||||
onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
|
||||
onDuplicateBlock: (blockId: string) => void
|
||||
onRemoveBlock: (blockId: string) => void
|
||||
onUpdateField: (blockId: string, fieldName: string, value: string) => void
|
||||
renderNestedList: (blocks?: LuaBlock[]) => JSX.Element
|
||||
}
|
||||
|
||||
interface BlockSectionProps {
|
||||
title: string
|
||||
blocks: LuaBlock[] | undefined
|
||||
parentId: string
|
||||
slot: BlockSlot
|
||||
onRequestAddBlock: (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => void
|
||||
renderNestedList: (blocks?: LuaBlock[]) => JSX.Element
|
||||
}
|
||||
|
||||
const BlockSection = ({
|
||||
title,
|
||||
blocks,
|
||||
parentId,
|
||||
slot,
|
||||
onRequestAddBlock,
|
||||
renderNestedList,
|
||||
}: BlockSectionProps) => (
|
||||
<Box className={styles.blockSection}>
|
||||
<Box className={styles.blockSectionHeader}>
|
||||
<Typography className={styles.blockSectionTitle}>{title}</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={(event) => onRequestAddBlock(event, { parentId, slot })}
|
||||
startIcon={<AddIcon fontSize="small" />}
|
||||
>
|
||||
Add block
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className={styles.blockSectionBody}>
|
||||
{blocks && blocks.length > 0 ? (
|
||||
renderNestedList(blocks)
|
||||
) : (
|
||||
<Box className={styles.blockEmpty}>Drop blocks here to build this section.</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const BlockFields = ({
|
||||
block,
|
||||
definition,
|
||||
onUpdateField,
|
||||
}: {
|
||||
block: LuaBlock
|
||||
definition: BlockDefinition
|
||||
onUpdateField: (blockId: string, fieldName: string, value: string) => void
|
||||
}) => {
|
||||
if (definition.fields.length === 0) return null
|
||||
|
||||
return (
|
||||
<Box className={styles.blockFields}>
|
||||
{definition.fields.map((field) => (
|
||||
<Box key={field.name}>
|
||||
<Typography className={styles.blockFieldLabel}>{field.label}</Typography>
|
||||
{field.type === 'select' ? (
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={block.fields[field.name]}
|
||||
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
|
||||
}}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={block.fields[field.name]}
|
||||
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
InputProps={{
|
||||
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const BlockItem = ({
|
||||
block,
|
||||
definition,
|
||||
index,
|
||||
total,
|
||||
onRequestAddBlock,
|
||||
onMoveBlock,
|
||||
onDuplicateBlock,
|
||||
onRemoveBlock,
|
||||
onUpdateField,
|
||||
renderNestedList,
|
||||
}: BlockItemProps) => (
|
||||
<Box className={styles.blockCard} data-category={definition.category}>
|
||||
<Box className={styles.blockHeader}>
|
||||
<Typography className={styles.blockTitle}>{definition.label}</Typography>
|
||||
<Box className={styles.blockActions}>
|
||||
<Tooltip title="Move up">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onMoveBlock(block.id, 'up')}
|
||||
disabled={index === 0}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ArrowUpward fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move down">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onMoveBlock(block.id, 'down')}
|
||||
disabled={index === total - 1}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ArrowDownward fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate block">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDuplicateBlock(block.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ContentCopy fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete block">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemoveBlock(block.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<DeleteIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<BlockFields block={block} definition={definition} onUpdateField={onUpdateField} />
|
||||
|
||||
{definition.hasChildren && (
|
||||
<BlockSection
|
||||
title="Then"
|
||||
blocks={block.children}
|
||||
parentId={block.id}
|
||||
slot="children"
|
||||
onRequestAddBlock={onRequestAddBlock}
|
||||
renderNestedList={renderNestedList}
|
||||
/>
|
||||
)}
|
||||
|
||||
{definition.hasElseChildren && (
|
||||
<BlockSection
|
||||
title="Else"
|
||||
blocks={block.elseChildren}
|
||||
parentId={block.id}
|
||||
slot="elseChildren"
|
||||
onRequestAddBlock={onRequestAddBlock}
|
||||
renderNestedList={renderNestedList}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
@@ -1,22 +1,8 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Add as AddIcon,
|
||||
ArrowDownward,
|
||||
ArrowUpward,
|
||||
ContentCopy,
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from '../types'
|
||||
import styles from '../LuaBlocksEditor.module.scss'
|
||||
import { BlockItem } from './BlockItem'
|
||||
|
||||
interface BlockListProps {
|
||||
blocks: LuaBlock[]
|
||||
@@ -31,89 +17,6 @@ interface BlockListProps {
|
||||
onUpdateField: (blockId: string, fieldName: string, value: string) => void
|
||||
}
|
||||
|
||||
const renderBlockFields = (
|
||||
block: LuaBlock,
|
||||
definition: BlockDefinition,
|
||||
onUpdateField: (blockId: string, fieldName: string, value: string) => void
|
||||
) => {
|
||||
if (definition.fields.length === 0) return null
|
||||
|
||||
return (
|
||||
<Box className={styles.blockFields}>
|
||||
{definition.fields.map((field) => (
|
||||
<Box key={field.name}>
|
||||
<Typography className={styles.blockFieldLabel}>{field.label}</Typography>
|
||||
{field.type === 'select' ? (
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={block.fields[field.name]}
|
||||
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
|
||||
}}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={block.fields[field.name]}
|
||||
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
InputProps={{
|
||||
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const renderBlockSection = (
|
||||
title: string,
|
||||
blocks: LuaBlock[] | undefined,
|
||||
parentId: string | null,
|
||||
slot: BlockSlot,
|
||||
onRequestAddBlock: (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => void,
|
||||
renderBlockCard: (block: LuaBlock, index: number, total: number) => JSX.Element | null
|
||||
) => (
|
||||
<Box className={styles.blockSection}>
|
||||
<Box className={styles.blockSectionHeader}>
|
||||
<Typography className={styles.blockSectionTitle}>{title}</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={(event) => onRequestAddBlock(event, { parentId, slot })}
|
||||
startIcon={<AddIcon fontSize="small" />}
|
||||
>
|
||||
Add block
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className={styles.blockSectionBody}>
|
||||
{blocks && blocks.length > 0 ? (
|
||||
blocks.map((child, index) => renderBlockCard(child, index, blocks.length))
|
||||
) : (
|
||||
<Box className={styles.blockEmpty}>Drop blocks here to build this section.</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const BlockList = ({
|
||||
blocks,
|
||||
blockDefinitionMap,
|
||||
@@ -123,78 +26,40 @@ export const BlockList = ({
|
||||
onRemoveBlock,
|
||||
onUpdateField,
|
||||
}: BlockListProps) => {
|
||||
const renderBlockCard = (block: LuaBlock, index: number, total: number) => {
|
||||
const definition = blockDefinitionMap.get(block.type)
|
||||
if (!definition) return null
|
||||
|
||||
return (
|
||||
<Box key={block.id} className={styles.blockCard} data-category={definition.category}>
|
||||
<Box className={styles.blockHeader}>
|
||||
<Typography className={styles.blockTitle}>{definition.label}</Typography>
|
||||
<Box className={styles.blockActions}>
|
||||
<Tooltip title="Move up">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onMoveBlock(block.id, 'up')}
|
||||
disabled={index === 0}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ArrowUpward fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move down">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onMoveBlock(block.id, 'down')}
|
||||
disabled={index === total - 1}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ArrowDownward fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate block">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDuplicateBlock(block.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ContentCopy fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete block">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemoveBlock(block.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<DeleteIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{renderBlockFields(block, definition, onUpdateField)}
|
||||
{definition.hasChildren &&
|
||||
renderBlockSection('Then', block.children, block.id, 'children', onRequestAddBlock, renderBlockCard)}
|
||||
{definition.hasElseChildren &&
|
||||
renderBlockSection(
|
||||
'Else',
|
||||
block.elseChildren,
|
||||
block.id,
|
||||
'elseChildren',
|
||||
onRequestAddBlock,
|
||||
renderBlockCard
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const renderNestedList = (childBlocks?: LuaBlock[]) => (
|
||||
<BlockList
|
||||
blocks={childBlocks ?? []}
|
||||
blockDefinitionMap={blockDefinitionMap}
|
||||
onRequestAddBlock={onRequestAddBlock}
|
||||
onMoveBlock={onMoveBlock}
|
||||
onDuplicateBlock={onDuplicateBlock}
|
||||
onRemoveBlock={onRemoveBlock}
|
||||
onUpdateField={onUpdateField}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box className={styles.blockStack}>
|
||||
{blocks.map((block, index) => renderBlockCard(block, index, blocks.length))}
|
||||
{blocks.map((block, index) => {
|
||||
const definition = blockDefinitionMap.get(block.type)
|
||||
if (!definition) return null
|
||||
|
||||
return (
|
||||
<BlockItem
|
||||
key={block.id}
|
||||
block={block}
|
||||
definition={definition}
|
||||
index={index}
|
||||
total={blocks.length}
|
||||
onRequestAddBlock={onRequestAddBlock}
|
||||
onMoveBlock={onMoveBlock}
|
||||
onDuplicateBlock={onDuplicateBlock}
|
||||
onRemoveBlock={onRemoveBlock}
|
||||
onUpdateField={onUpdateField}
|
||||
renderNestedList={renderNestedList}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { BlockCategory, BlockDefinition } from '../types'
|
||||
|
||||
const createCategoryIndex = (): Record<BlockCategory, BlockDefinition[]> => ({
|
||||
Basics: [],
|
||||
Logic: [],
|
||||
Loops: [],
|
||||
Data: [],
|
||||
Functions: [],
|
||||
})
|
||||
|
||||
export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
|
||||
const categories = createCategoryIndex()
|
||||
definitions.forEach((definition) => {
|
||||
categories[definition.category].push(definition)
|
||||
})
|
||||
return categories
|
||||
}
|
||||
|
||||
export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) =>
|
||||
new Map(definitions.map((definition) => [definition.type, definition]))
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BlockCategory, BlockDefinition } from '../types'
|
||||
import type { BlockDefinition } from '../types'
|
||||
import { basicBlocks } from './basics'
|
||||
import { dataBlocks } from './data'
|
||||
import { functionBlocks } from './functions'
|
||||
@@ -13,21 +13,4 @@ export const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
||||
...functionBlocks,
|
||||
]
|
||||
|
||||
const createCategoryIndex = (): Record<BlockCategory, BlockDefinition[]> => ({
|
||||
Basics: [],
|
||||
Logic: [],
|
||||
Loops: [],
|
||||
Data: [],
|
||||
Functions: [],
|
||||
})
|
||||
|
||||
export const groupBlockDefinitionsByCategory = (definitions: BlockDefinition[]) => {
|
||||
const categories = createCategoryIndex()
|
||||
definitions.forEach((definition) => {
|
||||
categories[definition.category].push(definition)
|
||||
})
|
||||
return categories
|
||||
}
|
||||
|
||||
export const buildBlockDefinitionMap = (definitions: BlockDefinition[]) =>
|
||||
new Map(definitions.map((definition) => [definition.type, definition]))
|
||||
export { buildBlockDefinitionMap, groupBlockDefinitionsByCategory } from './grouping'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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<LuaScript>) => {
|
||||
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<HTMLElement>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SetStateAction<Record<string, LuaBlock[]>>>
|
||||
setMenuAnchor: Dispatch<SetStateAction<HTMLElement | null>>
|
||||
setMenuTarget: Dispatch<SetStateAction<MenuTarget | null>>
|
||||
setSelectedScriptId: Dispatch<SetStateAction<string | null>>
|
||||
}
|
||||
|
||||
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<LuaScript>) => {
|
||||
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<HTMLElement>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<string, LuaBlock[]>,
|
||||
selectedScriptId: string | null
|
||||
): LuaBlock[] => (selectedScriptId ? blocksByScript[selectedScriptId] || [] : [])
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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<string | null>(
|
||||
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<ModelSchema>) => {
|
||||
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<FieldSchema>) => {
|
||||
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 (
|
||||
<div className="grid md:grid-cols-3 gap-6 h-full">
|
||||
@@ -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)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{schema.label || schema.name}</div>
|
||||
@@ -150,179 +83,13 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev
|
||||
</div>
|
||||
</CardContent>
|
||||
) : (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Edit Model: {currentModel.label}</CardTitle>
|
||||
<CardDescription>Configure model properties and fields</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Model Name (ID)</Label>
|
||||
<Input
|
||||
value={currentModel.name}
|
||||
onChange={(e) => handleUpdateModel({ name: e.target.value })}
|
||||
placeholder="user_model"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Display Label</Label>
|
||||
<Input
|
||||
value={currentModel.label || ''}
|
||||
onChange={(e) => handleUpdateModel({ label: e.target.value })}
|
||||
placeholder="User"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Plural Label</Label>
|
||||
<Input
|
||||
value={currentModel.labelPlural || ''}
|
||||
onChange={(e) => handleUpdateModel({ labelPlural: e.target.value })}
|
||||
placeholder="Users"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Icon Name</Label>
|
||||
<Input
|
||||
value={currentModel.icon || ''}
|
||||
onChange={(e) => handleUpdateModel({ icon: e.target.value })}
|
||||
placeholder="Users"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Label className="text-base">Fields</Label>
|
||||
<Button size="sm" onClick={handleAddField}>
|
||||
<Plus className="mr-2" size={16} />
|
||||
Add Field
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{currentModel.fields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8 border border-dashed rounded-lg">
|
||||
No fields yet. Add a field to start.
|
||||
</p>
|
||||
) : (
|
||||
currentModel.fields.map((field) => (
|
||||
<Card key={field.name} className="border-2">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 flex-1">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Field Name</Label>
|
||||
<Input
|
||||
value={field.name}
|
||||
onChange={(e) =>
|
||||
handleUpdateField(field.name, { name: e.target.value })
|
||||
}
|
||||
placeholder="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
value={field.label || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateField(field.name, { label: e.target.value })
|
||||
}
|
||||
placeholder="Email Address"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(value) =>
|
||||
handleUpdateField(field.name, { type: value as FieldType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">String</SelectItem>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
<SelectItem value="datetime">DateTime</SelectItem>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="url">URL</SelectItem>
|
||||
<SelectItem value="select">Select</SelectItem>
|
||||
<SelectItem value="relation">Relation</SelectItem>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Default Value</Label>
|
||||
<Input
|
||||
value={field.default || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateField(field.name, { default: e.target.value })
|
||||
}
|
||||
placeholder="Default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteField(field.name)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.required || false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateField(field.name, { required: checked })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Required</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.unique || false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateField(field.name, { unique: checked })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Unique</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.editable !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateField(field.name, { editable: checked })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Editable</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.searchable || false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateField(field.name, { searchable: checked })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Searchable</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
<SchemaTabs
|
||||
currentModel={currentModel}
|
||||
onUpdateModel={handleUpdateModel}
|
||||
onAddField={handleAddField}
|
||||
onDeleteField={handleDeleteField}
|
||||
onUpdateField={handleUpdateField}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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<ContactFormField['name'], string>
|
||||
|
||||
export function createInitialContactFormState(): ContactFormState {
|
||||
return contactFormConfig.fields.reduce<ContactFormState>((state, field) => {
|
||||
state[field.name] = ''
|
||||
return state
|
||||
}, {} as ContactFormState)
|
||||
}
|
||||
@@ -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<Record<ContactFormField['name'], string>>
|
||||
|
||||
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<ContactFormState>(
|
||||
createInitialContactFormState()
|
||||
)
|
||||
const [errors, setErrors] = useState<ValidationErrors>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const hasErrors = useMemo(() => Object.keys(errors).length > 0, [errors])
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
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<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<Textarea
|
||||
rows={4}
|
||||
{...commonProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={field.type}
|
||||
{...commonProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{contactFormConfig.title}</CardTitle>
|
||||
<CardDescription>{contactFormConfig.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{contactFormConfig.fields.map(field => (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-sm font-medium" htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-600">*</span>}
|
||||
</label>
|
||||
{field.helperText && (
|
||||
<span className="text-xs text-muted-foreground">{field.helperText}</span>
|
||||
)}
|
||||
</div>
|
||||
{renderField(field)}
|
||||
{errors[field.name] && (
|
||||
<p
|
||||
id={`${field.name}-error`}
|
||||
className="text-xs text-red-600"
|
||||
role="alert"
|
||||
>
|
||||
{errors[field.name]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button className="w-full" type="submit">
|
||||
{contactFormConfig.submitLabel}
|
||||
</Button>
|
||||
{submitted && !hasErrors && (
|
||||
<p className="rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||
<strong className="mr-1">{contactFormConfig.successTitle}.</strong>
|
||||
{contactFormConfig.successMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getScrambledPassword } from '@/lib/auth'
|
||||
import { GodCredentialsBanner } from '../level1/GodCredentialsBanner'
|
||||
import { ChallengePanel } from '../sections/ChallengePanel'
|
||||
|
||||
export function CredentialsSection() {
|
||||
const [showGodCredentials, setShowGodCredentials] = useState(false)
|
||||
const [showSuperGodCredentials, setShowSuperGodCredentials] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showSuperGodPassword, setShowSuperGodPassword] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copiedSuper, setCopiedSuper] = useState(false)
|
||||
const [timeRemaining, setTimeRemaining] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const checkCredentials = async () => {
|
||||
try {
|
||||
const { Database } = await import('@/lib/database')
|
||||
|
||||
const shouldShow = await Database.shouldShowGodCredentials()
|
||||
setShowGodCredentials(shouldShow)
|
||||
|
||||
const superGod = await Database.getSuperGod()
|
||||
const firstLoginFlags = await Database.getFirstLoginFlags()
|
||||
setShowSuperGodCredentials(superGod !== null && firstLoginFlags['supergod'] === true)
|
||||
|
||||
if (shouldShow) {
|
||||
const expiry = await Database.getGodCredentialsExpiry()
|
||||
const updateTimer = () => {
|
||||
const now = Date.now()
|
||||
const diff = expiry - now
|
||||
|
||||
if (diff <= 0) {
|
||||
setShowGodCredentials(false)
|
||||
setTimeRemaining('')
|
||||
return
|
||||
}
|
||||
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const seconds = Math.floor((diff % 60000) / 1000)
|
||||
setTimeRemaining(`${minutes}m ${seconds}s`)
|
||||
}
|
||||
|
||||
updateTimer()
|
||||
interval = setInterval(updateTimer, 1000)
|
||||
}
|
||||
} catch {
|
||||
setShowGodCredentials(false)
|
||||
setShowSuperGodCredentials(false)
|
||||
setTimeRemaining('')
|
||||
}
|
||||
}
|
||||
|
||||
void checkCredentials()
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
await navigator.clipboard.writeText(getScrambledPassword('god'))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleCopySuperGodPassword = async () => {
|
||||
await navigator.clipboard.writeText(getScrambledPassword('supergod'))
|
||||
setCopiedSuper(true)
|
||||
setTimeout(() => setCopiedSuper(false), 2000)
|
||||
}
|
||||
|
||||
if (!showGodCredentials && !showSuperGodCredentials) return null
|
||||
|
||||
return (
|
||||
<ChallengePanel
|
||||
title="Setup Credentials"
|
||||
description="Temporary credentials are available while configuring your environment."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{showSuperGodCredentials && (
|
||||
<GodCredentialsBanner
|
||||
username="supergod"
|
||||
password={getScrambledPassword('supergod')}
|
||||
showPassword={showSuperGodPassword}
|
||||
onTogglePassword={() => setShowSuperGodPassword(!showSuperGodPassword)}
|
||||
copied={copiedSuper}
|
||||
onCopy={handleCopySuperGodPassword}
|
||||
timeRemaining=""
|
||||
variant="supergod"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showGodCredentials && (
|
||||
<GodCredentialsBanner
|
||||
username="god"
|
||||
password={getScrambledPassword('god')}
|
||||
showPassword={showPassword}
|
||||
onTogglePassword={() => setShowPassword(!showPassword)}
|
||||
copied={copied}
|
||||
onCopy={handleCopyPassword}
|
||||
timeRemaining={timeRemaining}
|
||||
variant="god"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ChallengePanel>
|
||||
)
|
||||
}
|
||||
52
frontends/nextjs/src/components/level/level1/Level1Tabs.tsx
Normal file
52
frontends/nextjs/src/components/level/level1/Level1Tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { HeroSection } from '../../level1/HeroSection'
|
||||
import { FeaturesSection } from '../../level1/FeaturesSection'
|
||||
import { ContactSection } from '../../level1/ContactSection'
|
||||
import { ServerStatusPanel } from '../../status/ServerStatusPanel'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import { GitHubActionsFetcher } from '../../misc/github/GitHubActionsFetcher'
|
||||
import { IntroSection } from '../sections/IntroSection'
|
||||
|
||||
interface Level1TabsProps {
|
||||
onNavigate: (level: number) => void
|
||||
}
|
||||
|
||||
export function Level1Tabs({ onNavigate }: Level1TabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="home" className="w-full">
|
||||
<TabsList className="grid w-full max-w-3xl mx-auto grid-cols-3 mb-8">
|
||||
<TabsTrigger value="home">Home</TabsTrigger>
|
||||
<TabsTrigger value="github-actions">GitHub Actions</TabsTrigger>
|
||||
<TabsTrigger value="status">Server Status</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="home" className="mt-0 space-y-12">
|
||||
<HeroSection onNavigate={onNavigate} />
|
||||
<FeaturesSection />
|
||||
<IntroSection
|
||||
id="about"
|
||||
title="About MetaBuilder"
|
||||
description="MetaBuilder is a revolutionary platform that lets you build entire application stacks through visual interfaces."
|
||||
>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Whether you're a designer who wants to create without code, or a developer who wants to work at a higher level of
|
||||
abstraction, MetaBuilder adapts to your needs.
|
||||
</p>
|
||||
</IntroSection>
|
||||
<ContactSection />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="github-actions" className="mt-0">
|
||||
<GitHubActionsFetcher />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="status" className="mt-0">
|
||||
<IntroSection
|
||||
title="Server Status"
|
||||
description="Monitor the DBAL stack, Prisma schema, and the C++ daemon from this interface."
|
||||
>
|
||||
<ServerStatusPanel />
|
||||
</IntroSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IRCWebchatDeclarative } from '../../misc/demos/IRCWebchatDeclarative'
|
||||
import { ResultsPane } from '../sections/ResultsPane'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
interface ChatTabContentProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export function ChatTabContent({ user }: ChatTabContentProps) {
|
||||
return (
|
||||
<ResultsPane
|
||||
title="Webchat"
|
||||
description="Collaborate with other builders in real-time via the IRC channel."
|
||||
>
|
||||
<IRCWebchatDeclarative user={user} channelName="general" />
|
||||
</ResultsPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Textarea } from '@/components/ui'
|
||||
import { CommentsList } from '../../level2/CommentsList'
|
||||
import { ChallengePanel } from '../sections/ChallengePanel'
|
||||
import type { Comment, User } from '@/lib/level-types'
|
||||
|
||||
interface CommentsTabContentProps {
|
||||
comments: Comment[]
|
||||
users: User[]
|
||||
currentUserId: string
|
||||
newComment: string
|
||||
onChangeComment: (value: string) => void
|
||||
onPostComment: () => void
|
||||
onDeleteComment: (commentId: string) => void
|
||||
}
|
||||
|
||||
export function CommentsTabContent({
|
||||
comments,
|
||||
users,
|
||||
currentUserId,
|
||||
newComment,
|
||||
onChangeComment,
|
||||
onPostComment,
|
||||
onDeleteComment,
|
||||
}: CommentsTabContentProps) {
|
||||
const userComments = useMemo(() => comments.filter(c => c.userId === currentUserId), [comments, currentUserId])
|
||||
|
||||
return (
|
||||
<ChallengePanel title="Community" description="Share updates and see what others are building.">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Post a Comment</CardTitle>
|
||||
<CardDescription>Share your thoughts with the community</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
value={newComment}
|
||||
onChange={(e) => onChangeComment(e.target.value)}
|
||||
placeholder="Write your comment here..."
|
||||
rows={4}
|
||||
/>
|
||||
<Button onClick={onPostComment}>Post Comment</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CommentsList
|
||||
comments={userComments}
|
||||
currentUserId={currentUserId}
|
||||
users={users}
|
||||
onDelete={onDeleteComment}
|
||||
variant="my"
|
||||
/>
|
||||
|
||||
<CommentsList
|
||||
comments={comments}
|
||||
currentUserId={currentUserId}
|
||||
users={users}
|
||||
onDelete={onDeleteComment}
|
||||
variant="all"
|
||||
/>
|
||||
</ChallengePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ProfileCard } from '../../level2/ProfileCard'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
interface ProfileTabContentProps {
|
||||
user: User
|
||||
editingProfile: boolean
|
||||
profileForm: { bio: string; email: string }
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
onSave: () => void
|
||||
onFormChange: (value: { bio: string; email: string }) => void
|
||||
onRequestPasswordReset: () => void
|
||||
}
|
||||
|
||||
export function ProfileTabContent({
|
||||
user,
|
||||
editingProfile,
|
||||
profileForm,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
onFormChange,
|
||||
onRequestPasswordReset,
|
||||
}: ProfileTabContentProps) {
|
||||
return (
|
||||
<ProfileCard
|
||||
user={user}
|
||||
editingProfile={editingProfile}
|
||||
profileForm={profileForm}
|
||||
onEdit={onEdit}
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
onFormChange={onFormChange}
|
||||
onRequestPasswordReset={onRequestPasswordReset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
|
||||
import { ChallengePanel } from '../sections/ChallengePanel'
|
||||
import { Trash } from '@phosphor-icons/react'
|
||||
import type { Comment, User } from '@/lib/level-types'
|
||||
|
||||
interface CommentsTableProps {
|
||||
comments: Comment[]
|
||||
users: User[]
|
||||
searchTerm: string
|
||||
onDeleteComment: (commentId: string) => void
|
||||
}
|
||||
|
||||
export function CommentsTable({ comments, users, searchTerm, onDeleteComment }: CommentsTableProps) {
|
||||
const filteredComments = useMemo(
|
||||
() => comments.filter(c => c.content.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
[comments, searchTerm]
|
||||
)
|
||||
|
||||
return (
|
||||
<ChallengePanel title="Comments" description="Moderate community feedback">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Content</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredComments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No comments found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredComments.map((c) => {
|
||||
const commentUser = users.find(u => u.id === c.userId)
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{commentUser?.username || 'Unknown'}</TableCell>
|
||||
<TableCell className="max-w-md truncate">{c.content}</TableCell>
|
||||
<TableCell>{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => onDeleteComment(c.id)}>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ChallengePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
interface EditUserDialogProps {
|
||||
open: boolean
|
||||
user: User | null
|
||||
onClose: (open: boolean) => void
|
||||
onChange: (user: User) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function EditUserDialog({ open, user, onClose, onChange, onSave }: EditUserDialogProps) {
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogDescription>Update user information</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Username</label>
|
||||
<Input value={user.username} onChange={(e) => onChange({ ...user, username: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
onChange={(e) => onChange({ ...user, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Bio</label>
|
||||
<Input value={user.bio || ''} onChange={(e) => onChange({ ...user, bio: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => onClose(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSave}>Save Changes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
35
frontends/nextjs/src/components/level/level3/Level3Stats.tsx
Normal file
35
frontends/nextjs/src/components/level/level3/Level3Stats.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { ChatCircle, Users } from '@phosphor-icons/react'
|
||||
import type { User, Comment } from '@/lib/level-types'
|
||||
|
||||
interface Level3StatsProps {
|
||||
users: User[]
|
||||
comments: Comment[]
|
||||
}
|
||||
|
||||
export function Level3Stats({ users, comments }: Level3StatsProps) {
|
||||
const adminCount = users.filter(u => u.role === 'admin' || u.role === 'god').length
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total Users', value: users.length, icon: Users, helper: 'Registered accounts' },
|
||||
{ label: 'Total Comments', value: comments.length, icon: ChatCircle, helper: 'Posted by users' },
|
||||
{ label: 'Admins', value: adminCount, icon: Users, helper: 'Admin & god users' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.label}</CardTitle>
|
||||
<stat.icon className="text-muted-foreground" size={20} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">{stat.helper}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
frontends/nextjs/src/components/level/level3/UserTable.tsx
Normal file
105
frontends/nextjs/src/components/level/level3/UserTable.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Badge, Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
|
||||
import { ChallengePanel } from '../sections/ChallengePanel'
|
||||
import { MagnifyingGlass, PencilSimple, Trash, Users, ChatCircle } from '@phosphor-icons/react'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
interface UserTableProps {
|
||||
users: User[]
|
||||
searchTerm: string
|
||||
onSearchChange: (value: string) => void
|
||||
onEditUser: (user: User) => void
|
||||
onDeleteUser: (userId: string) => void
|
||||
currentUserId: string
|
||||
commentCount: number
|
||||
commentLabel: string
|
||||
}
|
||||
|
||||
export function UserTable({
|
||||
users,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
onEditUser,
|
||||
onDeleteUser,
|
||||
currentUserId,
|
||||
commentCount,
|
||||
commentLabel,
|
||||
}: UserTableProps) {
|
||||
const filteredUsers = useMemo(
|
||||
() =>
|
||||
users.filter(
|
||||
u => u.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
),
|
||||
[users, searchTerm]
|
||||
)
|
||||
|
||||
return (
|
||||
<ChallengePanel title="Models" description="Browse and manage data models">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="relative">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users size={16} /> {users.length}
|
||||
<ChatCircle size={16} /> {commentLabel} {commentCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.username}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.role === 'god' ? 'default' : u.role === 'admin' ? 'secondary' : 'outline'}>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(u.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => onEditUser(u)}>
|
||||
<PencilSimple size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteUser(u.id)}
|
||||
disabled={u.id === currentUserId}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ChallengePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Button, Input, Label } from '@/components/ui'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
|
||||
interface CreateTenantDialogProps {
|
||||
open: boolean
|
||||
newTenantName: string
|
||||
onChangeTenantName: (value: string) => void
|
||||
onClose: (open: boolean) => void
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
export function CreateTenantDialog({ open, newTenantName, onChangeTenantName, onClose, onCreate }: CreateTenantDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-slate-900 border-white/10 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Tenant</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Create a new tenant instance with its own homepage configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="tenant-name">Tenant Name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
value={newTenantName}
|
||||
onChange={(e) => onChangeTenantName(e.target.value)}
|
||||
placeholder="Enter tenant name"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onClose(false)} className="border-white/20 text-white hover:bg-white/10">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onCreate} className="bg-purple-600 hover:bg-purple-700">
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
104
frontends/nextjs/src/components/level/level5/Level5Navigator.tsx
Normal file
104
frontends/nextjs/src/components/level/level5/Level5Navigator.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import { Buildings, Users, ArrowsLeftRight, Eye, Camera, Warning } from '@phosphor-icons/react'
|
||||
import { ResultsPane } from '../sections/ResultsPane'
|
||||
import { TenantsTab } from '../../level5/tabs/TenantsTab'
|
||||
import { GodUsersTab } from '../../level5/tabs/GodUsersTab'
|
||||
import { PowerTransferTab } from '../../level5/tabs/power-transfer/PowerTransferTab'
|
||||
import { PreviewTab } from '../../level5/tabs/PreviewTab'
|
||||
import { ErrorLogsTab } from '../../level5/tabs/error-logs/ErrorLogsTab'
|
||||
import { ScreenshotAnalyzer } from '../../misc/demos/ScreenshotAnalyzer'
|
||||
import type { AppLevel, Tenant, User } from '@/lib/level-types'
|
||||
|
||||
interface Level5NavigatorProps {
|
||||
tenants: Tenant[]
|
||||
allUsers: User[]
|
||||
godUsers: User[]
|
||||
transferRefresh: number
|
||||
currentUser: User
|
||||
onCreateTenant: () => void
|
||||
onDeleteTenant: (tenantId: string) => void
|
||||
onAssignHomepage: (tenantId: string, pageId: string) => Promise<void>
|
||||
onInitiateTransfer: (userId: string) => void
|
||||
onPreview: (level: AppLevel) => void
|
||||
}
|
||||
|
||||
export function Level5Navigator({
|
||||
tenants,
|
||||
allUsers,
|
||||
godUsers,
|
||||
transferRefresh,
|
||||
currentUser,
|
||||
onCreateTenant,
|
||||
onDeleteTenant,
|
||||
onAssignHomepage,
|
||||
onInitiateTransfer,
|
||||
onPreview,
|
||||
}: Level5NavigatorProps) {
|
||||
return (
|
||||
<ResultsPane title="Navigation" description="Jump between builder levels and administrative tools.">
|
||||
<Tabs defaultValue="tenants" className="space-y-6">
|
||||
<TabsList className="bg-black/40 border border-white/10">
|
||||
<TabsTrigger value="tenants" className="data-[state=active]:bg-purple-600">
|
||||
<Buildings className="w-4 h-4 mr-2" />
|
||||
Tenants
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gods" className="data-[state=active]:bg-purple-600">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
God Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="power" className="data-[state=active]:bg-purple-600">
|
||||
<ArrowsLeftRight className="w-4 h-4 mr-2" />
|
||||
Power Transfer
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="data-[state=active]:bg-purple-600">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview Levels
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="screenshot" className="data-[state=active]:bg-purple-600">
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
Screenshot
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errorlogs" className="data-[state=active]:bg-purple-600">
|
||||
<Warning className="w-4 h-4 mr-2" />
|
||||
Error Logs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tenants" className="space-y-4">
|
||||
<TenantsTab
|
||||
tenants={tenants}
|
||||
allUsers={allUsers}
|
||||
onCreateTenant={onCreateTenant}
|
||||
onDeleteTenant={onDeleteTenant}
|
||||
onAssignHomepage={onAssignHomepage}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gods" className="space-y-4">
|
||||
<GodUsersTab godUsers={godUsers} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="power" className="space-y-4">
|
||||
<PowerTransferTab
|
||||
currentUser={currentUser}
|
||||
allUsers={allUsers}
|
||||
onInitiateTransfer={onInitiateTransfer}
|
||||
refreshSignal={transferRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="space-y-4">
|
||||
<PreviewTab onPreview={onPreview} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="screenshot" className="space-y-4">
|
||||
<ScreenshotAnalyzer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errorlogs" className="space-y-4">
|
||||
<ErrorLogsTab user={currentUser} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ResultsPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui'
|
||||
import { Crown } from '@phosphor-icons/react'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
interface TransferConfirmDialogProps {
|
||||
open: boolean
|
||||
allUsers: User[]
|
||||
selectedUserId: string
|
||||
onClose: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function TransferConfirmDialog({ open, allUsers, selectedUserId, onClose, onConfirm }: TransferConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onClose}>
|
||||
<AlertDialogContent className="bg-slate-900 border-white/10 text-white">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-amber-300">
|
||||
<Crown className="w-6 h-6" weight="fill" />
|
||||
Confirm Power Transfer
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400">
|
||||
Are you absolutely sure? This will transfer your Super God privileges to{' '}
|
||||
<span className="font-semibold text-white">{allUsers.find(u => u.id === selectedUserId)?.username}</span>.
|
||||
You will be downgraded to God level and cannot reverse this action.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-white/20 text-white hover:bg-white/10">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-gradient-to-r from-amber-600 to-yellow-600 hover:from-amber-700 hover:to-yellow-700"
|
||||
>
|
||||
Transfer Power
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,209 +1,25 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* Level1 Component - Public/Unauthenticated Interface
|
||||
*
|
||||
* The Level1 component serves as the main entry point for unauthenticated users.
|
||||
* It displays the public-facing interface with features, hero sections, and
|
||||
* navigation to login or other public pages.
|
||||
*
|
||||
* Key Features:
|
||||
* - Navigation bar for public users
|
||||
* - Hero section with marketing content
|
||||
* - Features overview
|
||||
* - Contact form
|
||||
* - Credentials display (god/supergod) during setup
|
||||
* - Login prompt for authenticated access
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getScrambledPassword } from '@/lib/auth'
|
||||
import { useState } from 'react'
|
||||
import { NavigationBar } from '../../level1/NavigationBar'
|
||||
import { GodCredentialsBanner } from '../../level1/GodCredentialsBanner'
|
||||
import { HeroSection } from '../../level1/HeroSection'
|
||||
import { FeaturesSection } from '../../level1/FeaturesSection'
|
||||
import { ContactSection } from '../../level1/ContactSection'
|
||||
import { AppFooter } from '../../shared/AppFooter'
|
||||
import { ServerStatusPanel } from '../../status/ServerStatusPanel'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import { GitHubActionsFetcher } from '../../misc/github/GitHubActionsFetcher'
|
||||
import { CredentialsSection } from '../level1/CredentialsSection'
|
||||
import { Level1Tabs } from '../level1/Level1Tabs'
|
||||
|
||||
// Props for Level1 component
|
||||
interface Level1Props {
|
||||
// Callback when user navigates to another level
|
||||
onNavigate: (level: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Level1 - Public interface component
|
||||
* @param props - Component props
|
||||
*/
|
||||
export function Level1({ onNavigate }: Level1Props) {
|
||||
// Menu visibility state
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Show god credentials banner during setup
|
||||
const [showGodCredentials, setShowGodCredentials] = useState(false)
|
||||
// Show supergod credentials banner during setup
|
||||
const [showSuperGodCredentials, setShowSuperGodCredentials] = useState(false)
|
||||
// Password visibility toggle for god credentials
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
// Password visibility toggle for supergod credentials
|
||||
const [showSuperGodPassword, setShowSuperGodPassword] = useState(false)
|
||||
// Track clipboard copy state for god credentials
|
||||
const [copied, setCopied] = useState(false)
|
||||
// Track clipboard copy state for supergod credentials
|
||||
const [copiedSuper, setCopiedSuper] = useState(false)
|
||||
// Display remaining time for god credentials expiry
|
||||
const [timeRemaining, setTimeRemaining] = useState('')
|
||||
|
||||
// Initialize component state on mount
|
||||
useEffect(() => {
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const checkCredentials = async () => {
|
||||
try {
|
||||
const { Database } = await import('@/lib/database')
|
||||
|
||||
// Check if god credentials should be displayed
|
||||
const shouldShow = await Database.shouldShowGodCredentials()
|
||||
setShowGodCredentials(shouldShow)
|
||||
|
||||
// Get supergod account if exists
|
||||
const superGod = await Database.getSuperGod()
|
||||
const firstLoginFlags = await Database.getFirstLoginFlags()
|
||||
setShowSuperGodCredentials(superGod !== null && firstLoginFlags['supergod'] === true)
|
||||
|
||||
// Update timer for god credentials expiry
|
||||
if (shouldShow) {
|
||||
const expiry = await Database.getGodCredentialsExpiry()
|
||||
const updateTimer = () => {
|
||||
const now = Date.now()
|
||||
const diff = expiry - now
|
||||
|
||||
// Hide credentials when expired
|
||||
if (diff <= 0) {
|
||||
setShowGodCredentials(false)
|
||||
setTimeRemaining('')
|
||||
return
|
||||
}
|
||||
|
||||
// Display remaining time in minutes and seconds
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const seconds = Math.floor((diff % 60000) / 1000)
|
||||
setTimeRemaining(`${minutes}m ${seconds}s`)
|
||||
}
|
||||
|
||||
updateTimer()
|
||||
interval = setInterval(updateTimer, 1000)
|
||||
}
|
||||
} catch {
|
||||
setShowGodCredentials(false)
|
||||
setShowSuperGodCredentials(false)
|
||||
setTimeRemaining('')
|
||||
}
|
||||
}
|
||||
|
||||
void checkCredentials()
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
await navigator.clipboard.writeText(getScrambledPassword('god'))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleCopySuperGodPassword = async () => {
|
||||
await navigator.clipboard.writeText(getScrambledPassword('supergod'))
|
||||
setCopiedSuper(true)
|
||||
setTimeout(() => setCopiedSuper(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary/5 via-background to-accent/5">
|
||||
<NavigationBar menuOpen={menuOpen} setMenuOpen={setMenuOpen} onNavigate={onNavigate} />
|
||||
|
||||
{(showGodCredentials || showSuperGodCredentials) && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-6 space-y-4">
|
||||
{showSuperGodCredentials && (
|
||||
<GodCredentialsBanner
|
||||
username="supergod"
|
||||
password={getScrambledPassword('supergod')}
|
||||
showPassword={showSuperGodPassword}
|
||||
onTogglePassword={() => setShowSuperGodPassword(!showSuperGodPassword)}
|
||||
copied={copiedSuper}
|
||||
onCopy={handleCopySuperGodPassword}
|
||||
timeRemaining=""
|
||||
variant="supergod"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showGodCredentials && (
|
||||
<GodCredentialsBanner
|
||||
username="god"
|
||||
password={getScrambledPassword('god')}
|
||||
showPassword={showPassword}
|
||||
onTogglePassword={() => setShowPassword(!showPassword)}
|
||||
copied={copied}
|
||||
onCopy={handleCopyPassword}
|
||||
timeRemaining={timeRemaining}
|
||||
variant="god"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-6">
|
||||
<Tabs defaultValue="home" className="w-full">
|
||||
<TabsList className="grid w-full max-w-3xl mx-auto grid-cols-3 mb-8">
|
||||
<TabsTrigger value="home">Home</TabsTrigger>
|
||||
<TabsTrigger value="github-actions">GitHub Actions</TabsTrigger>
|
||||
<TabsTrigger value="status">Server Status</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="home" className="mt-0">
|
||||
<HeroSection onNavigate={onNavigate} />
|
||||
<FeaturesSection />
|
||||
|
||||
<section id="about" className="bg-muted/30 py-20 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h2 className="text-3xl font-bold">About MetaBuilder</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
MetaBuilder is a revolutionary platform that lets you build entire application stacks
|
||||
through visual interfaces. From public websites to complex admin panels, everything
|
||||
is generated from declarative configurations, workflows, and embedded scripts.
|
||||
</p>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Whether you're a designer who wants to create without code, or a developer who wants
|
||||
to work at a higher level of abstraction, MetaBuilder adapts to your needs.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactSection />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="github-actions" className="mt-0">
|
||||
<GitHubActionsFetcher />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="status" className="mt-0">
|
||||
<section className="space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Runtime observability</p>
|
||||
<h2 className="text-3xl font-bold">Server Status</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">
|
||||
Monitor the DBAL stack, Prisma schema, and the C++ daemon from this interface—so you
|
||||
can see how the daemon is progressing toward production readiness.
|
||||
</p>
|
||||
</div>
|
||||
<ServerStatusPanel />
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-6 space-y-6">
|
||||
<CredentialsSection />
|
||||
<Level1Tabs onNavigate={onNavigate} />
|
||||
</div>
|
||||
|
||||
<AppFooter />
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Textarea } from '@/components/ui'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import { User, ChatCircle } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { Database, hashPassword } from '@/lib/database'
|
||||
import { generateScrambledPassword, simulateEmailSend } from '@/lib/password-utils'
|
||||
import { IRCWebchatDeclarative } from '../../misc/demos/IRCWebchatDeclarative'
|
||||
import { ProfileCard } from '../../level2/ProfileCard'
|
||||
import { CommentsList } from '../../level2/CommentsList'
|
||||
import { AppHeader } from '../../shared/AppHeader'
|
||||
import type { User as UserType, Comment } from '@/lib/level-types'
|
||||
import type { User as UserType } from '@/lib/level-types'
|
||||
import { IntroSection } from '../sections/IntroSection'
|
||||
import { ProfileTabContent } from '../level2/ProfileTabContent'
|
||||
import { CommentsTabContent } from '../level2/CommentsTabContent'
|
||||
import { ChatTabContent } from '../level2/ChatTabContent'
|
||||
import { useLevel2State } from './hooks/useLevel2State'
|
||||
|
||||
export interface Level2Props {
|
||||
user: UserType
|
||||
@@ -22,87 +17,21 @@ export interface Level2Props {
|
||||
}
|
||||
|
||||
export function Level2({ user, onLogout, onNavigate }: Level2Props) {
|
||||
const [currentUser, setCurrentUser] = useState<UserType>(user)
|
||||
const [users, setUsers] = useState<UserType[]>([])
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [editingProfile, setEditingProfile] = useState(false)
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
bio: user.bio || '',
|
||||
email: user.email,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const loadedUsers = await Database.getUsers({ scope: 'all' })
|
||||
setUsers(loadedUsers)
|
||||
const foundUser = loadedUsers.find(u => u.id === user.id)
|
||||
if (foundUser) {
|
||||
setCurrentUser(foundUser)
|
||||
setProfileForm({
|
||||
bio: foundUser.bio || '',
|
||||
email: foundUser.email,
|
||||
})
|
||||
}
|
||||
const loadedComments = await Database.getComments()
|
||||
setComments(loadedComments)
|
||||
}
|
||||
loadData()
|
||||
}, [user.id])
|
||||
|
||||
const handleProfileSave = async () => {
|
||||
await Database.updateUser(user.id, {
|
||||
bio: profileForm.bio,
|
||||
email: profileForm.email,
|
||||
})
|
||||
setCurrentUser({ ...currentUser, bio: profileForm.bio, email: profileForm.email })
|
||||
setEditingProfile(false)
|
||||
toast.success('Profile updated successfully')
|
||||
}
|
||||
|
||||
const handlePostComment = async () => {
|
||||
if (!newComment.trim()) {
|
||||
toast.error('Comment cannot be empty')
|
||||
return
|
||||
}
|
||||
|
||||
const comment: Comment = {
|
||||
id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
userId: user.id,
|
||||
content: newComment,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
await Database.addComment(comment)
|
||||
setComments((current) => [...current, comment])
|
||||
setNewComment('')
|
||||
toast.success('Comment posted')
|
||||
}
|
||||
|
||||
const handleDeleteComment = async (commentId: string) => {
|
||||
await Database.deleteComment(commentId)
|
||||
setComments((current) => current.filter(c => c.id !== commentId))
|
||||
toast.success('Comment deleted')
|
||||
}
|
||||
|
||||
const handleRequestPasswordReset = async () => {
|
||||
const newPassword = generateScrambledPassword(16)
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
await Database.setCredential(currentUser.username, passwordHash)
|
||||
|
||||
const smtpConfig = await Database.getSMTPConfig()
|
||||
await simulateEmailSend(
|
||||
currentUser.email,
|
||||
'Your New MetaBuilder Password',
|
||||
`Your password has been reset at your request.\n\nUsername: ${currentUser.username}\nNew Password: ${newPassword}\n\nPlease login with this password and change it from your profile settings if desired.`,
|
||||
smtpConfig || undefined
|
||||
)
|
||||
|
||||
toast.success('New password sent to your email! Check console (simulated email)')
|
||||
}
|
||||
|
||||
const userComments = comments.filter(c => c.userId === user.id)
|
||||
const allComments = comments
|
||||
const {
|
||||
comments,
|
||||
currentUser,
|
||||
editingProfile,
|
||||
newComment,
|
||||
profileForm,
|
||||
users,
|
||||
setEditingProfile,
|
||||
setNewComment,
|
||||
setProfileForm,
|
||||
handleDeleteComment,
|
||||
handlePostComment,
|
||||
handleProfileSave,
|
||||
handleRequestPasswordReset,
|
||||
} = useLevel2State(user as User)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -114,8 +43,12 @@ export function Level2({ user, onLogout, onNavigate }: Level2Props) {
|
||||
variant="user"
|
||||
/>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">User Dashboard</h1>
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
<IntroSection
|
||||
eyebrow="Level 2"
|
||||
title="User Dashboard"
|
||||
description="Manage your profile, collaborate with the community, and explore live chat."
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-lg">
|
||||
@@ -134,7 +67,7 @@ export function Level2({ user, onLogout, onNavigate }: Level2Props) {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
<ProfileCard
|
||||
<ProfileTabContent
|
||||
user={currentUser}
|
||||
editingProfile={editingProfile}
|
||||
profileForm={profileForm}
|
||||
@@ -147,41 +80,19 @@ export function Level2({ user, onLogout, onNavigate }: Level2Props) {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comments" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Post a Comment</CardTitle>
|
||||
<CardDescription>Share your thoughts with the community</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Write your comment here..."
|
||||
rows={4}
|
||||
/>
|
||||
<Button onClick={handlePostComment}>Post Comment</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CommentsList
|
||||
comments={userComments}
|
||||
currentUserId={user.id}
|
||||
<CommentsTabContent
|
||||
comments={comments}
|
||||
users={users}
|
||||
onDelete={handleDeleteComment}
|
||||
variant="my"
|
||||
/>
|
||||
|
||||
<CommentsList
|
||||
comments={allComments}
|
||||
currentUserId={user.id}
|
||||
users={users}
|
||||
onDelete={handleDeleteComment}
|
||||
variant="all"
|
||||
newComment={newComment}
|
||||
onChangeComment={setNewComment}
|
||||
onPostComment={handlePostComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chat" className="space-y-6">
|
||||
<IRCWebchatDeclarative user={currentUser} channelName="general" />
|
||||
<ChatTabContent user={currentUser} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import { MagnifyingGlass, Plus, PencilSimple, Trash, Users, ChatCircle } from '@phosphor-icons/react'
|
||||
import { Users, ChatCircle } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { getUsers, deleteUser, updateUser } from '@/lib/db/users'
|
||||
import { getComments, deleteComment } from '@/lib/db/comments'
|
||||
import { AppHeader } from '../../shared/AppHeader'
|
||||
import type { User as UserType, Comment } from '@/lib/level-types'
|
||||
import type { ModelSchema } from '@/lib/schema-types'
|
||||
import { IntroSection } from '../sections/IntroSection'
|
||||
import { Level3Stats } from '../level3/Level3Stats'
|
||||
import { UserTable } from '../level3/UserTable'
|
||||
import { CommentsTable } from '../level3/CommentsTable'
|
||||
import { EditUserDialog } from '../level3/EditUserDialog'
|
||||
|
||||
interface Level3Props {
|
||||
user: UserType
|
||||
@@ -39,8 +25,8 @@ export function Level3({ user, onLogout, onNavigate }: Level3Props) {
|
||||
const [users, setUsers] = useState<UserType[]>([])
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState<'users' | 'comments'>('users')
|
||||
const [editingItem, setEditingItem] = useState<any>(null)
|
||||
const [selectedModel, setSelectedModel] = useState<ModelSchema>('users' as ModelSchema)
|
||||
const [editingItem, setEditingItem] = useState<UserType | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,21 +39,9 @@ export function Level3({ user, onLogout, onNavigate }: Level3Props) {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const allUsers = users
|
||||
const allComments = comments
|
||||
|
||||
const filteredUsers = allUsers.filter(u =>
|
||||
u.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
const filteredComments = allComments.filter(c =>
|
||||
c.content.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
if (userId === user.id) {
|
||||
toast.error("You cannot delete your own account")
|
||||
toast.error('You cannot delete your own account')
|
||||
return
|
||||
}
|
||||
await deleteUser(userId)
|
||||
@@ -88,11 +62,9 @@ export function Level3({ user, onLogout, onNavigate }: Level3Props) {
|
||||
|
||||
const handleSaveUser = async () => {
|
||||
if (!editingItem) return
|
||||
|
||||
|
||||
await updateUser(editingItem.id, editingItem)
|
||||
setUsers((current) =>
|
||||
current.map(u => u.id === editingItem.id ? editingItem : u)
|
||||
)
|
||||
setUsers((current) => current.map(u => u.id === editingItem.id ? editingItem : u))
|
||||
setDialogOpen(false)
|
||||
setEditingItem(null)
|
||||
toast.success('User updated')
|
||||
@@ -109,227 +81,58 @@ export function Level3({ user, onLogout, onNavigate }: Level3Props) {
|
||||
variant="admin"
|
||||
/>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Data Management</h1>
|
||||
<p className="text-muted-foreground">Manage all application data and users</p>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
<IntroSection
|
||||
eyebrow="Level 3"
|
||||
title="Data Management"
|
||||
description="Manage all application data and users from a single dashboard."
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="text-muted-foreground" size={20} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{allUsers.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Registered accounts</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Level3Stats users={users} comments={comments} />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Comments</CardTitle>
|
||||
<ChatCircle className="text-muted-foreground" size={20} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{allComments.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Posted by users</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Tabs value={selectedModel} onValueChange={(v) => setSelectedModel(v as ModelSchema)}>
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-md">
|
||||
<TabsTrigger value="users">
|
||||
<Users className="mr-2" size={16} />
|
||||
Users ({users.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="comments">
|
||||
<ChatCircle className="mr-2" size={16} />
|
||||
Comments ({comments.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Admins</CardTitle>
|
||||
<Users className="text-muted-foreground" size={20} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{allUsers.filter(u => u.role === 'admin' || u.role === 'god').length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Admin & god users</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<TabsContent value="users" className="mt-6">
|
||||
<UserTable
|
||||
users={users}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onEditUser={handleEditUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
currentUserId={user.id}
|
||||
commentCount={comments.length}
|
||||
commentLabel="Comments"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Models</CardTitle>
|
||||
<CardDescription>Browse and manage data models</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={selectedModel} onValueChange={(v) => setSelectedModel(v as any)}>
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-md">
|
||||
<TabsTrigger value="users">
|
||||
<Users className="mr-2" size={16} />
|
||||
Users ({allUsers.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="comments">
|
||||
<ChatCircle className="mr-2" size={16} />
|
||||
Comments ({allComments.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="mt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.username}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.role === 'god' ? 'default' : u.role === 'admin' ? 'secondary' : 'outline'}>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(u.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditUser(u)}
|
||||
>
|
||||
<PencilSimple size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(u.id)}
|
||||
disabled={u.id === user.id}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comments" className="mt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Content</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredComments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No comments found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredComments.map((c) => {
|
||||
const commentUser = allUsers.find(u => u.id === c.userId)
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">
|
||||
{commentUser?.username || 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md truncate">{c.content}</TableCell>
|
||||
<TableCell>{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteComment(c.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="comments" className="mt-6">
|
||||
<CommentsTable
|
||||
comments={comments}
|
||||
users={users}
|
||||
searchTerm={searchTerm}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogDescription>Update user information</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingItem && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Username</label>
|
||||
<Input
|
||||
value={editingItem.username}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={editingItem.email}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Bio</label>
|
||||
<Input
|
||||
value={editingItem.bio || ''}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, bio: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveUser}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<EditUserDialog
|
||||
open={dialogOpen}
|
||||
user={editingItem}
|
||||
onClose={setDialogOpen}
|
||||
onChange={(item) => setEditingItem(item)}
|
||||
onSave={handleSaveUser}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Level4Summary } from '../../level4/Level4Summary'
|
||||
import { NerdModeIDE } from '../../misc/NerdModeIDE'
|
||||
import type { User as UserType } from '@/lib/level-types'
|
||||
import { useLevel4AppState } from './hooks/useLevel4AppState'
|
||||
import { IntroSection } from '../sections/IntroSection'
|
||||
|
||||
interface Level4Props {
|
||||
user: UserType
|
||||
@@ -42,19 +43,20 @@ export function Level4({ user, onLogout, onNavigate, onPreview }: Level4Props) {
|
||||
onImportConfig={handleImportConfig}
|
||||
/>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Application Builder</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{nerdMode
|
||||
? "Design your application declaratively. Define schemas, create workflows, and write Lua scripts."
|
||||
: "Build your application visually. Configure pages, users, and data models with simple forms."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
<IntroSection
|
||||
eyebrow="Level 4"
|
||||
title="Application Builder"
|
||||
description={
|
||||
nerdMode
|
||||
? 'Design your application declaratively. Define schemas, create workflows, and write Lua scripts.'
|
||||
: 'Build your application visually. Configure pages, users, and data models with simple forms.'
|
||||
}
|
||||
/>
|
||||
|
||||
<Level4Tabs
|
||||
appConfig={appConfig}
|
||||
user={user}
|
||||
nerdMode={nerdMode}
|
||||
onSchemasChange={handleSchemasChange}
|
||||
onWorkflowsChange={handleWorkflowsChange}
|
||||
|
||||
@@ -1,43 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Label } from '@/components/ui'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui'
|
||||
import { Crown, Buildings, Users, ArrowsLeftRight, Eye, Camera, Warning } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { Level5Header } from '../../level5/header/Level5Header'
|
||||
import { TenantsTab } from '../../level5/tabs/TenantsTab'
|
||||
import { GodUsersTab } from '../../level5/tabs/GodUsersTab'
|
||||
import { PowerTransferTab } from '../../level5/tabs/power-transfer/PowerTransferTab'
|
||||
import { PreviewTab } from '../../level5/tabs/PreviewTab'
|
||||
import { ErrorLogsTab } from '../../level5/tabs/error-logs/ErrorLogsTab'
|
||||
import { ScreenshotAnalyzer } from '../../misc/demos/ScreenshotAnalyzer'
|
||||
import { NerdModeIDE } from '../../misc/NerdModeIDE'
|
||||
import type { User, AppLevel, Tenant } from '@/lib/level-types'
|
||||
import { Database } from '@/lib/database'
|
||||
import { createPowerTransferRequest } from '@/lib/api/power-transfers'
|
||||
import { fetchUsers } from '@/lib/api/users/fetch-users'
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import type { User, AppLevel } from '@/lib/level-types'
|
||||
import { IntroSection } from '../sections/IntroSection'
|
||||
import { useLevel5State } from './hooks/useLevel5State'
|
||||
import { Level5Navigator } from '../level5/Level5Navigator'
|
||||
import { CreateTenantDialog } from '../level5/CreateTenantDialog'
|
||||
import { TransferConfirmDialog } from '../level5/TransferConfirmDialog'
|
||||
|
||||
export interface Level5Props {
|
||||
user: User
|
||||
@@ -46,113 +16,28 @@ export interface Level5Props {
|
||||
onPreview: (level: AppLevel) => void
|
||||
}
|
||||
|
||||
export function Level5({ user, onLogout, onNavigate, onPreview }: Level5Props) {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||
const [godUsers, setGodUsers] = useState<User[]>([])
|
||||
const [transferRefresh, setTransferRefresh] = useState(0)
|
||||
const [showTransferDialog, setShowTransferDialog] = useState(false)
|
||||
const [showConfirmTransfer, setShowConfirmTransfer] = useState(false)
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [newTenantName, setNewTenantName] = useState('')
|
||||
const [showCreateTenant, setShowCreateTenant] = useState(false)
|
||||
const [nerdMode, setNerdMode] = useKV<boolean>('level5-nerd-mode', false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
const [tenantsData, usersData] = await Promise.all([
|
||||
Database.getTenants(),
|
||||
fetchUsers(),
|
||||
])
|
||||
|
||||
setTenants(tenantsData)
|
||||
setAllUsers(usersData)
|
||||
setGodUsers(usersData.filter(u => u.role === 'god'))
|
||||
}
|
||||
|
||||
const handleCreateTenant = async () => {
|
||||
if (!newTenantName.trim()) {
|
||||
toast.error('Tenant name is required')
|
||||
return
|
||||
}
|
||||
|
||||
const newTenant: Tenant = {
|
||||
id: `tenant_${Date.now()}`,
|
||||
name: newTenantName,
|
||||
ownerId: user.id,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
await Database.addTenant(newTenant)
|
||||
setTenants(current => [...current, newTenant])
|
||||
setNewTenantName('')
|
||||
setShowCreateTenant(false)
|
||||
toast.success('Tenant created successfully')
|
||||
}
|
||||
|
||||
const handleAssignHomepage = async (tenantId: string, pageId: string) => {
|
||||
await Database.updateTenant(tenantId, {
|
||||
homepageConfig: { pageId },
|
||||
})
|
||||
await loadData()
|
||||
toast.success('Homepage assigned to tenant')
|
||||
}
|
||||
|
||||
const handleInitiateTransfer = (userId: string) => {
|
||||
if (!userId) {
|
||||
toast.error('Please select a user to transfer power to')
|
||||
return
|
||||
}
|
||||
setSelectedUserId(userId)
|
||||
setShowConfirmTransfer(true)
|
||||
}
|
||||
|
||||
const handleConfirmTransfer = async () => {
|
||||
if (!selectedUserId) return
|
||||
|
||||
const targetUser = allUsers.find((u) => u.id === selectedUserId)
|
||||
if (!targetUser) {
|
||||
toast.error('Selected user not found')
|
||||
setShowConfirmTransfer(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createPowerTransferRequest({
|
||||
fromUserId: user.id,
|
||||
toUserId: selectedUserId,
|
||||
})
|
||||
|
||||
toast.success(
|
||||
`Power transferred to ${targetUser.username}. You are now a God user and will be logged out shortly.`
|
||||
)
|
||||
setTransferRefresh((prev) => prev + 1)
|
||||
await loadData()
|
||||
|
||||
setTimeout(() => {
|
||||
onLogout()
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
toast.error('Failed to transfer power: ' + (error as Error).message)
|
||||
} finally {
|
||||
setShowConfirmTransfer(false)
|
||||
setSelectedUserId('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTenant = async (tenantId: string) => {
|
||||
await Database.deleteTenant(tenantId)
|
||||
setTenants(current => current.filter(t => t.id !== tenantId))
|
||||
toast.success('Tenant deleted')
|
||||
}
|
||||
|
||||
const handleToggleNerdMode = () => {
|
||||
setNerdMode(!nerdMode)
|
||||
toast.info(nerdMode ? 'Nerd Mode disabled' : 'Nerd Mode enabled')
|
||||
}
|
||||
export function Level5({ user, onLogout, onNavigate: _onNavigate, onPreview }: Level5Props) {
|
||||
const {
|
||||
tenants,
|
||||
allUsers,
|
||||
godUsers,
|
||||
transferRefresh,
|
||||
showConfirmTransfer,
|
||||
selectedUserId,
|
||||
newTenantName,
|
||||
showCreateTenant,
|
||||
nerdMode,
|
||||
setNewTenantName,
|
||||
setShowCreateTenant,
|
||||
setShowConfirmTransfer,
|
||||
setSelectedUserId,
|
||||
handleAssignHomepage,
|
||||
handleConfirmTransfer,
|
||||
handleCreateTenant,
|
||||
handleDeleteTenant,
|
||||
handleInitiateTransfer,
|
||||
handleToggleNerdMode,
|
||||
} = useLevel5State({ user, onLogout })
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-950 via-slate-900 to-indigo-950">
|
||||
@@ -163,130 +48,42 @@ export function Level5({ user, onLogout, onNavigate, onPreview }: Level5Props) {
|
||||
onToggleNerdMode={handleToggleNerdMode}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<Tabs defaultValue="tenants" className="space-y-6">
|
||||
<TabsList className="bg-black/40 border border-white/10">
|
||||
<TabsTrigger value="tenants" className="data-[state=active]:bg-purple-600">
|
||||
<Buildings className="w-4 h-4 mr-2" />
|
||||
Tenants
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gods" className="data-[state=active]:bg-purple-600">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
God Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="power" className="data-[state=active]:bg-purple-600">
|
||||
<ArrowsLeftRight className="w-4 h-4 mr-2" />
|
||||
Power Transfer
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="data-[state=active]:bg-purple-600">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview Levels
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="screenshot" className="data-[state=active]:bg-purple-600">
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
Screenshot
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errorlogs" className="data-[state=active]:bg-purple-600">
|
||||
<Warning className="w-4 h-4 mr-2" />
|
||||
Error Logs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<main className="max-w-7xl mx-auto px-4 py-8 space-y-8">
|
||||
<IntroSection
|
||||
eyebrow="Level 5"
|
||||
title="Super God Panel"
|
||||
description="Govern tenants, manage god users, and handle cross-level operations."
|
||||
/>
|
||||
|
||||
<TabsContent value="tenants" className="space-y-4">
|
||||
<TenantsTab
|
||||
tenants={tenants}
|
||||
allUsers={allUsers}
|
||||
onCreateTenant={() => setShowCreateTenant(true)}
|
||||
onDeleteTenant={handleDeleteTenant}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gods" className="space-y-4">
|
||||
<GodUsersTab godUsers={godUsers} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="power" className="space-y-4">
|
||||
<PowerTransferTab
|
||||
currentUser={user}
|
||||
allUsers={allUsers}
|
||||
onInitiateTransfer={handleInitiateTransfer}
|
||||
refreshSignal={transferRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="space-y-4">
|
||||
<PreviewTab onPreview={onPreview} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="screenshot" className="space-y-4">
|
||||
<ScreenshotAnalyzer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errorlogs" className="space-y-4">
|
||||
<ErrorLogsTab user={user} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Level5Navigator
|
||||
tenants={tenants}
|
||||
allUsers={allUsers}
|
||||
godUsers={godUsers}
|
||||
transferRefresh={transferRefresh}
|
||||
currentUser={user}
|
||||
onCreateTenant={() => setShowCreateTenant(true)}
|
||||
onDeleteTenant={handleDeleteTenant}
|
||||
onAssignHomepage={handleAssignHomepage}
|
||||
onInitiateTransfer={handleInitiateTransfer}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<Dialog open={showCreateTenant} onOpenChange={setShowCreateTenant}>
|
||||
<DialogContent className="bg-slate-900 border-white/10 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Tenant</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Create a new tenant instance with its own homepage configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="tenant-name">Tenant Name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
value={newTenantName}
|
||||
onChange={(e) => setNewTenantName(e.target.value)}
|
||||
placeholder="Enter tenant name"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateTenant(false)} className="border-white/20 text-white hover:bg-white/10">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateTenant} className="bg-purple-600 hover:bg-purple-700">
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CreateTenantDialog
|
||||
open={showCreateTenant}
|
||||
newTenantName={newTenantName}
|
||||
onChangeTenantName={setNewTenantName}
|
||||
onClose={setShowCreateTenant}
|
||||
onCreate={handleCreateTenant}
|
||||
/>
|
||||
|
||||
<AlertDialog open={showConfirmTransfer} onOpenChange={setShowConfirmTransfer}>
|
||||
<AlertDialogContent className="bg-slate-900 border-white/10 text-white">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-amber-300">
|
||||
<Crown className="w-6 h-6" weight="fill" />
|
||||
Confirm Power Transfer
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400">
|
||||
Are you absolutely sure? This will transfer your Super God privileges to{' '}
|
||||
<span className="font-semibold text-white">
|
||||
{allUsers.find(u => u.id === selectedUserId)?.username}
|
||||
</span>
|
||||
. You will be downgraded to God level and cannot reverse this action.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-white/20 text-white hover:bg-white/10">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmTransfer}
|
||||
className="bg-gradient-to-r from-amber-600 to-yellow-600 hover:from-amber-700 hover:to-yellow-700"
|
||||
>
|
||||
Transfer Power
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<TransferConfirmDialog
|
||||
open={showConfirmTransfer}
|
||||
allUsers={allUsers}
|
||||
selectedUserId={selectedUserId}
|
||||
onClose={setShowConfirmTransfer}
|
||||
onConfirm={handleConfirmTransfer}
|
||||
/>
|
||||
|
||||
{nerdMode && (
|
||||
<div className="fixed bottom-4 right-4 w-[calc(100%-2rem)] max-w-[1400px] h-[600px] z-50 shadow-2xl">
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Database, hashPassword } from '@/lib/database'
|
||||
import { generateScrambledPassword, simulateEmailSend } from '@/lib/password-utils'
|
||||
import type { Comment, User } from '@/lib/level-types'
|
||||
|
||||
export function useLevel2State(user: User) {
|
||||
const [currentUser, setCurrentUser] = useState<User>(user)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [editingProfile, setEditingProfile] = useState(false)
|
||||
const [profileForm, setProfileForm] = useState({ bio: user.bio || '', email: user.email })
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const loadedUsers = await Database.getUsers({ scope: 'all' })
|
||||
setUsers(loadedUsers)
|
||||
const foundUser = loadedUsers.find(u => u.id === user.id)
|
||||
if (foundUser) {
|
||||
setCurrentUser(foundUser)
|
||||
setProfileForm({ bio: foundUser.bio || '', email: foundUser.email })
|
||||
}
|
||||
const loadedComments = await Database.getComments()
|
||||
setComments(loadedComments)
|
||||
}
|
||||
void loadData()
|
||||
}, [user.id])
|
||||
|
||||
const handleProfileSave = async () => {
|
||||
await Database.updateUser(user.id, { bio: profileForm.bio, email: profileForm.email })
|
||||
setCurrentUser({ ...currentUser, bio: profileForm.bio, email: profileForm.email })
|
||||
setEditingProfile(false)
|
||||
toast.success('Profile updated successfully')
|
||||
}
|
||||
|
||||
const handlePostComment = async () => {
|
||||
if (!newComment.trim()) {
|
||||
toast.error('Comment cannot be empty')
|
||||
return
|
||||
}
|
||||
|
||||
const comment: Comment = {
|
||||
id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
userId: user.id,
|
||||
content: newComment,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
await Database.addComment(comment)
|
||||
setComments((current) => [...current, comment])
|
||||
setNewComment('')
|
||||
toast.success('Comment posted')
|
||||
}
|
||||
|
||||
const handleDeleteComment = async (commentId: string) => {
|
||||
await Database.deleteComment(commentId)
|
||||
setComments((current) => current.filter(c => c.id !== commentId))
|
||||
toast.success('Comment deleted')
|
||||
}
|
||||
|
||||
const handleRequestPasswordReset = async () => {
|
||||
const newPassword = generateScrambledPassword(16)
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
await Database.setCredential(currentUser.username, passwordHash)
|
||||
|
||||
const smtpConfig = await Database.getSMTPConfig()
|
||||
await simulateEmailSend(
|
||||
currentUser.email,
|
||||
'Your New MetaBuilder Password',
|
||||
`Your password has been reset at your request.\n\nUsername: ${currentUser.username}\nNew Password: ${newPassword}\n\nPlease login with this password and change it from your profile settings if desired.`,
|
||||
smtpConfig || undefined
|
||||
)
|
||||
|
||||
toast.success('New password sent to your email! Check console (simulated email)')
|
||||
}
|
||||
|
||||
return {
|
||||
comments,
|
||||
currentUser,
|
||||
editingProfile,
|
||||
newComment,
|
||||
profileForm,
|
||||
users,
|
||||
setEditingProfile,
|
||||
setNewComment,
|
||||
setProfileForm,
|
||||
handleDeleteComment,
|
||||
handlePostComment,
|
||||
handleProfileSave,
|
||||
handleRequestPasswordReset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import { Database } from '@/lib/database'
|
||||
import { createPowerTransferRequest } from '@/lib/api/power-transfers'
|
||||
import { fetchUsers } from '@/lib/api/users/fetch-users'
|
||||
import type { Tenant, User } from '@/lib/level-types'
|
||||
|
||||
interface Level5StateOptions {
|
||||
user: User
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function useLevel5State({ user, onLogout }: Level5StateOptions) {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||
const [godUsers, setGodUsers] = useState<User[]>([])
|
||||
const [transferRefresh, setTransferRefresh] = useState(0)
|
||||
const [showConfirmTransfer, setShowConfirmTransfer] = useState(false)
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [newTenantName, setNewTenantName] = useState('')
|
||||
const [showCreateTenant, setShowCreateTenant] = useState(false)
|
||||
const [nerdMode, setNerdMode] = useKV<boolean>('level5-nerd-mode', false)
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
const [tenantsData, usersData] = await Promise.all([Database.getTenants(), fetchUsers()])
|
||||
|
||||
setTenants(tenantsData)
|
||||
setAllUsers(usersData)
|
||||
setGodUsers(usersData.filter(u => u.role === 'god'))
|
||||
}
|
||||
|
||||
const handleCreateTenant = async () => {
|
||||
if (!newTenantName.trim()) {
|
||||
toast.error('Tenant name is required')
|
||||
return
|
||||
}
|
||||
|
||||
const newTenant: Tenant = {
|
||||
id: `tenant_${Date.now()}`,
|
||||
name: newTenantName,
|
||||
ownerId: user.id,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
await Database.addTenant(newTenant)
|
||||
setTenants(current => [...current, newTenant])
|
||||
setNewTenantName('')
|
||||
setShowCreateTenant(false)
|
||||
toast.success('Tenant created successfully')
|
||||
}
|
||||
|
||||
const handleAssignHomepage = async (tenantId: string, pageId: string) => {
|
||||
await Database.updateTenant(tenantId, { homepageConfig: { pageId } })
|
||||
await loadData()
|
||||
toast.success('Homepage assigned to tenant')
|
||||
}
|
||||
|
||||
const handleInitiateTransfer = (userId: string) => {
|
||||
if (!userId) {
|
||||
toast.error('Please select a user to transfer power to')
|
||||
return
|
||||
}
|
||||
setSelectedUserId(userId)
|
||||
setShowConfirmTransfer(true)
|
||||
}
|
||||
|
||||
const handleConfirmTransfer = async () => {
|
||||
if (!selectedUserId) return
|
||||
|
||||
const targetUser = allUsers.find((u) => u.id === selectedUserId)
|
||||
if (!targetUser) {
|
||||
toast.error('Selected user not found')
|
||||
setShowConfirmTransfer(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createPowerTransferRequest({ fromUserId: user.id, toUserId: selectedUserId })
|
||||
|
||||
toast.success(`Power transferred to ${targetUser.username}. You are now a God user and will be logged out shortly.`)
|
||||
setTransferRefresh((prev) => prev + 1)
|
||||
await loadData()
|
||||
|
||||
setTimeout(() => {
|
||||
onLogout()
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
toast.error('Failed to transfer power: ' + (error as Error).message)
|
||||
} finally {
|
||||
setShowConfirmTransfer(false)
|
||||
setSelectedUserId('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTenant = async (tenantId: string) => {
|
||||
await Database.deleteTenant(tenantId)
|
||||
setTenants(current => current.filter(t => t.id !== tenantId))
|
||||
toast.success('Tenant deleted')
|
||||
}
|
||||
|
||||
const handleToggleNerdMode = () => {
|
||||
setNerdMode(!nerdMode)
|
||||
toast.info(nerdMode ? 'Nerd Mode disabled' : 'Nerd Mode enabled')
|
||||
}
|
||||
|
||||
return {
|
||||
tenants,
|
||||
allUsers,
|
||||
godUsers,
|
||||
transferRefresh,
|
||||
showConfirmTransfer,
|
||||
selectedUserId,
|
||||
newTenantName,
|
||||
showCreateTenant,
|
||||
nerdMode,
|
||||
setNewTenantName,
|
||||
setShowCreateTenant,
|
||||
setShowConfirmTransfer,
|
||||
setSelectedUserId,
|
||||
handleAssignHomepage,
|
||||
handleConfirmTransfer,
|
||||
handleCreateTenant,
|
||||
handleDeleteTenant,
|
||||
handleInitiateTransfer,
|
||||
handleToggleNerdMode,
|
||||
loadData,
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui'
|
||||
import { Stack, Typography } from '@/components/ui'
|
||||
import { toast } from 'sonner'
|
||||
import { AppHeader } from '@/components/shared/AppHeader'
|
||||
import { Database } from '@/lib/database'
|
||||
import type { Comment, User } from '@/lib/level-types'
|
||||
import { AppHeader } from '@/components/shared/AppHeader'
|
||||
import { ModeratorActions } from './ModeratorPanel/Actions'
|
||||
import { ModeratorHeader } from './ModeratorPanel/Header'
|
||||
import { ModeratorLogList } from './ModeratorPanel/LogList'
|
||||
|
||||
const FLAGGED_TERMS = ['spam', 'error', 'abuse', 'illegal', 'urgent', 'offensive']
|
||||
|
||||
@@ -70,8 +61,6 @@ export function ModeratorPanel({ user, onLogout, onNavigate }: ModeratorPanelPro
|
||||
toast.success('Flag resolved and archived from the queue')
|
||||
}
|
||||
|
||||
const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader
|
||||
@@ -85,116 +74,19 @@ export function ModeratorPanel({ user, onLogout, onNavigate }: ModeratorPanelPro
|
||||
/>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Typography variant="h4">Moderation queue</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Keep the community healthy by resolving flags, reviewing reports, and guiding the tone.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flagged content</CardTitle>
|
||||
<CardDescription>Automated signal based on keywords</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="h3">{flaggedComments.length}</Typography>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Pending items in the moderation queue
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resolved this session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="h3">{resolvedIds.length}</Typography>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Items you flagged as handled
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Community signals</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{FLAGGED_TERMS.map((term) => (
|
||||
<Badge key={term}>{highlightLabel(term)}</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Track the keywords that pulled items into the queue
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Flagged comments</CardTitle>
|
||||
<CardDescription>A curated view of the comments that triggered a signal</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(2)}>
|
||||
Go to user dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Typography color="text.secondary">Loading flagged comments…</Typography>
|
||||
) : flaggedComments.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
No flagged comments at the moment. Enjoy the calm.
|
||||
</Typography>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
<TableHead>Matched terms</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flaggedComments.map((comment) => {
|
||||
const matches = FLAGGED_TERMS.filter((term) =>
|
||||
comment.content.toLowerCase().includes(term)
|
||||
)
|
||||
return (
|
||||
<TableRow key={comment.id}>
|
||||
<TableCell className="font-mono text-sm">{comment.userId}</TableCell>
|
||||
<TableCell>{comment.content}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{matches.map((match) => (
|
||||
<Badge key={`${comment.id}-${match}`} variant="outline">
|
||||
{match}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleResolve(comment.id)}>
|
||||
Mark safe
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ModeratorHeader />
|
||||
<ModeratorActions
|
||||
flaggedCount={flaggedComments.length}
|
||||
resolvedCount={resolvedIds.length}
|
||||
flaggedTerms={FLAGGED_TERMS}
|
||||
/>
|
||||
<ModeratorLogList
|
||||
flaggedComments={flaggedComments}
|
||||
flaggedTerms={FLAGGED_TERMS}
|
||||
isLoading={isLoading}
|
||||
onNavigate={onNavigate}
|
||||
onResolve={handleResolve}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack, Typography } from '@/components/ui'
|
||||
|
||||
interface ModeratorActionsProps {
|
||||
flaggedCount: number
|
||||
resolvedCount: number
|
||||
flaggedTerms: string[]
|
||||
}
|
||||
|
||||
export function ModeratorActions({ flaggedCount, resolvedCount, flaggedTerms }: ModeratorActionsProps) {
|
||||
const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flagged content</CardTitle>
|
||||
<CardDescription>Automated signal based on keywords</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="h3">{flaggedCount}</Typography>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Pending items in the moderation queue
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resolved this session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="h3">{resolvedCount}</Typography>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Items you flagged as handled
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Community signals</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{flaggedTerms.map((term) => (
|
||||
<Badge key={term}>{highlightLabel(term)}</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Track the keywords that pulled items into the queue
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Typography } from '@/components/ui'
|
||||
|
||||
export function ModeratorHeader() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Typography variant="h4">Moderation queue</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Keep the community healthy by resolving flags, reviewing reports, and guiding the tone.
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack } from '@/components/ui'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@/components/ui'
|
||||
import type { Comment } from '@/lib/level-types'
|
||||
|
||||
interface ModeratorLogListProps {
|
||||
flaggedComments: Comment[]
|
||||
flaggedTerms: string[]
|
||||
isLoading: boolean
|
||||
onNavigate: (level: number) => void
|
||||
onResolve: (commentId: string) => void
|
||||
}
|
||||
|
||||
export function ModeratorLogList({
|
||||
flaggedComments,
|
||||
flaggedTerms,
|
||||
isLoading,
|
||||
onNavigate,
|
||||
onResolve,
|
||||
}: ModeratorLogListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Flagged comments</CardTitle>
|
||||
<CardDescription>A curated view of the comments that triggered a signal</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(2)}>
|
||||
Go to user dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Typography color="text.secondary">Loading flagged comments…</Typography>
|
||||
) : flaggedComments.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
No flagged comments at the moment. Enjoy the calm.
|
||||
</Typography>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
<TableHead>Matched terms</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flaggedComments.map((comment) => {
|
||||
const matches = flaggedTerms.filter((term) =>
|
||||
comment.content.toLowerCase().includes(term)
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow key={comment.id}>
|
||||
<TableCell className="font-mono text-sm">{comment.userId}</TableCell>
|
||||
<TableCell>{comment.content}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{matches.map((match) => (
|
||||
<Badge key={`${comment.id}-${match}`} variant="outline">
|
||||
{match}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => onResolve(comment.id)}>
|
||||
Mark safe
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user