Merge branch 'main' into codex/add-package-operations-for-publish,-unpublish,-validate

This commit is contained in:
2025-12-27 18:39:37 +00:00
committed by GitHub
68 changed files with 1961 additions and 1159 deletions

View File

@@ -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'

View 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'

View File

@@ -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

View File

@@ -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)

View File

@@ -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'

View 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,
}
}

View 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
}

View 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,
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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[] = [
{

View File

@@ -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

View File

@@ -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> {

View File

@@ -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 => {

View File

@@ -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)
}

View File

@@ -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,
})

View File

@@ -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 (

View 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)
}

View File

@@ -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'

View File

@@ -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)
}

View File

@@ -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`)
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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`)
}
}

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
},
})

View File

@@ -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>,
})

View File

@@ -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)) {

View File

@@ -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'

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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 })))
}
}

View 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>
}

View 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>
}

View File

@@ -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'

View 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
}

View File

@@ -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)
})

View 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
}
}

View 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'),
}
}

View File

@@ -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 }

View 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)
}

View File

@@ -1,12 +1,13 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
import { Button } from '@/components/ui'
import { Alert, AlertDescription } from '@/components/ui'
import { FloppyDisk, X, Warning, ShieldCheck } from '@phosphor-icons/react'
import { useEffect, useState } from 'react'
import { Alert, AlertDescription, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { Warning } from '@phosphor-icons/react'
import Editor from '@monaco-editor/react'
import { toast } from 'sonner'
import { SchemaSection } from './json/SchemaSection'
import { Toolbar } from './json/Toolbar'
import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner'
import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog'
import { toast } from 'sonner'
interface JsonEditorProps {
open: boolean
@@ -32,10 +33,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
}
}, [open, value])
const parseJson = () => JSON.parse(jsonText)
const handleSave = () => {
try {
const parsed = JSON.parse(jsonText)
const parsed = parseJson()
const scanResult = securityScanner.scanJSON(jsonText)
setSecurityScanResult(scanResult)
@@ -66,8 +69,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const handleForceSave = () => {
try {
const parsed = JSON.parse(jsonText)
onSave(parsed)
onSave(parseJson())
setError(null)
setPendingSave(false)
setShowSecurityDialog(false)
@@ -81,7 +83,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const scanResult = securityScanner.scanJSON(jsonText)
setSecurityScanResult(scanResult)
setShowSecurityDialog(true)
if (scanResult.safe) {
toast.success('No security issues detected')
} else {
@@ -91,8 +93,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const handleFormat = () => {
try {
const parsed = JSON.parse(jsonText)
setJsonText(JSON.stringify(parsed, null, 2))
setJsonText(JSON.stringify(parseJson(), null, 2))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid JSON - cannot format')
@@ -106,7 +107,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
<DialogHeader>
<DialogTitle className="text-2xl">{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{error && (
<Alert variant="destructive">
@@ -115,16 +116,21 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
</Alert>
)}
{securityScanResult && securityScanResult.severity !== 'safe' && securityScanResult.severity !== 'low' && !showSecurityDialog && (
<Alert className="border-yellow-200 bg-yellow-50">
<Warning className="h-5 w-5 text-yellow-600" weight="fill" />
<AlertDescription className="text-yellow-800">
{securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'} detected.
Click Security Scan to review.
</AlertDescription>
</Alert>
)}
{securityScanResult &&
securityScanResult.severity !== 'safe' &&
securityScanResult.severity !== 'low' &&
!showSecurityDialog && (
<Alert className="border-yellow-200 bg-yellow-50">
<Warning className="h-5 w-5 text-yellow-600" weight="fill" />
<AlertDescription className="text-yellow-800">
{securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'}
 detected. Click Security Scan to review.
</AlertDescription>
</Alert>
)}
<SchemaSection schema={schema} />
<div className="border rounded-lg overflow-hidden">
<Editor
height="600px"
@@ -157,23 +163,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handleScan}>
<ShieldCheck className="mr-2" />
Security Scan
</Button>
<Button variant="outline" onClick={handleFormat}>
Format JSON
</Button>
<Button variant="outline" onClick={onClose}>
<X className="mr-2" />
Cancel
</Button>
<Button onClick={handleSave} className="bg-accent text-accent-foreground hover:bg-accent/90">
<FloppyDisk className="mr-2" />
Save
</Button>
</DialogFooter>
<Toolbar
onScan={handleScan}
onFormat={handleFormat}
onCancel={onClose}
onSave={handleSave}
/>
</DialogContent>
</Dialog>

View File

@@ -1,79 +1,15 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Label } from '@/components/ui'
import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Switch } from '@/components/ui'
import { Palette, Sun, Moon, FloppyDisk, ArrowCounterClockwise } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { useKV } from '@github/spark/hooks'
interface ThemeColors {
background: string
foreground: string
card: string
cardForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
border: string
input: string
ring: string
}
interface ThemeConfig {
light: ThemeColors
dark: ThemeColors
radius: string
}
const DEFAULT_LIGHT_THEME: ThemeColors = {
background: 'oklch(0.92 0.03 290)',
foreground: 'oklch(0.25 0.02 260)',
card: 'oklch(1 0 0)',
cardForeground: 'oklch(0.25 0.02 260)',
primary: 'oklch(0.55 0.18 290)',
primaryForeground: 'oklch(0.98 0 0)',
secondary: 'oklch(0.35 0.02 260)',
secondaryForeground: 'oklch(0.90 0.01 260)',
muted: 'oklch(0.95 0.02 290)',
mutedForeground: 'oklch(0.50 0.02 260)',
accent: 'oklch(0.70 0.17 195)',
accentForeground: 'oklch(0.2 0.02 260)',
destructive: 'oklch(0.55 0.22 25)',
destructiveForeground: 'oklch(0.98 0 0)',
border: 'oklch(0.85 0.02 290)',
input: 'oklch(0.85 0.02 290)',
ring: 'oklch(0.70 0.17 195)',
}
const DEFAULT_DARK_THEME: ThemeColors = {
background: 'oklch(0.145 0 0)',
foreground: 'oklch(0.985 0 0)',
card: 'oklch(0.205 0 0)',
cardForeground: 'oklch(0.985 0 0)',
primary: 'oklch(0.922 0 0)',
primaryForeground: 'oklch(0.205 0 0)',
secondary: 'oklch(0.269 0 0)',
secondaryForeground: 'oklch(0.985 0 0)',
muted: 'oklch(0.269 0 0)',
mutedForeground: 'oklch(0.708 0 0)',
accent: 'oklch(0.269 0 0)',
accentForeground: 'oklch(0.985 0 0)',
destructive: 'oklch(0.704 0.191 22.216)',
destructiveForeground: 'oklch(0.98 0 0)',
border: 'oklch(1 0 0 / 10%)',
input: 'oklch(1 0 0 / 15%)',
ring: 'oklch(0.556 0 0)',
}
import { PaletteEditor } from './theme/PaletteEditor'
import { PreviewPane } from './theme/PreviewPane'
import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from './theme/constants'
import { ThemeColors, ThemeConfig } from './theme/types'
export function ThemeEditor() {
const [themeConfig, setThemeConfig] = useKV<ThemeConfig>('theme_config', {
@@ -81,7 +17,7 @@ export function ThemeEditor() {
dark: DEFAULT_DARK_THEME,
radius: '0.5rem',
})
const [isDarkMode, setIsDarkMode] = useKV<boolean>('dark_mode_enabled', false)
const [editingTheme, setEditingTheme] = useState<'light' | 'dark'>('light')
const [localColors, setLocalColors] = useState<ThemeColors>(DEFAULT_LIGHT_THEME)
@@ -95,30 +31,19 @@ export function ThemeEditor() {
}, [editingTheme, themeConfig])
useEffect(() => {
if (themeConfig) {
applyTheme()
}
}, [themeConfig, isDarkMode])
const applyTheme = () => {
if (!themeConfig) return
const root = document.documentElement
const colors = isDarkMode ? themeConfig.dark : themeConfig.light
Object.entries(colors).forEach(([key, value]) => {
const cssVarName = key.replace(/([A-Z])/g, '-$1').toLowerCase()
root.style.setProperty(`--${cssVarName}`, value)
})
root.style.setProperty('--radius', themeConfig.radius)
if (isDarkMode) {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
root.classList.toggle('dark', isDarkMode)
}, [isDarkMode, themeConfig])
const handleColorChange = (colorKey: keyof ThemeColors, value: string) => {
setLocalColors((current) => ({
@@ -130,12 +55,14 @@ export function ThemeEditor() {
const handleSave = () => {
setThemeConfig((current) => {
if (!current) return { light: localColors, dark: DEFAULT_DARK_THEME, radius: localRadius }
return {
...current,
[editingTheme]: localColors,
radius: localRadius,
}
})
toast.success('Theme saved successfully')
}
@@ -151,41 +78,6 @@ export function ThemeEditor() {
toast.success(checked ? 'Dark mode enabled' : 'Light mode enabled')
}
const colorGroups = [
{
title: 'Base Colors',
colors: [
{ key: 'background' as const, label: 'Background' },
{ key: 'foreground' as const, label: 'Foreground' },
{ key: 'card' as const, label: 'Card' },
{ key: 'cardForeground' as const, label: 'Card Foreground' },
],
},
{
title: 'Action Colors',
colors: [
{ key: 'primary' as const, label: 'Primary' },
{ key: 'primaryForeground' as const, label: 'Primary Foreground' },
{ key: 'secondary' as const, label: 'Secondary' },
{ key: 'secondaryForeground' as const, label: 'Secondary Foreground' },
{ key: 'accent' as const, label: 'Accent' },
{ key: 'accentForeground' as const, label: 'Accent Foreground' },
{ key: 'destructive' as const, label: 'Destructive' },
{ key: 'destructiveForeground' as const, label: 'Destructive Foreground' },
],
},
{
title: 'Supporting Colors',
colors: [
{ key: 'muted' as const, label: 'Muted' },
{ key: 'mutedForeground' as const, label: 'Muted Foreground' },
{ key: 'border' as const, label: 'Border' },
{ key: 'input' as const, label: 'Input' },
{ key: 'ring' as const, label: 'Ring' },
],
},
]
return (
<div className="space-y-6">
<Card>
@@ -196,9 +88,7 @@ export function ThemeEditor() {
<Palette size={24} />
Theme Editor
</CardTitle>
<CardDescription>
Customize the application theme colors and appearance
</CardDescription>
<CardDescription>Customize the application theme colors and appearance</CardDescription>
</div>
<div className="flex items-center gap-3">
<Sun size={18} className={!isDarkMode ? 'text-amber-500' : 'text-muted-foreground'} />
@@ -207,52 +97,21 @@ export function ThemeEditor() {
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={editingTheme} onValueChange={(v) => setEditingTheme(v as 'light' | 'dark')}>
<CardContent className="space-y-6">
<Tabs value={editingTheme} onValueChange={(value) => setEditingTheme(value as 'light' | 'dark')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="light">Light Theme</TabsTrigger>
<TabsTrigger value="dark">Dark Theme</TabsTrigger>
</TabsList>
<TabsContent value={editingTheme} className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label htmlFor="radius">Border Radius</Label>
<Input
id="radius"
value={localRadius}
onChange={(e) => setLocalRadius(e.target.value)}
placeholder="e.g., 0.5rem"
className="mt-1.5"
/>
</div>
</div>
{colorGroups.map((group) => (
<div key={group.title} className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.colors.map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<Label htmlFor={key}>{label}</Label>
<div className="flex gap-2">
<div
className="w-10 h-10 rounded border border-border shrink-0"
style={{ background: localColors[key] }}
/>
<Input
id={key}
value={localColors[key]}
onChange={(e) => handleColorChange(key, e.target.value)}
placeholder="oklch(...)"
className="font-mono text-sm"
/>
</div>
</div>
))}
</div>
</div>
))}
<TabsContent value={editingTheme} className="space-y-6">
<PaletteEditor
colors={localColors}
radius={localRadius}
onColorChange={handleColorChange}
onRadiusChange={setLocalRadius}
/>
<div className="flex items-center gap-3 pt-4 border-t border-border">
<Button onClick={handleSave} className="gap-2">
@@ -267,26 +126,7 @@ export function ThemeEditor() {
</TabsContent>
</Tabs>
<div className="mt-6 p-4 border border-border rounded-lg bg-muted/30">
<h4 className="text-sm font-semibold mb-3">Theme Preview</h4>
<div className="space-y-3">
<div className="flex gap-2">
<Button size="sm">Primary Button</Button>
<Button size="sm" variant="secondary">Secondary</Button>
<Button size="sm" variant="outline">Outline</Button>
<Button size="sm" variant="destructive">Destructive</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Card Example</CardTitle>
<CardDescription>This is a card description</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Card content with muted text</p>
</CardContent>
</Card>
</div>
</div>
<PreviewPane />
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
interface SchemaSectionProps {
schema?: unknown
}
export function SchemaSection({ schema }: SchemaSectionProps) {
if (!schema) return null
const formattedSchema =
typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2)
return (
<Card>
<CardHeader className="flex flex-col gap-1">
<CardTitle>Schema</CardTitle>
<CardDescription>Reference for the expected JSON structure</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-48 overflow-auto rounded border bg-muted px-3 py-2 text-xs leading-5 whitespace-pre-wrap">
{formattedSchema}
</pre>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,31 @@
import { Button, DialogFooter } from '@/components/ui'
import { FloppyDisk, ShieldCheck, X } from '@phosphor-icons/react'
interface ToolbarProps {
onScan: () => void
onFormat: () => void
onCancel: () => void
onSave: () => void
}
export function Toolbar({ onScan, onFormat, onCancel, onSave }: ToolbarProps) {
return (
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onScan}>
<ShieldCheck className="mr-2" />
Security Scan
</Button>
<Button variant="outline" onClick={onFormat}>
Format JSON
</Button>
<Button variant="outline" onClick={onCancel}>
<X className="mr-2" />
Cancel
</Button>
<Button onClick={onSave} className="bg-accent text-accent-foreground hover:bg-accent/90">
<FloppyDisk className="mr-2" />
Save
</Button>
</DialogFooter>
)
}

View File

@@ -0,0 +1,86 @@
import { Input, Label } from '@/components/ui'
import { ThemeColors } from './types'
const colorGroups = [
{
title: 'Base Colors',
colors: [
{ key: 'background' as const, label: 'Background' },
{ key: 'foreground' as const, label: 'Foreground' },
{ key: 'card' as const, label: 'Card' },
{ key: 'cardForeground' as const, label: 'Card Foreground' },
],
},
{
title: 'Action Colors',
colors: [
{ key: 'primary' as const, label: 'Primary' },
{ key: 'primaryForeground' as const, label: 'Primary Foreground' },
{ key: 'secondary' as const, label: 'Secondary' },
{ key: 'secondaryForeground' as const, label: 'Secondary Foreground' },
{ key: 'accent' as const, label: 'Accent' },
{ key: 'accentForeground' as const, label: 'Accent Foreground' },
{ key: 'destructive' as const, label: 'Destructive' },
{ key: 'destructiveForeground' as const, label: 'Destructive Foreground' },
],
},
{
title: 'Supporting Colors',
colors: [
{ key: 'muted' as const, label: 'Muted' },
{ key: 'mutedForeground' as const, label: 'Muted Foreground' },
{ key: 'border' as const, label: 'Border' },
{ key: 'input' as const, label: 'Input' },
{ key: 'ring' as const, label: 'Ring' },
],
},
]
interface PaletteEditorProps {
colors: ThemeColors
radius: string
onColorChange: (colorKey: keyof ThemeColors, value: string) => void
onRadiusChange: (value: string) => void
}
export function PaletteEditor({ colors, radius, onColorChange, onRadiusChange }: PaletteEditorProps) {
return (
<div className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label htmlFor="radius">Border Radius</Label>
<Input
id="radius"
value={radius}
onChange={(e) => onRadiusChange(e.target.value)}
placeholder="e.g., 0.5rem"
className="mt-1.5"
/>
</div>
</div>
{colorGroups.map((group) => (
<div key={group.title} className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.colors.map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<Label htmlFor={key}>{label}</Label>
<div className="flex gap-2">
<div className="w-10 h-10 rounded border border-border shrink-0" style={{ background: colors[key] }} />
<Input
id={key}
value={colors[key]}
onChange={(e) => onColorChange(key, e.target.value)}
placeholder="oklch(...)"
className="font-mono text-sm"
/>
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Button } from '@/components/ui'
export function PreviewPane() {
return (
<div className="mt-6 p-4 border border-border rounded-lg bg-muted/30">
<h4 className="text-sm font-semibold mb-3">Theme Preview</h4>
<div className="space-y-3">
<div className="flex gap-2">
<Button size="sm">Primary Button</Button>
<Button size="sm" variant="secondary">
Secondary
</Button>
<Button size="sm" variant="outline">
Outline
</Button>
<Button size="sm" variant="destructive">
Destructive
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Card Example</CardTitle>
<CardDescription>This is a card description</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Card content with muted text</p>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { ThemeColors } from './types'
export const DEFAULT_LIGHT_THEME: ThemeColors = {
background: 'oklch(0.92 0.03 290)',
foreground: 'oklch(0.25 0.02 260)',
card: 'oklch(1 0 0)',
cardForeground: 'oklch(0.25 0.02 260)',
primary: 'oklch(0.55 0.18 290)',
primaryForeground: 'oklch(0.98 0 0)',
secondary: 'oklch(0.35 0.02 260)',
secondaryForeground: 'oklch(0.90 0.01 260)',
muted: 'oklch(0.95 0.02 290)',
mutedForeground: 'oklch(0.50 0.02 260)',
accent: 'oklch(0.70 0.17 195)',
accentForeground: 'oklch(0.2 0.02 260)',
destructive: 'oklch(0.55 0.22 25)',
destructiveForeground: 'oklch(0.98 0 0)',
border: 'oklch(0.85 0.02 290)',
input: 'oklch(0.85 0.02 290)',
ring: 'oklch(0.70 0.17 195)',
}
export const DEFAULT_DARK_THEME: ThemeColors = {
background: 'oklch(0.145 0 0)',
foreground: 'oklch(0.985 0 0)',
card: 'oklch(0.205 0 0)',
cardForeground: 'oklch(0.985 0 0)',
primary: 'oklch(0.922 0 0)',
primaryForeground: 'oklch(0.205 0 0)',
secondary: 'oklch(0.269 0 0)',
secondaryForeground: 'oklch(0.985 0 0)',
muted: 'oklch(0.269 0 0)',
mutedForeground: 'oklch(0.708 0 0)',
accent: 'oklch(0.269 0 0)',
accentForeground: 'oklch(0.985 0 0)',
destructive: 'oklch(0.704 0.191 22.216)',
destructiveForeground: 'oklch(0.98 0 0)',
border: 'oklch(1 0 0 / 10%)',
input: 'oklch(1 0 0 / 15%)',
ring: 'oklch(0.556 0 0)',
}

View File

@@ -0,0 +1,25 @@
export interface ThemeColors {
background: string
foreground: string
card: string
cardForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
border: string
input: string
ring: string
}
export interface ThemeConfig {
light: ThemeColors
dark: ThemeColors
radius: string
}

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,26 +1,16 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
import { Button } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Card } from '@/components/ui'
import { Badge } from '@/components/ui'
import { Separator } from '@/components/ui'
import { useEffect, useState } from 'react'
import { Button, Card } from '@/components/ui'
import { Database } from '@/lib/database'
import { Plus, X, FloppyDisk, Trash, Pencil } from '@phosphor-icons/react'
import { Plus } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { DropdownConfig } from '@/lib/database'
import { DropdownConfigForm } from './dropdown/DropdownConfigForm'
import { PreviewPane } from './dropdown/PreviewPane'
export function DropdownConfigManager() {
const [dropdowns, setDropdowns] = useState<DropdownConfig[]>([])
const [isEditing, setIsEditing] = useState(false)
const [editingDropdown, setEditingDropdown] = useState<DropdownConfig | null>(null)
const [dropdownName, setDropdownName] = useState('')
const [dropdownLabel, setDropdownLabel] = useState('')
const [options, setOptions] = useState<Array<{ value: string; label: string }>>([])
const [newOptionValue, setNewOptionValue] = useState('')
const [newOptionLabel, setNewOptionLabel] = useState('')
useEffect(() => {
loadDropdowns()
@@ -31,63 +21,34 @@ export function DropdownConfigManager() {
setDropdowns(configs)
}
const startEdit = (dropdown?: DropdownConfig) => {
if (dropdown) {
setEditingDropdown(dropdown)
setDropdownName(dropdown.name)
setDropdownLabel(dropdown.label)
setOptions(dropdown.options)
} else {
setEditingDropdown(null)
setDropdownName('')
setDropdownLabel('')
setOptions([])
}
const openEditor = (dropdown?: DropdownConfig) => {
setEditingDropdown(dropdown ?? null)
setIsEditing(true)
}
const addOption = () => {
if (newOptionValue && newOptionLabel) {
setOptions(current => [...current, { value: newOptionValue, label: newOptionLabel }])
setNewOptionValue('')
setNewOptionLabel('')
}
}
const removeOption = (index: number) => {
setOptions(current => current.filter((_, i) => i !== index))
}
const handleSave = async () => {
if (!dropdownName || !dropdownLabel || options.length === 0) {
toast.error('Please fill all fields and add at least one option')
return
}
const newDropdown: DropdownConfig = {
id: editingDropdown?.id || `dropdown_${Date.now()}`,
name: dropdownName,
label: dropdownLabel,
options,
}
if (editingDropdown) {
await Database.updateDropdownConfig(newDropdown.id, newDropdown)
const handleSave = async (config: DropdownConfig, isEdit: boolean) => {
if (isEdit) {
await Database.updateDropdownConfig(config.id, config)
toast.success('Dropdown updated successfully')
} else {
await Database.addDropdownConfig(newDropdown)
await Database.addDropdownConfig(config)
toast.success('Dropdown created successfully')
}
setIsEditing(false)
loadDropdowns()
await loadDropdowns()
}
const handleDelete = async (id: string) => {
if (confirm('Are you sure you want to delete this dropdown configuration?')) {
await Database.deleteDropdownConfig(id)
toast.success('Dropdown deleted')
loadDropdowns()
await Database.deleteDropdownConfig(id)
toast.success('Dropdown deleted')
await loadDropdowns()
}
const handleDialogChange = (open: boolean) => {
setIsEditing(open)
if (!open) {
setEditingDropdown(null)
}
}
@@ -98,7 +59,7 @@ export function DropdownConfigManager() {
<h2 className="text-2xl font-bold">Dropdown Configurations</h2>
<p className="text-sm text-muted-foreground">Manage dynamic dropdown options for properties</p>
</div>
<Button onClick={() => startEdit()}>
<Button onClick={() => openEditor()}>
<Plus className="mr-2" />
Create Dropdown
</Button>
@@ -106,30 +67,12 @@ export function DropdownConfigManager() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{dropdowns.map(dropdown => (
<Card key={dropdown.id} className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{dropdown.label}</h3>
<p className="text-xs text-muted-foreground font-mono">{dropdown.name}</p>
</div>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => startEdit(dropdown)}>
<Pencil size={16} />
</Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(dropdown.id)}>
<Trash size={16} />
</Button>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-1">
{dropdown.options.map((opt, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{opt.label}
</Badge>
))}
</div>
</Card>
<PreviewPane
key={dropdown.id}
dropdown={dropdown}
onEdit={openEditor}
onDelete={handleDelete}
/>
))}
</div>
@@ -139,88 +82,12 @@ export function DropdownConfigManager() {
</Card>
)}
<Dialog open={isEditing} onOpenChange={setIsEditing}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingDropdown ? 'Edit' : 'Create'} Dropdown Configuration</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Dropdown Name (ID)</Label>
<Input
placeholder="e.g., status_options"
value={dropdownName}
onChange={(e) => setDropdownName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Unique identifier for this dropdown</p>
</div>
<div className="space-y-2">
<Label>Display Label</Label>
<Input
placeholder="e.g., Status"
value={dropdownLabel}
onChange={(e) => setDropdownLabel(e.target.value)}
/>
</div>
<Separator />
<div className="space-y-2">
<Label>Options</Label>
<div className="flex gap-2">
<Input
placeholder="Value"
value={newOptionValue}
onChange={(e) => setNewOptionValue(e.target.value)}
/>
<Input
placeholder="Label"
value={newOptionLabel}
onChange={(e) => setNewOptionLabel(e.target.value)}
/>
<Button onClick={addOption} type="button">
<Plus size={16} />
</Button>
</div>
</div>
{options.length > 0 && (
<ScrollArea className="h-[200px] border rounded-lg p-3">
<div className="space-y-2">
{options.map((opt, i) => (
<div key={i} className="flex items-center justify-between p-2 border rounded bg-muted/50">
<div className="flex-1">
<span className="font-mono text-sm">{opt.value}</span>
<span className="mx-2 text-muted-foreground"></span>
<span className="text-sm">{opt.label}</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeOption(i)}
>
<X size={16} />
</Button>
</div>
))}
</div>
</ScrollArea>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
<FloppyDisk className="mr-2" />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DropdownConfigForm
open={isEditing}
editingDropdown={editingDropdown}
onOpenChange={handleDialogChange}
onSave={handleSave}
/>
</div>
)
}

View File

@@ -1,29 +1,39 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import { Badge } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui'
import { Plus, Pencil, Trash, Eye, LockKey } from '@phosphor-icons/react'
import { useEffect, useState } from 'react'
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui'
import { Plus } from '@phosphor-icons/react'
import { Database } from '@/lib/database'
import type { PageConfig } from '@/lib/level-types'
import { toast } from 'sonner'
import type { PageConfig, UserRole, AppLevel } from '@/lib/level-types'
import { Switch } from '@/components/ui'
import { RoutesTable } from './page-routes/RoutesTable'
import { Preview } from './page-routes/Preview'
import { RouteEditor, RouteFormData } from './page-routes/RouteEditor'
const defaultFormData: RouteFormData = {
path: '/',
title: '',
level: 1,
requiresAuth: false,
componentTree: [],
}
export function PageRoutesManager() {
const [pages, setPages] = useState<PageConfig[]>([])
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingPage, setEditingPage] = useState<PageConfig | null>(null)
const [formData, setFormData] = useState<Partial<PageConfig>>({
path: '/',
title: '',
level: 1,
requiresAuth: false,
componentTree: [],
})
const [formData, setFormData] = useState<RouteFormData>({ ...defaultFormData })
useEffect(() => {
loadPages()
@@ -40,13 +50,7 @@ export function PageRoutesManager() {
setFormData(page)
} else {
setEditingPage(null)
setFormData({
path: '/',
title: '',
level: 1,
requiresAuth: false,
componentTree: [],
})
setFormData({ ...defaultFormData })
}
setIsDialogOpen(true)
}
@@ -54,13 +58,7 @@ export function PageRoutesManager() {
const handleCloseDialog = () => {
setIsDialogOpen(false)
setEditingPage(null)
setFormData({
path: '/',
title: '',
level: 1,
requiresAuth: false,
componentTree: [],
})
setFormData({ ...defaultFormData })
}
const handleSavePage = async () => {
@@ -98,18 +96,6 @@ export function PageRoutesManager() {
}
}
const getLevelBadgeColor = (level: AppLevel) => {
switch (level) {
case 1: return 'bg-blue-500'
case 2: return 'bg-green-500'
case 3: return 'bg-orange-500'
case 4: return 'bg-sky-500'
case 5: return 'bg-purple-500'
case 6: return 'bg-rose-500'
default: return 'bg-gray-500'
}
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
@@ -124,94 +110,23 @@ export function PageRoutesManager() {
New Page Route
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{editingPage ? 'Edit Page Route' : 'Create New Page Route'}</DialogTitle>
<DialogDescription>
Configure the route path, access level, and authentication requirements
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="path">Route Path *</Label>
<Input
id="path"
placeholder="/home"
value={formData.path || ''}
onChange={(e) => setFormData({ ...formData, path: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">Page Title *</Label>
<Input
id="title"
placeholder="Home Page"
value={formData.title || ''}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="level">Application Level</Label>
<Select
value={String(formData.level)}
onValueChange={(value) => setFormData({ ...formData, level: Number(value) as AppLevel })}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Level 1 - Public</SelectItem>
<SelectItem value="2">Level 2 - User Area</SelectItem>
<SelectItem value="3">Level 3 - Moderator Desk</SelectItem>
<SelectItem value="4">Level 4 - Admin Panel</SelectItem>
<SelectItem value="5">Level 5 - God Builder</SelectItem>
<SelectItem value="6">Level 6 - Supergod Console</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="requiredRole">Required Role (if auth)</Label>
<Select
value={formData.requiredRole || 'public'}
onValueChange={(value) => setFormData({ ...formData, requiredRole: value as UserRole })}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">Public</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="god">God</SelectItem>
<SelectItem value="supergod">Supergod</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
id="requiresAuth"
checked={formData.requiresAuth}
onCheckedChange={(checked) => setFormData({ ...formData, requiresAuth: checked })}
/>
<Label htmlFor="requiresAuth" className="cursor-pointer">Requires Authentication</Label>
</div>
<div className="grid gap-6 md:grid-cols-2">
<RouteEditor
formData={formData}
onChange={setFormData}
onSave={handleSavePage}
onCancel={handleCloseDialog}
isEdit={Boolean(editingPage)}
/>
<Preview formData={formData} />
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseDialog}>Cancel</Button>
<Button onClick={handleSavePage}>
{editingPage ? 'Update Page' : 'Create Page'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
@@ -222,67 +137,11 @@ export function PageRoutesManager() {
<CardDescription>All page routes in your application</CardDescription>
</CardHeader>
<CardContent>
{pages.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No pages configured yet. Create your first page route!</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Path</TableHead>
<TableHead>Title</TableHead>
<TableHead>Level</TableHead>
<TableHead>Auth</TableHead>
<TableHead>Required Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pages.map((page) => (
<TableRow key={page.id}>
<TableCell className="font-mono text-sm">{page.path}</TableCell>
<TableCell>{page.title}</TableCell>
<TableCell>
<Badge className={getLevelBadgeColor(page.level)}>
Level {page.level}
</Badge>
</TableCell>
<TableCell>
{page.requiresAuth ? (
<LockKey className="text-orange-500" weight="fill" />
) : (
<Eye className="text-green-500" weight="fill" />
)}
</TableCell>
<TableCell>
<Badge variant="outline">
{page.requiredRole || 'public'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleOpenDialog(page)}
>
<Pencil />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeletePage(page.id)}
>
<Trash />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<RoutesTable
pages={pages}
onEdit={handleOpenDialog}
onDelete={handleDeletePage}
/>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,182 @@
import { useEffect, useMemo, useState } from 'react'
import { Badge, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, ScrollArea, Separator } from '@/components/ui'
import { FloppyDisk, Plus, X } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { DropdownConfig } from '@/lib/database'
interface DropdownConfigFormProps {
open: boolean
editingDropdown: DropdownConfig | null
onOpenChange: (open: boolean) => void
onSave: (config: DropdownConfig, isEdit: boolean) => Promise<void> | void
}
const getDefaultOptions = (dropdown?: DropdownConfig | null) => dropdown?.options ?? []
const buildDropdownConfig = (
dropdown: DropdownConfig | null,
name: string,
label: string,
options: Array<{ value: string; label: string }>
): DropdownConfig => ({
id: dropdown?.id ?? `dropdown_${Date.now()}`,
name: name.trim(),
label: label.trim(),
options,
})
export function DropdownConfigForm({ open, editingDropdown, onOpenChange, onSave }: DropdownConfigFormProps) {
const [dropdownName, setDropdownName] = useState('')
const [dropdownLabel, setDropdownLabel] = useState('')
const [options, setOptions] = useState<Array<{ value: string; label: string }>>([])
const [newOptionValue, setNewOptionValue] = useState('')
const [newOptionLabel, setNewOptionLabel] = useState('')
const isEditMode = useMemo(() => Boolean(editingDropdown), [editingDropdown])
useEffect(() => {
if (open) {
setDropdownName(editingDropdown?.name ?? '')
setDropdownLabel(editingDropdown?.label ?? '')
setOptions(getDefaultOptions(editingDropdown))
} else {
setDropdownName('')
setDropdownLabel('')
setOptions([])
setNewOptionValue('')
setNewOptionLabel('')
}
}, [open, editingDropdown])
const addOption = () => {
if (!newOptionValue.trim() || !newOptionLabel.trim()) {
toast.error('Please provide both a value and label for the option')
return
}
const duplicate = options.some(
(opt) => opt.value.toLowerCase() === newOptionValue.trim().toLowerCase()
)
if (duplicate) {
toast.error('An option with this value already exists')
return
}
setOptions((current) => [
...current,
{ value: newOptionValue.trim(), label: newOptionLabel.trim() },
])
setNewOptionValue('')
setNewOptionLabel('')
}
const removeOption = (index: number) => {
setOptions((current) => current.filter((_, i) => i !== index))
}
const handleSave = async () => {
if (!dropdownName.trim() || !dropdownLabel.trim() || options.length === 0) {
toast.error('Please fill all fields and add at least one option')
return
}
const config = buildDropdownConfig(editingDropdown, dropdownName, dropdownLabel, options)
await onSave(config, isEditMode)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{isEditMode ? 'Edit' : 'Create'} Dropdown Configuration</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dropdownName">Dropdown Name (ID)</Label>
<Input
id="dropdownName"
placeholder="e.g., status_options"
value={dropdownName}
onChange={(e) => setDropdownName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Unique identifier for this dropdown</p>
</div>
<div className="space-y-2">
<Label htmlFor="dropdownLabel">Display Label</Label>
<Input
id="dropdownLabel"
placeholder="e.g., Status"
value={dropdownLabel}
onChange={(e) => setDropdownLabel(e.target.value)}
/>
</div>
<Separator />
<div className="space-y-2">
<Label>Options</Label>
<div className="flex gap-2">
<Input
placeholder="Value"
value={newOptionValue}
onChange={(e) => setNewOptionValue(e.target.value)}
/>
<Input
placeholder="Label"
value={newOptionLabel}
onChange={(e) => setNewOptionLabel(e.target.value)}
/>
<Button onClick={addOption} type="button">
<Plus size={16} />
</Button>
</div>
</div>
{options.length > 0 && (
<ScrollArea className="h-[200px] border rounded-lg p-3">
<div className="space-y-2">
{options.map((opt, i) => (
<div key={i} className="flex items-center justify-between p-2 border rounded bg-muted/50">
<div className="flex-1">
<span className="font-mono text-sm">{opt.value}</span>
<span className="mx-2 text-muted-foreground"></span>
<span className="text-sm">{opt.label}</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeOption(i)}
>
<X size={16} />
</Button>
</div>
))}
</div>
</ScrollArea>
)}
</div>
{options.length === 0 && (
<div className="flex items-center gap-2 rounded-md border bg-muted/40 p-3 text-sm text-muted-foreground">
<Badge variant="outline">Tip</Badge>
Add at least one option to save this dropdown configuration.
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
<FloppyDisk className="mr-2" />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,44 @@
import { Badge, Button, Card, Separator } from '@/components/ui'
import { Pencil, Trash } from '@phosphor-icons/react'
import type { DropdownConfig } from '@/lib/database'
interface PreviewPaneProps {
dropdown: DropdownConfig
onEdit: (dropdown: DropdownConfig) => void
onDelete: (id: string) => void
}
export function PreviewPane({ dropdown, onEdit, onDelete }: PreviewPaneProps) {
const handleDelete = () => {
if (confirm('Are you sure you want to delete this dropdown configuration?')) {
onDelete(dropdown.id)
}
}
return (
<Card className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{dropdown.label}</h3>
<p className="text-xs text-muted-foreground font-mono">{dropdown.name}</p>
</div>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => onEdit(dropdown)}>
<Pencil size={16} />
</Button>
<Button size="sm" variant="ghost" onClick={handleDelete}>
<Trash size={16} />
</Button>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-1">
{dropdown.options.map((opt, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{opt.label}
</Badge>
))}
</div>
</Card>
)
}

View File

@@ -0,0 +1,37 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Eye, LockKey } from '@phosphor-icons/react'
import type { PageConfig } from '@/lib/level-types'
interface PreviewProps {
formData: Partial<PageConfig>
}
export function Preview({ formData }: PreviewProps) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Route Preview</CardTitle>
<CardDescription>Quick glance at the route details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs uppercase text-muted-foreground">Path</p>
<p className="font-mono">{formData.path || '/your-path'}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Title</p>
<p className="font-semibold">{formData.title || 'Untitled Page'}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline">Level {formData.level || 1}</Badge>
<Badge variant="secondary">{formData.requiredRole || 'public'}</Badge>
{formData.requiresAuth ? (
<LockKey className="text-orange-500" weight="fill" />
) : (
<Eye className="text-green-500" weight="fill" />
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,107 @@
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@/components/ui'
import type { PageConfig, UserRole, AppLevel } from '@/lib/level-types'
export type RouteFormData = Partial<PageConfig>
interface RouteEditorProps {
formData: RouteFormData
onChange: (value: RouteFormData) => void
onSave: () => void
onCancel: () => void
isEdit: boolean
}
export function RouteEditor({ formData, onChange, onSave, onCancel, isEdit }: RouteEditorProps) {
return (
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="path">Route Path *</Label>
<Input
id="path"
placeholder="/home"
value={formData.path || ''}
onChange={(e) => onChange({ ...formData, path: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">Page Title *</Label>
<Input
id="title"
placeholder="Home Page"
value={formData.title || ''}
onChange={(e) => onChange({ ...formData, title: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="level">Application Level</Label>
<Select
value={formData.level ? String(formData.level) : ''}
onValueChange={(value) => onChange({ ...formData, level: Number(value) as AppLevel })}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Level 1 - Public</SelectItem>
<SelectItem value="2">Level 2 - User Area</SelectItem>
<SelectItem value="3">Level 3 - Moderator Desk</SelectItem>
<SelectItem value="4">Level 4 - Admin Panel</SelectItem>
<SelectItem value="5">Level 5 - God Builder</SelectItem>
<SelectItem value="6">Level 6 - Supergod Console</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="requiredRole">Required Role (if auth)</Label>
<Select
value={formData.requiredRole || 'public'}
onValueChange={(value) => onChange({ ...formData, requiredRole: value as UserRole })}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">Public</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="god">God</SelectItem>
<SelectItem value="supergod">Supergod</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
id="requiresAuth"
checked={formData.requiresAuth}
onCheckedChange={(checked) => onChange({ ...formData, requiresAuth: checked })}
/>
<Label htmlFor="requiresAuth" className="cursor-pointer">Requires Authentication</Label>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="button" onClick={onSave}>
{isEdit ? 'Update Page' : 'Create Page'}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import {
Badge,
Button,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui'
import { Eye, LockKey, Pencil, Trash } from '@phosphor-icons/react'
import type { PageConfig, AppLevel } from '@/lib/level-types'
interface RoutesTableProps {
pages: PageConfig[]
onEdit: (page: PageConfig) => void
onDelete: (pageId: string) => void
}
const getLevelBadgeColor = (level: AppLevel) => {
switch (level) {
case 1: return 'bg-blue-500'
case 2: return 'bg-green-500'
case 3: return 'bg-orange-500'
case 4: return 'bg-sky-500'
case 5: return 'bg-purple-500'
case 6: return 'bg-rose-500'
default: return 'bg-gray-500'
}
}
export function RoutesTable({ pages, onEdit, onDelete }: RoutesTableProps) {
if (pages.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<p>No pages configured yet. Create your first page route!</p>
</div>
)
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Path</TableHead>
<TableHead>Title</TableHead>
<TableHead>Level</TableHead>
<TableHead>Auth</TableHead>
<TableHead>Required Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pages.map((page) => (
<TableRow key={page.id}>
<TableCell className="font-mono text-sm">{page.path}</TableCell>
<TableCell>{page.title}</TableCell>
<TableCell>
<Badge className={getLevelBadgeColor(page.level)}>
Level {page.level}
</Badge>
</TableCell>
<TableCell>
{page.requiresAuth ? (
<LockKey className="text-orange-500" weight="fill" />
) : (
<Eye className="text-green-500" weight="fill" />
)}
</TableCell>
<TableCell>
<Badge variant="outline">
{page.requiredRole || 'public'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => onEdit(page)}
>
<Pencil />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(page.id)}
>
<Trash />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}