mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 22:04:56 +00:00
Merge branch 'main' into codex/introduce-shared-helpers-and-refactor-storage
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export { ACLAdapter } from './acl-adapter/index'
|
||||
export type { User, ACLRule } from './acl/types'
|
||||
export { ACLAdapter } from './acl-adapter'
|
||||
export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './acl-adapter/types'
|
||||
export { defaultACLRules } from './acl/default-rules'
|
||||
|
||||
86
dbal/development/src/adapters/acl-adapter/acl-adapter.ts
Normal file
86
dbal/development/src/adapters/acl-adapter/acl-adapter.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { AdapterCapabilities, DBALAdapter } from '../adapter'
|
||||
import type { ListOptions, ListResult } from '../../core/foundation/types'
|
||||
import { createContext } from './context'
|
||||
import { createReadStrategy } from './read-strategy'
|
||||
import { createWriteStrategy } from './write-strategy'
|
||||
import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
|
||||
|
||||
export class ACLAdapter implements DBALAdapter {
|
||||
private readonly context: ACLContext
|
||||
private readonly readStrategy: ReturnType<typeof createReadStrategy>
|
||||
private readonly writeStrategy: ReturnType<typeof createWriteStrategy>
|
||||
|
||||
constructor(baseAdapter: DBALAdapter, user: User, options?: ACLAdapterOptions) {
|
||||
this.context = createContext(baseAdapter, user, options)
|
||||
this.readStrategy = createReadStrategy(this.context)
|
||||
this.writeStrategy = createWriteStrategy(this.context)
|
||||
}
|
||||
|
||||
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
return this.writeStrategy.create(entity, data)
|
||||
}
|
||||
|
||||
async read(entity: string, id: string): Promise<unknown | null> {
|
||||
return this.readStrategy.read(entity, id)
|
||||
}
|
||||
|
||||
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
return this.writeStrategy.update(entity, id, data)
|
||||
}
|
||||
|
||||
async delete(entity: string, id: string): Promise<boolean> {
|
||||
return this.writeStrategy.delete(entity, id)
|
||||
}
|
||||
|
||||
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
|
||||
return this.readStrategy.list(entity, options)
|
||||
}
|
||||
|
||||
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
|
||||
return this.readStrategy.findFirst(entity, filter)
|
||||
}
|
||||
|
||||
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
|
||||
return this.readStrategy.findByField(entity, field, value)
|
||||
}
|
||||
|
||||
async upsert(
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return this.writeStrategy.upsert(entity, filter, createData, updateData)
|
||||
}
|
||||
|
||||
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
|
||||
return this.writeStrategy.updateByField(entity, field, value, data)
|
||||
}
|
||||
|
||||
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
|
||||
return this.writeStrategy.deleteByField(entity, field, value)
|
||||
}
|
||||
|
||||
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
return this.writeStrategy.createMany(entity, data)
|
||||
}
|
||||
|
||||
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
|
||||
return this.writeStrategy.updateMany(entity, filter, data)
|
||||
}
|
||||
|
||||
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
|
||||
return this.writeStrategy.deleteMany(entity, filter)
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AdapterCapabilities> {
|
||||
return this.context.baseAdapter.getCapabilities()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.context.baseAdapter.close()
|
||||
}
|
||||
}
|
||||
|
||||
export type { ACLAdapterOptions, ACLContext, ACLRule, User }
|
||||
export { defaultACLRules } from '../acl/default-rules'
|
||||
@@ -1,20 +1,12 @@
|
||||
import type { DBALAdapter } from '../adapter'
|
||||
import type { User, ACLRule } from '../acl/types'
|
||||
import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
|
||||
import { logAudit } from '../acl/audit-logger'
|
||||
import { defaultACLRules } from '../acl/default-rules'
|
||||
|
||||
export interface ACLContext {
|
||||
baseAdapter: DBALAdapter
|
||||
user: User
|
||||
rules: ACLRule[]
|
||||
auditLog: boolean
|
||||
logger: (entity: string, operation: string, success: boolean, message?: string) => void
|
||||
}
|
||||
|
||||
export const createContext = (
|
||||
baseAdapter: DBALAdapter,
|
||||
user: User,
|
||||
options?: { rules?: ACLRule[]; auditLog?: boolean },
|
||||
options?: ACLAdapterOptions,
|
||||
): ACLContext => {
|
||||
const auditLog = options?.auditLog ?? true
|
||||
const rules = options?.rules || defaultACLRules
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { checkPermission } from '../acl/check-permission'
|
||||
import { checkRowLevelAccess } from '../acl/check-row-level-access'
|
||||
import { resolvePermissionOperation } from '../acl/resolve-permission-operation'
|
||||
import type { ACLContext } from './context'
|
||||
import type { ACLContext } from './types'
|
||||
|
||||
export const enforcePermission = (context: ACLContext, entity: string, operation: string) => {
|
||||
checkPermission(entity, operation, context.user, context.rules, context.logger)
|
||||
|
||||
@@ -1,92 +1,3 @@
|
||||
import type { AdapterCapabilities, DBALAdapter } from '../adapter'
|
||||
import type { ListOptions, ListResult } from '../../core/foundation/types'
|
||||
import type { User, ACLRule } from '../acl/types'
|
||||
import type { ACLContext } from './context'
|
||||
import { createContext } from './context'
|
||||
import { createEntity, deleteEntity, listEntities, readEntity, updateEntity } from './crud'
|
||||
import {
|
||||
createMany,
|
||||
deleteByField,
|
||||
deleteMany,
|
||||
findByField,
|
||||
findFirst,
|
||||
updateByField,
|
||||
updateMany,
|
||||
upsert,
|
||||
} from './bulk'
|
||||
|
||||
export class ACLAdapter implements DBALAdapter {
|
||||
private readonly context: ACLContext
|
||||
|
||||
constructor(baseAdapter: DBALAdapter, user: User, options?: { rules?: ACLRule[]; auditLog?: boolean }) {
|
||||
this.context = createContext(baseAdapter, user, options)
|
||||
}
|
||||
|
||||
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
return createEntity(this.context)(entity, data)
|
||||
}
|
||||
|
||||
async read(entity: string, id: string): Promise<unknown | null> {
|
||||
return readEntity(this.context)(entity, id)
|
||||
}
|
||||
|
||||
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
return updateEntity(this.context)(entity, id, data)
|
||||
}
|
||||
|
||||
async delete(entity: string, id: string): Promise<boolean> {
|
||||
return deleteEntity(this.context)(entity, id)
|
||||
}
|
||||
|
||||
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
|
||||
return listEntities(this.context)(entity, options)
|
||||
}
|
||||
|
||||
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
|
||||
return findFirst(this.context)(entity, filter)
|
||||
}
|
||||
|
||||
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
|
||||
return findByField(this.context)(entity, field, value)
|
||||
}
|
||||
|
||||
async upsert(
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return upsert(this.context)(entity, filter, createData, updateData)
|
||||
}
|
||||
|
||||
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
|
||||
return updateByField(this.context)(entity, field, value, data)
|
||||
}
|
||||
|
||||
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
|
||||
return deleteByField(this.context)(entity, field, value)
|
||||
}
|
||||
|
||||
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
return createMany(this.context)(entity, data)
|
||||
}
|
||||
|
||||
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
|
||||
return updateMany(this.context)(entity, filter, data)
|
||||
}
|
||||
|
||||
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
|
||||
return deleteMany(this.context)(entity, filter)
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AdapterCapabilities> {
|
||||
return this.context.baseAdapter.getCapabilities()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.context.baseAdapter.close()
|
||||
}
|
||||
}
|
||||
|
||||
export type { User, ACLRule } from './acl/types'
|
||||
export { defaultACLRules } from './acl/default-rules'
|
||||
export { ACLAdapter } from './acl-adapter'
|
||||
export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types'
|
||||
export { defaultACLRules } from '../acl/default-rules'
|
||||
|
||||
48
dbal/development/src/adapters/acl-adapter/read-strategy.ts
Normal file
48
dbal/development/src/adapters/acl-adapter/read-strategy.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ListOptions, ListResult } from '../../core/foundation/types'
|
||||
import { enforceRowAccess, resolveOperation, withAudit } from './guards'
|
||||
import type { ACLContext } from './types'
|
||||
|
||||
export const createReadStrategy = (context: ACLContext) => {
|
||||
const read = async (entity: string, id: string): Promise<unknown | null> => {
|
||||
return withAudit(context, entity, 'read', async () => {
|
||||
const result = await context.baseAdapter.read(entity, id)
|
||||
if (result) {
|
||||
enforceRowAccess(context, entity, 'read', result as Record<string, unknown>)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
const list = async (entity: string, options?: ListOptions): Promise<ListResult<unknown>> => {
|
||||
return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options))
|
||||
}
|
||||
|
||||
const findFirst = async (entity: string, filter?: Record<string, unknown>): Promise<unknown | null> => {
|
||||
const operation = resolveOperation('findFirst')
|
||||
return withAudit(context, entity, operation, async () => {
|
||||
const result = await context.baseAdapter.findFirst(entity, filter)
|
||||
if (result) {
|
||||
enforceRowAccess(context, entity, operation, result as Record<string, unknown>)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
const findByField = async (entity: string, field: string, value: unknown): Promise<unknown | null> => {
|
||||
const operation = resolveOperation('findByField')
|
||||
return withAudit(context, entity, operation, async () => {
|
||||
const result = await context.baseAdapter.findByField(entity, field, value)
|
||||
if (result) {
|
||||
enforceRowAccess(context, entity, operation, result as Record<string, unknown>)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
list,
|
||||
findFirst,
|
||||
findByField,
|
||||
}
|
||||
}
|
||||
27
dbal/development/src/adapters/acl-adapter/types.ts
Normal file
27
dbal/development/src/adapters/acl-adapter/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { DBALAdapter } from '../adapter'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: 'user' | 'admin' | 'god' | 'supergod'
|
||||
}
|
||||
|
||||
export interface ACLRule {
|
||||
entity: string
|
||||
roles: string[]
|
||||
operations: string[]
|
||||
rowLevelFilter?: (user: User, data: Record<string, unknown>) => boolean
|
||||
}
|
||||
|
||||
export interface ACLAdapterOptions {
|
||||
rules?: ACLRule[]
|
||||
auditLog?: boolean
|
||||
}
|
||||
|
||||
export interface ACLContext {
|
||||
baseAdapter: DBALAdapter
|
||||
user: User
|
||||
rules: ACLRule[]
|
||||
auditLog: boolean
|
||||
logger: (entity: string, operation: string, success: boolean, message?: string) => void
|
||||
}
|
||||
83
dbal/development/src/adapters/acl-adapter/write-strategy.ts
Normal file
83
dbal/development/src/adapters/acl-adapter/write-strategy.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { enforceRowAccess, resolveOperation, withAudit } from './guards'
|
||||
import type { ACLContext } from './types'
|
||||
|
||||
export const createWriteStrategy = (context: ACLContext) => {
|
||||
const create = async (entity: string, data: Record<string, unknown>): Promise<unknown> => {
|
||||
return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data))
|
||||
}
|
||||
|
||||
const update = async (entity: string, id: string, data: Record<string, unknown>): Promise<unknown> => {
|
||||
return withAudit(context, entity, 'update', async () => {
|
||||
const existing = await context.baseAdapter.read(entity, id)
|
||||
if (existing) {
|
||||
enforceRowAccess(context, entity, 'update', existing as Record<string, unknown>)
|
||||
}
|
||||
return context.baseAdapter.update(entity, id, data)
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (entity: string, id: string): Promise<boolean> => {
|
||||
return withAudit(context, entity, 'delete', async () => {
|
||||
const existing = await context.baseAdapter.read(entity, id)
|
||||
if (existing) {
|
||||
enforceRowAccess(context, entity, 'delete', existing as Record<string, unknown>)
|
||||
}
|
||||
return context.baseAdapter.delete(entity, id)
|
||||
})
|
||||
}
|
||||
|
||||
const upsert = async (
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>,
|
||||
): Promise<unknown> => {
|
||||
return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData))
|
||||
}
|
||||
|
||||
const updateByField = async (
|
||||
entity: string,
|
||||
field: string,
|
||||
value: unknown,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<unknown> => {
|
||||
const operation = resolveOperation('updateByField')
|
||||
return withAudit(context, entity, operation, () => context.baseAdapter.updateByField(entity, field, value, data))
|
||||
}
|
||||
|
||||
const deleteByField = async (entity: string, field: string, value: unknown): Promise<boolean> => {
|
||||
const operation = resolveOperation('deleteByField')
|
||||
return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value))
|
||||
}
|
||||
|
||||
const createMany = async (entity: string, data: Record<string, unknown>[]): Promise<number> => {
|
||||
const operation = resolveOperation('createMany')
|
||||
return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data))
|
||||
}
|
||||
|
||||
const updateMany = async (
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<number> => {
|
||||
const operation = resolveOperation('updateMany')
|
||||
return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data))
|
||||
}
|
||||
|
||||
const deleteMany = async (entity: string, filter?: Record<string, unknown>): Promise<number> => {
|
||||
const operation = resolveOperation('deleteMany')
|
||||
return withAudit(context, entity, operation, () => context.baseAdapter.deleteMany(entity, filter))
|
||||
}
|
||||
|
||||
return {
|
||||
create,
|
||||
update,
|
||||
delete: remove,
|
||||
upsert,
|
||||
updateByField,
|
||||
deleteByField,
|
||||
createMany,
|
||||
updateMany,
|
||||
deleteMany,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description Audit logging for ACL operations
|
||||
*/
|
||||
|
||||
import type { User } from './types'
|
||||
import type { User } from '../acl-adapter/types'
|
||||
|
||||
/**
|
||||
* Log audit entry for ACL operation
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { DBALError } from '../../core/foundation/errors'
|
||||
import type { User, ACLRule } from './types'
|
||||
import type { ACLRule, User } from '../acl-adapter/types'
|
||||
|
||||
/**
|
||||
* Check if user has permission to perform operation on entity
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { DBALError } from '../../core/foundation/errors'
|
||||
import type { User, ACLRule } from './types'
|
||||
import type { ACLRule, User } from '../acl-adapter/types'
|
||||
|
||||
/**
|
||||
* Check row-level access for specific data
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description Default ACL rules for entities
|
||||
*/
|
||||
|
||||
import type { ACLRule } from './types'
|
||||
import type { ACLRule } from '../acl-adapter/types'
|
||||
|
||||
export const defaultACLRules: ACLRule[] = [
|
||||
{
|
||||
|
||||
19
dbal/development/src/core/foundation/types/entities.ts
Normal file
19
dbal/development/src/core/foundation/types/entities.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type EntityId = string
|
||||
|
||||
export interface BaseEntity {
|
||||
id: EntityId
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface SoftDeletableEntity extends BaseEntity {
|
||||
deletedAt?: Date
|
||||
}
|
||||
|
||||
export interface TenantScopedEntity extends BaseEntity {
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export interface EntityMetadata {
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
13
dbal/development/src/core/foundation/types/events.ts
Normal file
13
dbal/development/src/core/foundation/types/events.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { OperationContext } from './operations'
|
||||
|
||||
export interface DomainEvent<TPayload = Record<string, unknown>> {
|
||||
id: string
|
||||
name: string
|
||||
occurredAt: Date
|
||||
payload: TPayload
|
||||
context?: OperationContext
|
||||
}
|
||||
|
||||
export interface EventHandler<TPayload = Record<string, unknown>> {
|
||||
(event: DomainEvent<TPayload>): void | Promise<void>
|
||||
}
|
||||
@@ -4,3 +4,6 @@ export * from './content'
|
||||
export * from './automation'
|
||||
export * from './packages'
|
||||
export * from './shared'
|
||||
export * from './entities'
|
||||
export * from './operations'
|
||||
export * from './events'
|
||||
|
||||
19
dbal/development/src/core/foundation/types/operations.ts
Normal file
19
dbal/development/src/core/foundation/types/operations.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface OperationContext {
|
||||
tenantId?: string
|
||||
userId?: string
|
||||
correlationId?: string
|
||||
traceId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface OperationOptions {
|
||||
timeoutMs?: number
|
||||
retryCount?: number
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export interface OperationAuditTrail {
|
||||
performedAt: Date
|
||||
performedBy?: string
|
||||
context?: OperationContext
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import path from 'path'
|
||||
import { CppBuildAssistant, runCppBuildAssistant } from './cpp-build-assistant/index'
|
||||
import { runCppBuildAssistant } from './cpp-build-assistant/runner'
|
||||
|
||||
export { CppBuildAssistant, runCppBuildAssistant }
|
||||
export { CppBuildAssistant, createAssistant } from './cpp-build-assistant'
|
||||
export { createCppBuildAssistantConfig } from './cpp-build-assistant/config'
|
||||
export { runCppBuildAssistant } from './cpp-build-assistant/runner'
|
||||
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2)
|
||||
const projectRoot = path.join(__dirname, '..')
|
||||
|
||||
runCppBuildAssistant(args, path.join(__dirname, '..'))
|
||||
runCppBuildAssistant(args, projectRoot)
|
||||
.then(success => {
|
||||
process.exit(success ? 0 : 1)
|
||||
})
|
||||
|
||||
125
dbal/shared/tools/cpp-build-assistant/cli.ts
Normal file
125
dbal/shared/tools/cpp-build-assistant/cli.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import os from 'os'
|
||||
import { BuildType } from './config'
|
||||
import { COLORS, log } from './logging'
|
||||
import { CppBuildAssistant } from './index'
|
||||
|
||||
export type CliCommand =
|
||||
| 'check'
|
||||
| 'init'
|
||||
| 'install'
|
||||
| 'configure'
|
||||
| 'build'
|
||||
| 'test'
|
||||
| 'clean'
|
||||
| 'rebuild'
|
||||
| 'full'
|
||||
| 'help'
|
||||
|
||||
export interface ParsedCliArgs {
|
||||
command: CliCommand
|
||||
buildType: BuildType
|
||||
jobs: number
|
||||
target?: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
const parseBuildType = (options: string[]): BuildType => (options.includes('--debug') ? 'Debug' : 'Release')
|
||||
|
||||
const parseJobs = (options: string[]): number => {
|
||||
const jobsArg = options.find(option => option.startsWith('--jobs='))
|
||||
const parsedJobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : Number.NaN
|
||||
|
||||
return Number.isNaN(parsedJobs) ? os.cpus().length : parsedJobs
|
||||
}
|
||||
|
||||
const parseTarget = (command: CliCommand, options: string[]): string | undefined => {
|
||||
if (command !== 'build') return undefined
|
||||
|
||||
return options.find(option => !option.startsWith('--')) || 'all'
|
||||
}
|
||||
|
||||
export const parseCliArgs = (args: string[]): ParsedCliArgs => {
|
||||
const command = (args[0] as CliCommand | undefined) || 'help'
|
||||
const options = args.slice(1)
|
||||
|
||||
return {
|
||||
command,
|
||||
buildType: parseBuildType(options),
|
||||
jobs: parseJobs(options),
|
||||
target: parseTarget(command, options),
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
export const showHelp = (): void => {
|
||||
console.log(`
|
||||
${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper
|
||||
|
||||
${COLORS.cyan}USAGE:${COLORS.reset}
|
||||
npm run cpp:build [command] [options]
|
||||
|
||||
${COLORS.cyan}COMMANDS:${COLORS.reset}
|
||||
${COLORS.green}check${COLORS.reset} Check if all dependencies are installed
|
||||
${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing)
|
||||
${COLORS.green}install${COLORS.reset} Install Conan dependencies
|
||||
${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator
|
||||
${COLORS.green}build${COLORS.reset} [target] Build the project (default: all)
|
||||
${COLORS.green}test${COLORS.reset} Run tests with CTest
|
||||
${COLORS.green}clean${COLORS.reset} Remove build artifacts
|
||||
${COLORS.green}rebuild${COLORS.reset} Clean and rebuild
|
||||
${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build
|
||||
${COLORS.green}help${COLORS.reset} Show this help message
|
||||
|
||||
${COLORS.cyan}OPTIONS:${COLORS.reset}
|
||||
--debug Use Debug build type
|
||||
--release Use Release build type (default)
|
||||
--jobs=N Number of parallel build jobs (default: CPU count)
|
||||
|
||||
${COLORS.cyan}EXAMPLES:${COLORS.reset}
|
||||
npm run cpp:build check
|
||||
npm run cpp:build full
|
||||
npm run cpp:build build dbal_daemon
|
||||
npm run cpp:build build -- --debug
|
||||
npm run cpp:build test
|
||||
`)
|
||||
}
|
||||
|
||||
export const runCli = async (args: string[], assistant: CppBuildAssistant): Promise<boolean> => {
|
||||
const parsed = parseCliArgs(args)
|
||||
|
||||
switch (parsed.command) {
|
||||
case 'check':
|
||||
return assistant.checkDependencies()
|
||||
case 'init':
|
||||
return assistant.createConanfile()
|
||||
case 'install':
|
||||
if (!assistant.checkDependencies()) return false
|
||||
return assistant.installConanDeps()
|
||||
case 'configure':
|
||||
if (!assistant.checkDependencies()) return false
|
||||
return assistant.configureCMake(parsed.buildType)
|
||||
case 'build':
|
||||
if (!assistant.checkDependencies()) return false
|
||||
return assistant.build(parsed.target, parsed.jobs)
|
||||
case 'test':
|
||||
return assistant.test()
|
||||
case 'clean':
|
||||
return assistant.clean()
|
||||
case 'rebuild':
|
||||
assistant.clean()
|
||||
if (!assistant.checkDependencies()) return false
|
||||
if (!assistant.configureCMake(parsed.buildType)) return false
|
||||
return assistant.build('all', parsed.jobs)
|
||||
case 'full':
|
||||
log.section('Full Build Workflow')
|
||||
if (!assistant.checkDependencies()) return false
|
||||
if (!assistant.createConanfile()) return false
|
||||
if (!assistant.installConanDeps()) return false
|
||||
if (!assistant.configureCMake(parsed.buildType)) return false
|
||||
return assistant.build('all', parsed.jobs)
|
||||
case 'help':
|
||||
default:
|
||||
showHelp()
|
||||
return true
|
||||
}
|
||||
}
|
||||
20
dbal/shared/tools/cpp-build-assistant/config.ts
Normal file
20
dbal/shared/tools/cpp-build-assistant/config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import path from 'path'
|
||||
|
||||
export type BuildType = 'Debug' | 'Release'
|
||||
|
||||
export interface CppBuildAssistantConfig {
|
||||
projectRoot: string
|
||||
cppDir: string
|
||||
buildDir: string
|
||||
}
|
||||
|
||||
export const createCppBuildAssistantConfig = (projectRoot?: string): CppBuildAssistantConfig => {
|
||||
const resolvedProjectRoot = projectRoot || path.join(__dirname, '..')
|
||||
const cppDir = path.join(resolvedProjectRoot, 'cpp')
|
||||
|
||||
return {
|
||||
projectRoot: resolvedProjectRoot,
|
||||
cppDir,
|
||||
buildDir: path.join(cppDir, 'build'),
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { CppBuildAssistantConfig, BuildType, createCppBuildAssistantConfig } from './config'
|
||||
import { COLORS, log } from './logging'
|
||||
import { checkDependencies } from './dependencies'
|
||||
import { cleanBuild, configureCMake, ensureConanFile, execCommand, installConanDeps, buildTarget, runTests } from './workflow'
|
||||
|
||||
export class CppBuildAssistant {
|
||||
private projectRoot: string
|
||||
private cppDir: string
|
||||
private buildDir: string
|
||||
private config: CppBuildAssistantConfig
|
||||
|
||||
constructor(projectRoot?: string) {
|
||||
this.projectRoot = projectRoot || path.join(__dirname, '..')
|
||||
this.cppDir = path.join(this.projectRoot, 'cpp')
|
||||
this.buildDir = path.join(this.cppDir, 'build')
|
||||
constructor(config?: CppBuildAssistantConfig) {
|
||||
this.config = config || createCppBuildAssistantConfig()
|
||||
}
|
||||
|
||||
get projectRoot(): string {
|
||||
return this.config.projectRoot
|
||||
}
|
||||
|
||||
get cppDir(): string {
|
||||
return this.config.cppDir
|
||||
}
|
||||
|
||||
get buildDir(): string {
|
||||
return this.config.buildDir
|
||||
}
|
||||
|
||||
checkDependencies(): boolean {
|
||||
@@ -27,7 +36,7 @@ export class CppBuildAssistant {
|
||||
return installConanDeps(this.cppDir, execCommand)
|
||||
}
|
||||
|
||||
configureCMake(buildType: 'Debug' | 'Release' = 'Release'): boolean {
|
||||
configureCMake(buildType: BuildType = 'Release'): boolean {
|
||||
return configureCMake(this.cppDir, buildType, execCommand)
|
||||
}
|
||||
|
||||
@@ -42,88 +51,11 @@ export class CppBuildAssistant {
|
||||
clean(): boolean {
|
||||
return cleanBuild(this.buildDir)
|
||||
}
|
||||
|
||||
async run(args: string[]): Promise<boolean> {
|
||||
const command = args[0] || 'help'
|
||||
const options = args.slice(1)
|
||||
|
||||
const buildType = options.includes('--debug') ? 'Debug' : 'Release'
|
||||
const jobsArg = options.find(option => option.startsWith('--jobs='))
|
||||
const jobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : os.cpus().length
|
||||
|
||||
switch (command) {
|
||||
case 'check':
|
||||
return this.checkDependencies()
|
||||
case 'init':
|
||||
return this.createConanfile()
|
||||
case 'install':
|
||||
if (!this.checkDependencies()) return false
|
||||
return this.installConanDeps()
|
||||
case 'configure':
|
||||
if (!this.checkDependencies()) return false
|
||||
return this.configureCMake(buildType as 'Debug' | 'Release')
|
||||
case 'build':
|
||||
if (!this.checkDependencies()) return false
|
||||
const target = options.find(option => !option.startsWith('--')) || 'all'
|
||||
return this.build(target, jobs)
|
||||
case 'test':
|
||||
return this.test()
|
||||
case 'clean':
|
||||
return this.clean()
|
||||
case 'rebuild':
|
||||
this.clean()
|
||||
if (!this.checkDependencies()) return false
|
||||
if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false
|
||||
return this.build('all', jobs)
|
||||
case 'full':
|
||||
log.section('Full Build Workflow')
|
||||
if (!this.checkDependencies()) return false
|
||||
if (!this.createConanfile()) return false
|
||||
if (!this.installConanDeps()) return false
|
||||
if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false
|
||||
return this.build('all', jobs)
|
||||
case 'help':
|
||||
default:
|
||||
this.showHelp()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private showHelp(): void {
|
||||
console.log(`
|
||||
${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper
|
||||
|
||||
${COLORS.cyan}USAGE:${COLORS.reset}
|
||||
npm run cpp:build [command] [options]
|
||||
|
||||
${COLORS.cyan}COMMANDS:${COLORS.reset}
|
||||
${COLORS.green}check${COLORS.reset} Check if all dependencies are installed
|
||||
${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing)
|
||||
${COLORS.green}install${COLORS.reset} Install Conan dependencies
|
||||
${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator
|
||||
${COLORS.green}build${COLORS.reset} [target] Build the project (default: all)
|
||||
${COLORS.green}test${COLORS.reset} Run tests with CTest
|
||||
${COLORS.green}clean${COLORS.reset} Remove build artifacts
|
||||
${COLORS.green}rebuild${COLORS.reset} Clean and rebuild
|
||||
${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build
|
||||
${COLORS.green}help${COLORS.reset} Show this help message
|
||||
|
||||
${COLORS.cyan}OPTIONS:${COLORS.reset}
|
||||
--debug Use Debug build type
|
||||
--release Use Release build type (default)
|
||||
--jobs=N Number of parallel build jobs (default: CPU count)
|
||||
|
||||
${COLORS.cyan}EXAMPLES:${COLORS.reset}
|
||||
npm run cpp:build check
|
||||
npm run cpp:build full
|
||||
npm run cpp:build build dbal_daemon
|
||||
npm run cpp:build build -- --debug
|
||||
npm run cpp:build test
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
export const runCppBuildAssistant = async (args: string[], projectRoot?: string) => {
|
||||
const assistant = new CppBuildAssistant(projectRoot || path.join(__dirname, '..'))
|
||||
return assistant.run(args)
|
||||
export const createAssistant = (projectRoot?: string): CppBuildAssistant => {
|
||||
const config = createCppBuildAssistantConfig(projectRoot || path.join(__dirname, '..'))
|
||||
return new CppBuildAssistant(config)
|
||||
}
|
||||
|
||||
export { BuildType, CppBuildAssistantConfig, COLORS, log }
|
||||
|
||||
10
dbal/shared/tools/cpp-build-assistant/runner.ts
Normal file
10
dbal/shared/tools/cpp-build-assistant/runner.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { CppBuildAssistant } from './index'
|
||||
import { createCppBuildAssistantConfig } from './config'
|
||||
import { runCli } from './cli'
|
||||
|
||||
export const runCppBuildAssistant = async (args: string[], projectRoot?: string): Promise<boolean> => {
|
||||
const config = createCppBuildAssistantConfig(projectRoot)
|
||||
const assistant = new CppBuildAssistant(config)
|
||||
|
||||
return runCli(args, assistant)
|
||||
}
|
||||
@@ -1,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
31
frontends/nextjs/src/components/editors/json/Toolbar.tsx
Normal file
31
frontends/nextjs/src/components/editors/json/Toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
41
frontends/nextjs/src/components/editors/theme/constants.ts
Normal file
41
frontends/nextjs/src/components/editors/theme/constants.ts
Normal 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)',
|
||||
}
|
||||
25
frontends/nextjs/src/components/editors/theme/types.ts
Normal file
25
frontends/nextjs/src/components/editors/theme/types.ts
Normal 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
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui'
|
||||
import { Stack, Typography } from '@/components/ui'
|
||||
import { toast } from 'sonner'
|
||||
import { AppHeader } from '@/components/shared/AppHeader'
|
||||
import { Database } from '@/lib/database'
|
||||
import type { Comment, User } from '@/lib/level-types'
|
||||
import { AppHeader } from '@/components/shared/AppHeader'
|
||||
import { ModeratorActions } from './ModeratorPanel/Actions'
|
||||
import { ModeratorHeader } from './ModeratorPanel/Header'
|
||||
import { ModeratorLogList } from './ModeratorPanel/LogList'
|
||||
|
||||
const FLAGGED_TERMS = ['spam', 'error', 'abuse', 'illegal', 'urgent', 'offensive']
|
||||
|
||||
@@ -70,8 +61,6 @@ export function ModeratorPanel({ user, onLogout, onNavigate }: ModeratorPanelPro
|
||||
toast.success('Flag resolved and archived from the queue')
|
||||
}
|
||||
|
||||
const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader
|
||||
@@ -85,116 +74,19 @@ export function ModeratorPanel({ user, onLogout, onNavigate }: ModeratorPanelPro
|
||||
/>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Typography variant="h4">Moderation queue</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Keep the community healthy by resolving flags, reviewing reports, and guiding the tone.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flagged content</CardTitle>
|
||||
<CardDescription>Automated signal based on keywords</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="h3">{flaggedComments.length}</Typography>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Pending items in the moderation queue
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resolved this session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="h3">{resolvedIds.length}</Typography>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Items you flagged as handled
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Community signals</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{FLAGGED_TERMS.map((term) => (
|
||||
<Badge key={term}>{highlightLabel(term)}</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Track the keywords that pulled items into the queue
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Flagged comments</CardTitle>
|
||||
<CardDescription>A curated view of the comments that triggered a signal</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(2)}>
|
||||
Go to user dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Typography color="text.secondary">Loading flagged comments…</Typography>
|
||||
) : flaggedComments.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
No flagged comments at the moment. Enjoy the calm.
|
||||
</Typography>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
<TableHead>Matched terms</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flaggedComments.map((comment) => {
|
||||
const matches = FLAGGED_TERMS.filter((term) =>
|
||||
comment.content.toLowerCase().includes(term)
|
||||
)
|
||||
return (
|
||||
<TableRow key={comment.id}>
|
||||
<TableCell className="font-mono text-sm">{comment.userId}</TableCell>
|
||||
<TableCell>{comment.content}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{matches.map((match) => (
|
||||
<Badge key={`${comment.id}-${match}`} variant="outline">
|
||||
{match}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleResolve(comment.id)}>
|
||||
Mark safe
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ModeratorHeader />
|
||||
<ModeratorActions
|
||||
flaggedCount={flaggedComments.length}
|
||||
resolvedCount={resolvedIds.length}
|
||||
flaggedTerms={FLAGGED_TERMS}
|
||||
/>
|
||||
<ModeratorLogList
|
||||
flaggedComments={flaggedComments}
|
||||
flaggedTerms={FLAGGED_TERMS}
|
||||
isLoading={isLoading}
|
||||
onNavigate={onNavigate}
|
||||
onResolve={handleResolve}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack, Typography } from '@/components/ui'
|
||||
|
||||
interface ModeratorActionsProps {
|
||||
flaggedCount: number
|
||||
resolvedCount: number
|
||||
flaggedTerms: string[]
|
||||
}
|
||||
|
||||
export function ModeratorActions({ flaggedCount, resolvedCount, flaggedTerms }: ModeratorActionsProps) {
|
||||
const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flagged content</CardTitle>
|
||||
<CardDescription>Automated signal based on keywords</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="h3">{flaggedCount}</Typography>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Pending items in the moderation queue
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resolved this session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="h3">{resolvedCount}</Typography>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Items you flagged as handled
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Community signals</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{flaggedTerms.map((term) => (
|
||||
<Badge key={term}>{highlightLabel(term)}</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography color="text.secondary" className="mt-2">
|
||||
Track the keywords that pulled items into the queue
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Typography } from '@/components/ui'
|
||||
|
||||
export function ModeratorHeader() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Typography variant="h4">Moderation queue</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Keep the community healthy by resolving flags, reviewing reports, and guiding the tone.
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack } from '@/components/ui'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@/components/ui'
|
||||
import type { Comment } from '@/lib/level-types'
|
||||
|
||||
interface ModeratorLogListProps {
|
||||
flaggedComments: Comment[]
|
||||
flaggedTerms: string[]
|
||||
isLoading: boolean
|
||||
onNavigate: (level: number) => void
|
||||
onResolve: (commentId: string) => void
|
||||
}
|
||||
|
||||
export function ModeratorLogList({
|
||||
flaggedComments,
|
||||
flaggedTerms,
|
||||
isLoading,
|
||||
onNavigate,
|
||||
onResolve,
|
||||
}: ModeratorLogListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Flagged comments</CardTitle>
|
||||
<CardDescription>A curated view of the comments that triggered a signal</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(2)}>
|
||||
Go to user dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Typography color="text.secondary">Loading flagged comments…</Typography>
|
||||
) : flaggedComments.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
No flagged comments at the moment. Enjoy the calm.
|
||||
</Typography>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
<TableHead>Matched terms</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flaggedComments.map((comment) => {
|
||||
const matches = flaggedTerms.filter((term) =>
|
||||
comment.content.toLowerCase().includes(term)
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow key={comment.id}>
|
||||
<TableCell className="font-mono text-sm">{comment.userId}</TableCell>
|
||||
<TableCell>{comment.content}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{matches.map((match) => (
|
||||
<Badge key={`${comment.id}-${match}`} variant="outline">
|
||||
{match}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => onResolve(comment.id)}>
|
||||
Mark safe
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user