diff --git a/apps/web/src/config/types.ts b/apps/web/src/config/types.ts index b8e34eba..c7fbd391 100644 --- a/apps/web/src/config/types.ts +++ b/apps/web/src/config/types.ts @@ -1,3 +1,4 @@ export interface InjectConfig { useApi: boolean + useCloud: boolean } diff --git a/be/apps/core/src/errors/error-codes.ts b/be/apps/core/src/errors/error-codes.ts index 5d1b16a1..c6032f8d 100644 --- a/be/apps/core/src/errors/error-codes.ts +++ b/be/apps/core/src/errors/error-codes.ts @@ -10,6 +10,7 @@ export enum ErrorCode { AUTH_UNAUTHORIZED = 10, AUTH_FORBIDDEN = 11, AUTH_TENANT_NOT_FOUND = 12, + AUTH_TENANT_NOT_FOUND_GUARD = 13, // Tenant TENANT_NOT_FOUND = 20, @@ -59,6 +60,10 @@ export const ERROR_CODE_DESCRIPTORS: Record = { httpStatus: 400, message: 'Tenant context not found', }, + [ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD]: { + httpStatus: 400, + message: 'Tenant context not found (guard)', + }, [ErrorCode.TENANT_NOT_FOUND]: { httpStatus: 404, message: 'Tenant not found', diff --git a/be/apps/core/src/filters/all-exceptions.filter.ts b/be/apps/core/src/filters/all-exceptions.filter.ts index bb2e1fe9..1a06bf73 100644 --- a/be/apps/core/src/filters/all-exceptions.filter.ts +++ b/be/apps/core/src/filters/all-exceptions.filter.ts @@ -8,6 +8,16 @@ import { injectable } from 'tsyringe' export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = createLogger('AllExceptionsFilter') catch(exception: unknown, host: ArgumentsHost) { + const store = host.getContext() + const ctx = store.hono + + const error = exception instanceof Error ? exception : new Error(String(exception)) + + this.logger.error(`--- ${ctx.req.method} ${toUri(ctx.req.url)} --->\n`, error) + + if (process.env.NODE_ENV === 'development') { + this.logger.error(error) + } if (exception instanceof BizException) { const response = exception.toResponse() return new Response(JSON.stringify(response), { @@ -36,13 +46,6 @@ export class AllExceptionsFilter implements ExceptionFilter { }) } - const store = host.getContext() - const ctx = store.hono - - const error = exception instanceof Error ? exception : new Error(String(exception)) - - this.logger.error(`--- ${ctx.req.method} ${toUri(ctx.req.url)} --->\n`, error) - return new Response( JSON.stringify({ statusCode: 500, diff --git a/be/apps/core/src/guards/auth.guard.ts b/be/apps/core/src/guards/auth.guard.ts index 7c6bf4e7..9a833b0f 100644 --- a/be/apps/core/src/guards/auth.guard.ts +++ b/be/apps/core/src/guards/auth.guard.ts @@ -104,14 +104,14 @@ export class AuthGuard implements CanActivate { this.log.warn( `Denied access: session ${(authSession.user as { id?: string }).id ?? 'unknown'} missing tenant id for ${method} ${path}`, ) - throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND) + throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD) } if (!tenantContext) { this.log.warn( `Denied access: tenant context missing while session tenant=${sessionTenantId} accessing ${method} ${path}`, ) - throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND) + throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD) } if (sessionTenantId !== tenantContext.tenant.id) { this.log.warn( diff --git a/be/apps/core/src/modules/auth/auth-registration.service.ts b/be/apps/core/src/modules/auth/auth-registration.service.ts index b048f04c..fc7eeb12 100644 --- a/be/apps/core/src/modules/auth/auth-registration.service.ts +++ b/be/apps/core/src/modules/auth/auth-registration.service.ts @@ -4,6 +4,10 @@ import { eq } from 'drizzle-orm' import { injectable } from 'tsyringe' import { DbAccessor } from '../../database/database.provider' +import { SETTING_SCHEMAS } from '../setting/setting.constant' +import type { SettingEntryInput } from '../setting/setting.service' +import { SettingService } from '../setting/setting.service' +import type { SettingKeyType } from '../setting/setting.type' import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service' import { getTenantContext } from '../tenant/tenant.context' import { TenantRepository } from '../tenant/tenant.repository' @@ -23,6 +27,7 @@ type RegisterTenantInput = { name: string slug?: string | null } + settings?: Array<{ key: string; value: unknown }> } export interface RegisterTenantResult { @@ -49,6 +54,7 @@ export class AuthRegistrationService { private readonly tenantService: TenantService, private readonly tenantRepository: TenantRepository, private readonly superAdminSettings: SuperAdminSettingService, + private readonly settingService: SettingService, private readonly dbAccessor: DbAccessor, ) {} @@ -66,7 +72,7 @@ export class AuthRegistrationService { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户信息不能为空' }) } - return await this.registerNewTenant(account, input.tenant, headers) + return await this.registerNewTenant(account, input.tenant, headers, input.settings) } private async generateUniqueSlug(base: string): Promise { @@ -157,10 +163,44 @@ export class AuthRegistrationService { } } + private normalizeSettings(settings?: RegisterTenantInput['settings']): SettingEntryInput[] { + if (!settings || settings.length === 0) { + return [] + } + + const normalized: SettingEntryInput[] = [] + + for (const entry of settings) { + const key = entry.key?.trim() ?? '' + if (!key) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: 'Setting key cannot be empty', + }) + } + + if (!(key in SETTING_SCHEMAS)) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: `Unknown setting key: ${key}`, + }) + } + + const schema = SETTING_SCHEMAS[key as SettingKeyType] + const value = schema.parse(entry.value) + + normalized.push({ + key: key as SettingKeyType, + value, + }) + } + + return normalized + } + private async registerNewTenant( account: Required, tenantInput: RegisterTenantInput['tenant'], headers: Headers, + settings?: RegisterTenantInput['settings'], ): Promise { const tenantName = tenantInput?.name?.trim() ?? '' if (!tenantName) { @@ -222,6 +262,19 @@ export class AuthRegistrationService { const db = this.dbAccessor.get() await db.update(authUsers).set({ tenantId, role: 'admin' }).where(eq(authUsers.id, userId)) + const initialSettings = this.normalizeSettings(settings) + if (initialSettings.length > 0) { + await this.settingService.setMany( + initialSettings.map((entry) => ({ + ...entry, + options: { + tenantId, + isSensitive: false, + }, + })), + ) + } + const refreshed = await this.tenantService.getById(tenantId) return { diff --git a/be/apps/core/src/modules/auth/auth.controller.ts b/be/apps/core/src/modules/auth/auth.controller.ts index 2c33d197..30867f14 100644 --- a/be/apps/core/src/modules/auth/auth.controller.ts +++ b/be/apps/core/src/modules/auth/auth.controller.ts @@ -59,6 +59,7 @@ type TenantSignUpRequest = { name?: string slug?: string | null } + settings?: Array<{ key?: string; value?: unknown }> } type SocialSignInRequest = { @@ -208,6 +209,7 @@ export class AuthController { slug: body.tenant.slug ?? null, } : undefined, + settings: body.settings, }, headers, ) diff --git a/be/apps/core/src/modules/auth/auth.module.ts b/be/apps/core/src/modules/auth/auth.module.ts index 85597632..31e9a332 100644 --- a/be/apps/core/src/modules/auth/auth.module.ts +++ b/be/apps/core/src/modules/auth/auth.module.ts @@ -1,6 +1,7 @@ import { Module } from '@afilmory/framework' import { DatabaseModule } from 'core/database/database.module' +import { SettingModule } from '../setting/setting.module' import { SystemSettingModule } from '../system-setting/system-setting.module' import { TenantModule } from '../tenant/tenant.module' import { AuthConfig } from './auth.config' @@ -9,7 +10,7 @@ import { AuthProvider } from './auth.provider' import { AuthRegistrationService } from './auth-registration.service' @Module({ - imports: [DatabaseModule, SystemSettingModule, TenantModule], + imports: [DatabaseModule, SystemSettingModule, SettingModule, TenantModule], controllers: [AuthController], providers: [AuthProvider, AuthConfig, AuthRegistrationService], }) diff --git a/be/apps/core/src/modules/index.module.ts b/be/apps/core/src/modules/index.module.ts index cb2cff50..99b33333 100644 --- a/be/apps/core/src/modules/index.module.ts +++ b/be/apps/core/src/modules/index.module.ts @@ -14,6 +14,7 @@ import { OnboardingModule } from './onboarding/onboarding.module' import { PhotoModule } from './photo/photo.module' import { ReactionModule } from './reaction/reaction.module' import { SettingModule } from './setting/setting.module' +import { SiteSettingModule } from './site-setting/site-setting.module' import { StaticWebModule } from './static-web/static-web.module' import { StorageSettingModule } from './storage-setting/storage-setting.module' import { SuperAdminModule } from './super-admin/super-admin.module' @@ -33,6 +34,7 @@ function createEventModuleOptions(redis: RedisAccessor) { AuthModule, SettingModule, StorageSettingModule, + SiteSettingModule, SystemSettingModule, SuperAdminModule, OnboardingModule, diff --git a/be/apps/core/src/modules/onboarding/onboarding.controller.ts b/be/apps/core/src/modules/onboarding/onboarding.controller.ts index 8e94b391..7196f8a0 100644 --- a/be/apps/core/src/modules/onboarding/onboarding.controller.ts +++ b/be/apps/core/src/modules/onboarding/onboarding.controller.ts @@ -1,5 +1,7 @@ import { Body, Controller, Get, Post } from '@afilmory/framework' +import { SkipTenant } from 'core/decorators/skip-tenant.decorator' import { BizException, ErrorCode } from 'core/errors' +import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' import { OnboardingInitDto } from './onboarding.dto' import { OnboardingService } from './onboarding.service' @@ -14,6 +16,13 @@ export class OnboardingController { return { initialized } } + @Get('/site-schema') + @BypassResponseTransform() + @SkipTenant() + async getSiteSchema() { + return await this.service.getSiteSchema() + } + @Post('/init') async initialize(@Body() dto: OnboardingInitDto) { const initialized = await this.service.isInitialized() diff --git a/be/apps/core/src/modules/onboarding/onboarding.module.ts b/be/apps/core/src/modules/onboarding/onboarding.module.ts index 6f8c06aa..4f9f7920 100644 --- a/be/apps/core/src/modules/onboarding/onboarding.module.ts +++ b/be/apps/core/src/modules/onboarding/onboarding.module.ts @@ -3,12 +3,13 @@ import { Module } from '@afilmory/framework' import { DatabaseModule } from '../../database/database.module' import { AuthModule } from '../auth/auth.module' import { SettingModule } from '../setting/setting.module' +import { SiteSettingModule } from '../site-setting/site-setting.module' import { TenantModule } from '../tenant/tenant.module' import { OnboardingController } from './onboarding.controller' import { OnboardingService } from './onboarding.service' @Module({ - imports: [DatabaseModule, AuthModule, SettingModule, TenantModule], + imports: [DatabaseModule, AuthModule, SettingModule, SiteSettingModule, TenantModule], providers: [OnboardingService], controllers: [OnboardingController], }) diff --git a/be/apps/core/src/modules/onboarding/onboarding.service.ts b/be/apps/core/src/modules/onboarding/onboarding.service.ts index e451033b..ae0fdfe6 100644 --- a/be/apps/core/src/modules/onboarding/onboarding.service.ts +++ b/be/apps/core/src/modules/onboarding/onboarding.service.ts @@ -10,6 +10,7 @@ import { injectable } from 'tsyringe' import { DbAccessor } from '../../database/database.provider' import { AuthProvider } from '../auth/auth.provider' import { SettingService } from '../setting/setting.service' +import { SiteSettingService } from '../site-setting/site-setting.service' import { TenantService } from '../tenant/tenant.service' import type { NormalizedSettingEntry, OnboardingInitDto } from './onboarding.dto' @@ -22,6 +23,7 @@ export class OnboardingService { private readonly auth: AuthProvider, private readonly settings: SettingService, private readonly tenantService: TenantService, + private readonly siteSettingService: SiteSettingService, ) {} async isInitialized(): Promise { @@ -30,6 +32,10 @@ export class OnboardingService { return Boolean(user) } + async getSiteSchema() { + return await this.siteSettingService.getOnboardingUiSchema() + } + async initialize( payload: OnboardingInitDto, ): Promise<{ adminUserId: string; superAdminUserId: string; tenantId: string }> { diff --git a/be/apps/core/src/modules/setting/setting.constant.ts b/be/apps/core/src/modules/setting/setting.constant.ts index 6a662bcb..f661cf5f 100644 --- a/be/apps/core/src/modules/setting/setting.constant.ts +++ b/be/apps/core/src/modules/setting/setting.constant.ts @@ -2,6 +2,68 @@ import { z } from 'zod' import type { SettingDefinition, SettingMetadata } from './setting.type' +const HEX_COLOR_REGEX = /^#(?:[0-9a-f]{3}){1,2}$/i + +function createOptionalUrlSchema(errorMessage: string) { + return z + .string() + .trim() + .superRefine((value, ctx) => { + if (value.length === 0) { + return + } + + try { + new URL(value) + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: errorMessage, + }) + } + }) +} + +function createJsonStringArraySchema(options: { + allowEmpty?: boolean + validator?: (value: unknown) => boolean + errorMessage: string +}) { + return z.string().transform((value, ctx) => { + const normalized = value.trim() + + if (normalized.length === 0) { + if (options.allowEmpty) { + return '[]' + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: options.errorMessage, + }) + return z.NEVER + } + + try { + const parsed = JSON.parse(normalized) + if (!Array.isArray(parsed) || (options.validator && !parsed.every(options.validator))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: options.errorMessage, + }) + return z.NEVER + } + return JSON.stringify(parsed) + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: options.errorMessage, + }) + return z.NEVER + } + }) +} + export const DEFAULT_SETTING_DEFINITIONS = { // 'ai.openai.apiKey': { // isSensitive: true, @@ -17,35 +79,130 @@ export const DEFAULT_SETTING_DEFINITIONS = { // }, 'builder.storage.providers': { isSensitive: false, - schema: z.string().transform((value, ctx) => { - const normalized = value.trim() - if (normalized.length === 0) { - return '[]' - } - - try { - const parsed = JSON.parse(normalized) - if (!Array.isArray(parsed)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Builder storage providers must be a JSON array', - }) - return z.NEVER - } - return JSON.stringify(parsed) - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Builder storage providers must be valid JSON', - }) - return z.NEVER - } + schema: createJsonStringArraySchema({ + allowEmpty: true, + errorMessage: 'Builder storage providers must be a JSON array', }), }, 'builder.storage.activeProvider': { isSensitive: false, schema: z.string().transform((value) => value.trim()), }, + 'site.name': { + isSensitive: false, + schema: z.string().trim().min(1, 'Site name cannot be empty'), + }, + 'site.title': { + isSensitive: false, + schema: z.string().trim().min(1, 'Site title cannot be empty'), + }, + 'site.description': { + isSensitive: false, + schema: z.string().trim().min(1, 'Site description cannot be empty'), + }, + 'site.url': { + isSensitive: false, + schema: z.string().trim().url('Site URL must be a valid URL'), + }, + 'site.accentColor': { + isSensitive: false, + schema: z + .string() + .trim() + .superRefine((value, ctx) => { + if (value.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Accent color cannot be empty', + }) + return + } + + if (!HEX_COLOR_REGEX.test(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Accent color must be a valid hex color', + }) + } + }), + }, + 'site.author.name': { + isSensitive: false, + schema: z.string().trim().min(1, 'Author name cannot be empty'), + }, + 'site.author.url': { + isSensitive: false, + schema: z.url('Author URL must be a valid URL'), + }, + 'site.author.avatar': { + isSensitive: false, + schema: createOptionalUrlSchema('Author avatar must be a valid URL'), + }, + 'site.social.twitter': { + isSensitive: false, + schema: z.string().trim(), + }, + 'site.social.github': { + isSensitive: false, + schema: z.string().trim(), + }, + 'site.social.rss': { + isSensitive: false, + schema: z + .string() + .trim() + .transform((value) => value.toLowerCase()) + .superRefine((value, ctx) => { + if (value.length === 0) { + return + } + + if (value !== 'true' && value !== 'false') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'RSS toggle must be either "true" or "false"', + }) + } + }), + }, + 'site.feed.folo.challenge.feedId': { + isSensitive: false, + schema: z.string().trim(), + }, + 'site.feed.folo.challenge.userId': { + isSensitive: false, + schema: z.string().trim(), + }, + 'site.map.providers': { + isSensitive: false, + schema: createJsonStringArraySchema({ + allowEmpty: true, + errorMessage: 'Map providers must be a JSON array of provider identifiers', + validator: (value): value is string => typeof value === 'string', + }), + }, + 'site.mapStyle': { + isSensitive: false, + schema: z.string().trim(), + }, + 'site.mapProjection': { + isSensitive: false, + schema: z + .string() + .trim() + .superRefine((value, ctx) => { + if (value.length === 0) { + return + } + + if (value !== 'globe' && value !== 'mercator') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Map projection must be either globe or mercator', + }) + } + }), + }, } as const satisfies Record export const DEFAULT_SETTING_METADATA = Object.fromEntries( diff --git a/be/apps/core/src/modules/site-setting/site-setting.controller.ts b/be/apps/core/src/modules/site-setting/site-setting.controller.ts new file mode 100644 index 00000000..b6492abc --- /dev/null +++ b/be/apps/core/src/modules/site-setting/site-setting.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Post } from '@afilmory/framework' +import { Roles } from 'core/guards/roles.decorator' +import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' + +import { UpdateSiteSettingsDto } from './site-setting.dto' +import { SiteSettingService } from './site-setting.service' + +@Controller('site/settings') +@Roles('admin') +export class SiteSettingController { + constructor(private readonly siteSettingService: SiteSettingService) {} + + @Get('/ui-schema') + @BypassResponseTransform() + async getUiSchema() { + return await this.siteSettingService.getUiSchema() + } + + @Post('/') + async update(@Body() { entries }: UpdateSiteSettingsDto) { + await this.siteSettingService.setMany(entries) + return { updated: entries } + } +} diff --git a/be/apps/core/src/modules/site-setting/site-setting.dto.ts b/be/apps/core/src/modules/site-setting/site-setting.dto.ts new file mode 100644 index 00000000..b1618dc0 --- /dev/null +++ b/be/apps/core/src/modules/site-setting/site-setting.dto.ts @@ -0,0 +1,23 @@ +import { createZodDto } from '@afilmory/framework' +import { z } from 'zod' + +import { SETTING_SCHEMAS } from '../setting/setting.constant' +import { SITE_SETTING_KEYS } from './site-setting.type' + +const keySchema = z.enum(SITE_SETTING_KEYS) + +const entrySchema = z + .object({ + key: keySchema, + value: z.unknown(), + }) + .transform((entry) => ({ + key: entry.key, + value: SETTING_SCHEMAS[entry.key].parse(entry.value), + })) + +export class UpdateSiteSettingsDto extends createZodDto( + z.object({ + entries: z.array(entrySchema).min(1), + }), +) {} diff --git a/be/apps/core/src/modules/site-setting/site-setting.module.ts b/be/apps/core/src/modules/site-setting/site-setting.module.ts new file mode 100644 index 00000000..d9e9732b --- /dev/null +++ b/be/apps/core/src/modules/site-setting/site-setting.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@afilmory/framework' + +import { SettingModule } from '../setting/setting.module' +import { SiteSettingController } from './site-setting.controller' +import { SiteSettingService } from './site-setting.service' + +@Module({ + imports: [SettingModule], + controllers: [SiteSettingController], + providers: [SiteSettingService], + exports: [SiteSettingService], +}) +export class SiteSettingModule {} diff --git a/be/apps/core/src/modules/site-setting/site-setting.service.ts b/be/apps/core/src/modules/site-setting/site-setting.service.ts new file mode 100644 index 00000000..aab52872 --- /dev/null +++ b/be/apps/core/src/modules/site-setting/site-setting.service.ts @@ -0,0 +1,86 @@ +import { injectable } from 'tsyringe' + +import type { SettingEntryInput } from '../setting/setting.service' +import { SettingService } from '../setting/setting.service' +import type { UiNode } from '../ui-schema/ui-schema.type' +import type { SiteSettingEntryInput, SiteSettingKey, SiteSettingUiSchemaResponse } from './site-setting.type' +import { ONBOARDING_SITE_SETTING_KEYS, SITE_SETTING_KEYS } from './site-setting.type' +import { SITE_SETTING_UI_SCHEMA, SITE_SETTING_UI_SCHEMA_KEYS } from './site-setting.ui-schema' + +@injectable() +export class SiteSettingService { + constructor(private readonly settingService: SettingService) {} + + async getUiSchema(): Promise { + const values = await this.settingService.getMany(SITE_SETTING_UI_SCHEMA_KEYS, {}) + const typedValues: SiteSettingUiSchemaResponse['values'] = {} + + for (const key of SITE_SETTING_KEYS) { + typedValues[key] = values[key] ?? null + } + + return { + schema: SITE_SETTING_UI_SCHEMA, + values: typedValues, + } + } + + async getOnboardingUiSchema(): Promise { + const allowedKeys = new Set(ONBOARDING_SITE_SETTING_KEYS) + const schema = this.filterSchema(SITE_SETTING_UI_SCHEMA, allowedKeys) + + return { + schema, + values: {}, + } + } + + async setMany(entries: readonly SiteSettingEntryInput[]): Promise { + if (entries.length === 0) { + return + } + + const normalizedEntries = entries.map((entry) => ({ + ...entry, + value: typeof entry.value === 'string' ? entry.value : String(entry.value), + })) as readonly SettingEntryInput[] + + await this.settingService.setMany(normalizedEntries) + } + + async get(key: SiteSettingKey) { + return await this.settingService.get(key, {}) + } + + private filterSchema( + schema: SiteSettingUiSchemaResponse['schema'], + allowed: Set, + ): SiteSettingUiSchemaResponse['schema'] { + const filterNodes = (nodes: ReadonlyArray>): Array> => { + const filtered: Array> = [] + + for (const node of nodes) { + if (node.type === 'field') { + if (allowed.has(node.key)) { + filtered.push(node) + } + continue + } + + const filteredChildren = filterNodes(node.children) + if (filteredChildren.length === 0) { + continue + } + + filtered.push({ ...node, children: filteredChildren }) + } + + return filtered + } + + return { + ...schema, + sections: filterNodes(schema.sections) as SiteSettingUiSchemaResponse['schema']['sections'], + } + } +} diff --git a/be/apps/core/src/modules/site-setting/site-setting.type.ts b/be/apps/core/src/modules/site-setting/site-setting.type.ts new file mode 100644 index 00000000..9e505103 --- /dev/null +++ b/be/apps/core/src/modules/site-setting/site-setting.type.ts @@ -0,0 +1,37 @@ +import type { SettingEntryInput } from '../setting/setting.service' +import type { SettingKeyType } from '../setting/setting.type' +import type { UiSchema } from '../ui-schema/ui-schema.type' + +export const SITE_SETTING_KEYS = [ + 'site.name', + 'site.title', + 'site.description', + 'site.url', + 'site.accentColor', + 'site.author.name', + 'site.author.url', + 'site.author.avatar', + 'site.social.twitter', + 'site.social.github', + 'site.social.rss', + 'site.feed.folo.challenge.feedId', + 'site.feed.folo.challenge.userId', + 'site.map.providers', + 'site.mapStyle', + 'site.mapProjection', +] as const satisfies readonly SettingKeyType[] + +export type SiteSettingKey = (typeof SITE_SETTING_KEYS)[number] + +export const ONBOARDING_SITE_SETTING_KEYS = [ + 'site.name', + 'site.title', + 'site.description', +] as const satisfies readonly SiteSettingKey[] + +export type SiteSettingEntryInput = Extract + +export interface SiteSettingUiSchemaResponse { + readonly schema: UiSchema + readonly values: Partial> +} diff --git a/be/apps/core/src/modules/site-setting/site-setting.ui-schema.ts b/be/apps/core/src/modules/site-setting/site-setting.ui-schema.ts new file mode 100644 index 00000000..9be41783 --- /dev/null +++ b/be/apps/core/src/modules/site-setting/site-setting.ui-schema.ts @@ -0,0 +1,304 @@ +import type { UiNode, UiSchema } from '../ui-schema/ui-schema.type' +import type { SiteSettingKey } from './site-setting.type' + +export const SITE_SETTING_UI_SCHEMA_VERSION = '1.0.0' + +export const SITE_SETTING_UI_SCHEMA: UiSchema = { + version: SITE_SETTING_UI_SCHEMA_VERSION, + title: '站点设置', + description: '配置前台站点的基础信息、品牌样式与地图展示能力。', + sections: [ + { + type: 'section', + id: 'site-basic', + title: '基础信息', + description: '这些信息将直接展示在站点的导航栏、标题和 SEO 中。', + icon: 'layout-dashboard', + children: [ + { + type: 'field', + id: 'site-name', + title: '站点名称', + description: '显示在站点导航栏和页面标题中。', + key: 'site.name', + required: true, + component: { + type: 'text', + placeholder: '请输入站点名称', + }, + icon: 'type', + }, + { + type: 'field', + id: 'site-title', + title: '首页标题', + description: '用于浏览器标签页及 SEO 标题。', + key: 'site.title', + required: true, + component: { + type: 'text', + placeholder: '请输入首页标题', + }, + icon: 'heading-1', + }, + { + type: 'field', + id: 'site-description', + title: '站点描述', + description: '展示在站点简介及搜索引擎摘要中。', + key: 'site.description', + required: true, + component: { + type: 'textarea', + placeholder: '请输入站点描述…', + minRows: 3, + maxRows: 6, + }, + icon: 'align-left', + }, + { + type: 'field', + id: 'site-url', + title: '站点 URL', + description: '站点对外访问的主域名,必须为绝对地址。', + key: 'site.url', + required: true, + component: { + type: 'text', + inputType: 'url', + placeholder: 'https://afilmory.innei.in', + autoComplete: 'url', + }, + icon: 'globe', + }, + { + type: 'field', + id: 'site-accent-color', + title: '品牌主题色', + description: '用于按钮、强调文本等高亮元素,支持 HEX 格式。', + helperText: '示例:#007bff', + key: 'site.accentColor', + required: true, + component: { + type: 'text', + placeholder: '#007bff', + }, + icon: 'palette', + }, + ], + }, + { + type: 'section', + id: 'site-author-social', + title: '作者与社交', + description: '展示在站点关于信息与页脚的联系人和社交账号。', + icon: 'user-round', + children: [ + { + type: 'group', + id: 'site-author-group', + title: '作者信息', + icon: 'id-card', + children: [ + { + type: 'field', + id: 'site-author-name', + title: '作者名称', + key: 'site.author.name', + required: true, + component: { + type: 'text', + placeholder: '请输入作者名称', + autoComplete: 'name', + }, + icon: 'user-circle', + }, + { + type: 'field', + id: 'site-author-url', + title: '作者主页链接', + key: 'site.author.url', + required: true, + component: { + type: 'text', + inputType: 'url', + placeholder: 'https://innei.in/', + autoComplete: 'url', + }, + icon: 'link', + }, + { + type: 'field', + id: 'site-author-avatar', + title: '头像地址', + description: '可选,展示在站点顶部与分享卡片中。', + helperText: '留空则不显示头像。', + key: 'site.author.avatar', + component: { + type: 'text', + inputType: 'url', + placeholder: 'https://cdn.example.com/avatar.png', + }, + icon: 'image', + }, + ], + }, + { + type: 'group', + id: 'site-social-group', + title: '社交渠道', + description: '填写完整的链接或用户名,展示在站点社交区块。', + icon: 'share-2', + children: [ + { + type: 'field', + id: 'site-social-twitter', + title: 'Twitter', + helperText: '支持完整链接或 @用户名。', + key: 'site.social.twitter', + component: { + type: 'text', + placeholder: 'https://twitter.com/username', + }, + icon: 'twitter', + }, + { + type: 'field', + id: 'site-social-github', + title: 'GitHub', + helperText: '支持完整链接或用户名。', + key: 'site.social.github', + component: { + type: 'text', + placeholder: 'https://github.com/username', + }, + icon: 'github', + }, + { + type: 'field', + id: 'site-social-rss', + title: '生成 RSS 订阅源', + description: '启用后将在前台站点暴露 RSS 订阅入口。', + helperText: '开启后,访客可通过 RSS 订阅最新照片更新。', + key: 'site.social.rss', + component: { + type: 'switch', + }, + icon: 'rss', + }, + ], + }, + ], + }, + { + type: 'section', + id: 'site-feed', + title: 'Feed 设置', + description: '配置第三方 Feed 数据源,用于聚合内容或挑战进度。', + icon: 'radio', + children: [ + { + type: 'group', + id: 'site-feed-folo', + title: 'Folo Challenge', + description: '同步 Folo Challenge 数据所需的 Feed ID 与用户 ID。', + icon: 'goal', + children: [ + { + type: 'field', + id: 'site-feed-folo-feed-id', + title: 'Feed ID', + key: 'site.feed.folo.challenge.feedId', + component: { + type: 'text', + placeholder: '请输入 Feed ID', + }, + icon: 'hash', + }, + { + type: 'field', + id: 'site-feed-folo-user-id', + title: 'User ID', + key: 'site.feed.folo.challenge.userId', + component: { + type: 'text', + placeholder: '请输入 User ID', + }, + icon: 'user', + }, + ], + }, + ], + }, + { + type: 'section', + id: 'site-map', + title: '地图展示', + description: '配置地图组件的可用提供商、样式与投影。', + icon: 'map', + children: [ + { + type: 'field', + id: 'site-map-providers', + title: '地图提供商列表', + description: '使用 JSON 数组表示优先级列表,例如 ["maplibre" ]。', + helperText: '留空则禁用地图功能。', + key: 'site.map.providers', + component: { + type: 'textarea', + placeholder: '["maplibre"]', + minRows: 3, + maxRows: 6, + }, + icon: 'list', + }, + { + type: 'field', + id: 'site-map-style', + title: '地图样式', + description: '填写 MapLibre Style URL,或使用 builtin 选择内置样式。', + helperText: '示例:builtin 或 https://tiles.example.com/style.json', + key: 'site.mapStyle', + component: { + type: 'text', + placeholder: 'builtin', + }, + icon: 'paintbrush', + }, + { + type: 'field', + id: 'site-map-projection', + title: '地图投影', + description: '选择地图渲染的投影方式。', + helperText: '默认为 mercator,可根据需求切换为 globe。', + key: 'site.mapProjection', + component: { + type: 'select', + placeholder: '选择投影方式', + options: ['mercator', 'globe'], + }, + icon: 'compass', + }, + ], + }, + ], +} + +function collectKeys(nodes: ReadonlyArray>): SiteSettingKey[] { + const keys: SiteSettingKey[] = [] + + for (const node of nodes) { + if (node.type === 'field') { + keys.push(node.key) + continue + } + + keys.push(...collectKeys(node.children)) + } + + return keys +} + +export const SITE_SETTING_UI_SCHEMA_KEYS = Array.from( + new Set(collectKeys(SITE_SETTING_UI_SCHEMA.sections)), +) as SiteSettingKey[] diff --git a/be/apps/dashboard/index.html b/be/apps/dashboard/index.html index 4908b6b0..46f8cf32 100644 --- a/be/apps/dashboard/index.html +++ b/be/apps/dashboard/index.html @@ -2,9 +2,11 @@ - + + + - Vite App + Afilmory Dashboard
diff --git a/be/apps/dashboard/package.json b/be/apps/dashboard/package.json index f0f69924..78f2ad3d 100644 --- a/be/apps/dashboard/package.json +++ b/be/apps/dashboard/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-tooltip": "1.2.8", "@react-hook/window-size": "3.1.1", "@remixicon/react": "4.7.0", + "@tanstack/react-form": "1.23.8", "@tanstack/react-query": "5.90.5", "better-auth": "1.3.34", "class-variance-authority": "0.7.1", @@ -99,4 +100,4 @@ "eslint --fix" ] } -} +} \ No newline at end of file diff --git a/be/apps/dashboard/public/android-chrome-192x192.png b/be/apps/dashboard/public/android-chrome-192x192.png new file mode 100644 index 00000000..995b3ebf Binary files /dev/null and b/be/apps/dashboard/public/android-chrome-192x192.png differ diff --git a/be/apps/dashboard/public/android-chrome-512x512.png b/be/apps/dashboard/public/android-chrome-512x512.png new file mode 100644 index 00000000..12c2bf3e Binary files /dev/null and b/be/apps/dashboard/public/android-chrome-512x512.png differ diff --git a/be/apps/dashboard/public/apple-touch-icon.png b/be/apps/dashboard/public/apple-touch-icon.png new file mode 100644 index 00000000..92fb15f5 Binary files /dev/null and b/be/apps/dashboard/public/apple-touch-icon.png differ diff --git a/be/apps/dashboard/public/favicon-16x16.png b/be/apps/dashboard/public/favicon-16x16.png new file mode 100644 index 00000000..acbed477 Binary files /dev/null and b/be/apps/dashboard/public/favicon-16x16.png differ diff --git a/be/apps/dashboard/public/favicon-32x32.png b/be/apps/dashboard/public/favicon-32x32.png new file mode 100644 index 00000000..62ceea63 Binary files /dev/null and b/be/apps/dashboard/public/favicon-32x32.png differ diff --git a/be/apps/dashboard/public/favicon.ico b/be/apps/dashboard/public/favicon.ico new file mode 100644 index 00000000..bdc3bd7f Binary files /dev/null and b/be/apps/dashboard/public/favicon.ico differ diff --git a/be/apps/dashboard/public/site.webmanifest b/be/apps/dashboard/public/site.webmanifest new file mode 100644 index 00000000..45dc8a20 --- /dev/null +++ b/be/apps/dashboard/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/be/apps/dashboard/src/modules/auth/api/registerTenant.ts b/be/apps/dashboard/src/modules/auth/api/registerTenant.ts index 2c17116b..c3154f5f 100644 --- a/be/apps/dashboard/src/modules/auth/api/registerTenant.ts +++ b/be/apps/dashboard/src/modules/auth/api/registerTenant.ts @@ -14,6 +14,10 @@ export interface RegisterTenantPayload { name: string slug: string | null } + settings?: Array<{ + key: string + value: unknown + }> } export type RegisterTenantResult = FetchResponse diff --git a/be/apps/dashboard/src/modules/auth/components/RegistrationWizard.tsx b/be/apps/dashboard/src/modules/auth/components/RegistrationWizard.tsx index 930554d1..ac869152 100644 --- a/be/apps/dashboard/src/modules/auth/components/RegistrationWizard.tsx +++ b/be/apps/dashboard/src/modules/auth/components/RegistrationWizard.tsx @@ -1,635 +1 @@ -import { Button, Checkbox, FormError, Input, Label, ScrollArea } from '@afilmory/ui' -import { cx, Spring } from '@afilmory/utils' -import { m } from 'motion/react' -import type { FC, KeyboardEvent } from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link } from 'react-router' - -import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons' -import { useRegisterTenant } from '~/modules/auth/hooks/useRegisterTenant' -import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegistrationForm' -import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm' -import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer' - -const REGISTRATION_STEPS = [ - { - id: 'workspace', - title: 'Workspace details', - description: 'Give your workspace a recognizable name and choose a slug for tenant URLs.', - }, - { - id: 'admin', - title: 'Administrator account', - description: 'Set up the primary administrator who will manage the workspace after creation.', - }, - { - id: 'review', - title: 'Review & confirm', - description: 'Verify everything looks right and accept the terms before provisioning the workspace.', - }, -] as const satisfies ReadonlyArray<{ - id: 'workspace' | 'admin' | 'review' - title: string - description: string -}> - -const STEP_FIELDS: Record<(typeof REGISTRATION_STEPS)[number]['id'], Array> = { - workspace: ['tenantName', 'tenantSlug'], - admin: ['accountName', 'email', 'password', 'confirmPassword'], - review: ['termsAccepted'], -} - -const progressForStep = (index: number) => Math.round((index / (REGISTRATION_STEPS.length - 1 || 1)) * 100) - -type SidebarProps = { - currentStepIndex: number - canNavigateTo: (index: number) => boolean - onStepSelect: (index: number) => void -} - -const RegistrationSidebar: FC = ({ currentStepIndex, canNavigateTo, onStepSelect }) => ( - -) - -type HeaderProps = { - currentStepIndex: number -} - -const RegistrationHeader: FC = ({ currentStepIndex }) => { - const step = REGISTRATION_STEPS[currentStepIndex] - return ( -
-
- Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length} -
-

{step.title}

-

{step.description}

-
- ) -} - -type FooterProps = { - disableBack: boolean - isSubmitting: boolean - isLastStep: boolean - onBack: () => void - onNext: () => void -} - -const RegistrationFooter: FC = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => ( -
- {!disableBack ? ( -
- Adjustments are always possible—use the sidebar or go back to modify earlier details. -
- ) : ( -
- )} -
- {!disableBack && ( - - )} - -
-
-) - -type StepCommonProps = { - values: TenantRegistrationFormState - errors: Partial> - onFieldChange: ( - field: Field, - value: TenantRegistrationFormState[Field], - ) => void - isLoading: boolean -} - -const WorkspaceStep: FC = ({ values, errors, onFieldChange, isLoading }) => ( -
-
-

Workspace basics

-

- This information appears in navigation, invitations, and other tenant-facing areas. -

-
-
-
- - onFieldChange('tenantName', event.currentTarget.value)} - placeholder="Acme Studio" - disabled={isLoading} - error={Boolean(errors.tenantName)} - autoComplete="organization" - /> - {errors.tenantName} -
-
- - onFieldChange('tenantSlug', event.currentTarget.value)} - placeholder="acme" - disabled={isLoading} - error={Boolean(errors.tenantSlug)} - autoComplete="off" - /> -

- Lowercase letters, numbers, and hyphen are allowed. We'll ensure the slug is unique. -

- {errors.tenantSlug} -
-
-
-) - -const AdminStep: FC = ({ values, errors, onFieldChange, isLoading }) => ( -
-
-

Administrator

-

- The first user becomes the workspace administrator and can invite additional members later. -

-
-
-
- - onFieldChange('accountName', event.currentTarget.value)} - placeholder="Jane Doe" - disabled={isLoading} - error={Boolean(errors.accountName)} - autoComplete="name" - /> - {errors.accountName} -
-
- - onFieldChange('email', event.currentTarget.value)} - placeholder="jane@acme.studio" - disabled={isLoading} - error={Boolean(errors.email)} - autoComplete="email" - /> - {errors.email} -
-
- - onFieldChange('password', event.currentTarget.value)} - placeholder="Create a strong password" - disabled={isLoading} - error={Boolean(errors.password)} - autoComplete="new-password" - /> - {errors.password} -
-
- - onFieldChange('confirmPassword', event.currentTarget.value)} - placeholder="Repeat your password" - disabled={isLoading} - error={Boolean(errors.confirmPassword)} - autoComplete="new-password" - /> - {errors.confirmPassword} -
-
-

- We recommend using a secure password manager to store credentials for critical roles like the administrator. -

- -
-) - -type ReviewStepProps = Omit & { - onToggleTerms: (value: boolean) => void - serverError: string | null -} - -const ReviewStep: FC = ({ values, errors, onToggleTerms, isLoading, serverError }) => ( -
-
-

Confirm workspace configuration

-

- Double-check the details below. You can go back to make adjustments before creating the workspace. -

-
-
-
-
Workspace name
-
{values.tenantName || '—'}
-
-
-
Workspace slug
-
{values.tenantSlug || '—'}
-
-
-
Administrator name
-
{values.accountName || '—'}
-
-
-
Administrator email
-
{values.email || '—'}
-
-
- - {serverError && ( - -

{serverError}

-
- )} - -
-

Policies

-

- Creating a workspace means you agree to comply with our usage guidelines and privacy practices. -

-
- - {errors.termsAccepted} -
-
-
-) - -export const RegistrationWizard: FC = () => { - const { values, errors, updateValue, validate, getFieldError } = useRegistrationForm() - const { registerTenant, isLoading, error, clearError } = useRegisterTenant() - const [currentStepIndex, setCurrentStepIndex] = useState(0) - const [maxVisitedIndex, setMaxVisitedIndex] = useState(0) - const contentRef = useRef(null) - - useEffect(() => { - const root = contentRef.current - if (!root) return - - const rafId = requestAnimationFrame(() => { - const selector = [ - 'input:not([type="hidden"]):not([disabled])', - 'textarea:not([disabled])', - 'select:not([disabled])', - '[contenteditable="true"]', - '[tabindex]:not([tabindex="-1"])', - ].join(',') - - const candidates = Array.from(root.querySelectorAll(selector)) - const firstVisible = candidates.find((el) => { - if (el.getAttribute('aria-hidden') === 'true') return false - const rect = el.getBoundingClientRect() - if (rect.width === 0 || rect.height === 0) return false - if ((el as HTMLInputElement).disabled) return false - return true - }) - - firstVisible?.focus({ preventScroll: true }) - }) - - return () => cancelAnimationFrame(rafId) - }, [currentStepIndex]) - - const canNavigateTo = useCallback((index: number) => index <= maxVisitedIndex, [maxVisitedIndex]) - - const jumpToStep = useCallback( - (index: number) => { - if (isLoading) return - if (index === currentStepIndex) return - if (!canNavigateTo(index)) return - if (error) clearError() - setCurrentStepIndex(index) - setMaxVisitedIndex((prev) => Math.max(prev, index)) - }, - [canNavigateTo, clearError, currentStepIndex, error, isLoading], - ) - - const handleFieldChange = useCallback( - (field: Field, value: TenantRegistrationFormState[Field]) => { - updateValue(field, value) - if (error) clearError() - }, - [clearError, error, updateValue], - ) - - const handleBack = useCallback(() => { - if (isLoading) return - if (currentStepIndex === 0) return - if (error) clearError() - setCurrentStepIndex((prev) => Math.max(0, prev - 1)) - }, [clearError, currentStepIndex, error, isLoading]) - - const focusFirstInvalidStep = useCallback(() => { - const invalidStepIndex = REGISTRATION_STEPS.findIndex((step) => - STEP_FIELDS[step.id].some((field) => Boolean(getFieldError(field))), - ) - - if (invalidStepIndex !== -1 && invalidStepIndex !== currentStepIndex) { - setCurrentStepIndex(invalidStepIndex) - setMaxVisitedIndex((prev) => Math.max(prev, invalidStepIndex)) - } - }, [currentStepIndex, getFieldError]) - - const handleNext = useCallback(() => { - if (isLoading) return - - const step = REGISTRATION_STEPS[currentStepIndex] - const fields = STEP_FIELDS[step.id] - - const isStepValid = validate(fields) - if (!isStepValid) { - focusFirstInvalidStep() - return - } - - if (step.id === 'review') { - const formIsValid = validate() - if (!formIsValid) { - focusFirstInvalidStep() - return - } - - if (error) clearError() - - registerTenant({ - tenantName: values.tenantName, - tenantSlug: values.tenantSlug, - accountName: values.accountName, - email: values.email, - password: values.password, - }) - return - } - - setCurrentStepIndex((prev) => { - const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1) - setMaxVisitedIndex((visited) => Math.max(visited, nextIndex)) - return nextIndex - }) - }, [ - clearError, - currentStepIndex, - error, - focusFirstInvalidStep, - isLoading, - registerTenant, - validate, - values.accountName, - values.email, - values.password, - values.tenantName, - values.tenantSlug, - ]) - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key !== 'Enter') return - if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return - const nativeEvent = event.nativeEvent as unknown as { isComposing?: boolean } - if (nativeEvent?.isComposing) return - - const target = event.target as HTMLElement - if (target.isContentEditable) return - if (target.tagName === 'TEXTAREA') return - if (target.tagName === 'BUTTON' || target.tagName === 'A') return - if (target.tagName === 'INPUT') { - const { type } = target as HTMLInputElement - if (type === 'checkbox' || type === 'radio') return - } - - event.preventDefault() - handleNext() - }, - [handleNext], - ) - - const StepComponent = useMemo(() => { - const step = REGISTRATION_STEPS[currentStepIndex] - switch (step.id) { - case 'workspace': { - return - } - case 'admin': { - return - } - case 'review': { - return ( - handleFieldChange('termsAccepted', accepted)} - isLoading={isLoading} - serverError={error} - /> - ) - } - default: { - return null - } - } - }, [currentStepIndex, error, errors, handleFieldChange, isLoading, values]) - - const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1 - - return ( -
- -
-
-
- -
- -
-
- -
-
- -
- -
- {StepComponent} -
-
-
- -
-
- -
-
-
- - -

- Already have an account?{' '} - - Sign in - - . -

-
- ) -} +export { RegistrationWizard } from './registration-wizard/RegistrationWizard' diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationFooter.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationFooter.tsx new file mode 100644 index 00000000..b4d5f188 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationFooter.tsx @@ -0,0 +1,33 @@ +import { Button } from '@afilmory/ui' +import type { FC } from 'react' + +type FooterProps = { + disableBack: boolean + isSubmitting: boolean + isLastStep: boolean + onBack: () => void + onNext: () => void +} + +export const RegistrationFooter: FC = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => ( +
+
+
+ {!disableBack && ( + + )} + +
+
+) diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationHeader.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationHeader.tsx new file mode 100644 index 00000000..fae72d36 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationHeader.tsx @@ -0,0 +1,20 @@ +import type { FC } from 'react' + +import { REGISTRATION_STEPS } from './constants' + +type HeaderProps = { + currentStepIndex: number +} + +export const RegistrationHeader: FC = ({ currentStepIndex }) => { + const step = REGISTRATION_STEPS[currentStepIndex] + return ( +
+
+ Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length} +
+

{step.title}

+

{step.description}

+
+ ) +} diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationSidebar.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationSidebar.tsx new file mode 100644 index 00000000..3e3f841d --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationSidebar.tsx @@ -0,0 +1,112 @@ +import { cx } from '@afilmory/utils' +import type { FC } from 'react' + +import { progressForStep,REGISTRATION_STEPS } from './constants' + +type SidebarProps = { + currentStepIndex: number + canNavigateTo: (index: number) => boolean + onStepSelect: (index: number) => void +} + +export const RegistrationSidebar: FC = ({ currentStepIndex, canNavigateTo, onStepSelect }) => ( + +) diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationWizard.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationWizard.tsx new file mode 100644 index 00000000..8a5670b9 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationWizard.tsx @@ -0,0 +1,442 @@ +import { ScrollArea } from '@afilmory/ui' +import { useStore } from '@tanstack/react-form' +import { useQuery } from '@tanstack/react-query' +import type { FC, KeyboardEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Link } from 'react-router' +import { toast } from 'sonner' + +import { useRegisterTenant } from '~/modules/auth/hooks/useRegisterTenant' +import type { TenantRegistrationFormState, TenantSiteFieldKey } from '~/modules/auth/hooks/useRegistrationForm' +import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm' +import { getOnboardingSiteSchema } from '~/modules/onboarding/api' +import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer' +import { SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/onboarding/siteSchema' +import { + coerceSiteFieldValue, + collectSchemaFieldMap, + createInitialSiteStateFromFieldMap, + serializeSiteFieldValue, +} from '~/modules/onboarding/utils' +import type { SchemaFormValue, UiSchema } from '~/modules/schema-form/types' + +import { REGISTRATION_STEPS, STEP_FIELDS } from './constants' +import { RegistrationFooter } from './RegistrationFooter' +import { RegistrationHeader } from './RegistrationHeader' +import { RegistrationSidebar } from './RegistrationSidebar' +import { AdminStep } from './steps/AdminStep' +import { ReviewStep } from './steps/ReviewStep' +import { SiteSettingsStep } from './steps/SiteSettingsStep' +import { WorkspaceStep } from './steps/WorkspaceStep' +import { firstErrorMessage } from './utils' + +export const RegistrationWizard: FC = () => { + const form = useRegistrationForm() + const formValues = useStore(form.store, (state) => state.values) + const fieldMeta = useStore(form.store, (state) => state.fieldMeta) + const { registerTenant, isLoading, error, clearError } = useRegisterTenant() + const [currentStepIndex, setCurrentStepIndex] = useState(0) + const [maxVisitedIndex, setMaxVisitedIndex] = useState(0) + const contentRef = useRef(null) + const slugManuallyEditedRef = useRef(false) + const siteDefaultsAppliedRef = useRef(false) + + const siteSchemaQuery = useQuery({ + queryKey: ['onboarding', 'site-schema'], + queryFn: getOnboardingSiteSchema, + staleTime: Infinity, + }) + + const [siteSchema, setSiteSchema] = useState | null>(null) + + useEffect(() => { + const data = siteSchemaQuery.data as + | { + schema?: UiSchema + values?: Partial> + } + | undefined + + if (!data) { + return + } + + if (data.schema && !siteSchema) { + setSiteSchema(data.schema) + } + + if (!data.schema || siteDefaultsAppliedRef.current) { + return + } + + const fieldMap = collectSchemaFieldMap(data.schema) + const defaults = createInitialSiteStateFromFieldMap(fieldMap) + const presetValues = data.values ?? {} + + let applied = false + + for (const [key, field] of fieldMap) { + const defaultValue = defaults[key] + if (defaultValue !== undefined) { + applied = true + form.setFieldValue(key, () => defaultValue) + } + + const coerced = coerceSiteFieldValue(field, presetValues[key]) + if (coerced !== undefined) { + applied = true + form.setFieldValue(key, () => coerced) + } + } + + if (applied) { + siteDefaultsAppliedRef.current = true + } + }, [form, siteSchema, siteSchemaQuery.data]) + + const siteSchemaLoading = siteSchemaQuery.isLoading && !siteSchema + const siteSchemaErrorMessage = siteSchemaQuery.isError + ? siteSchemaQuery.error instanceof Error + ? siteSchemaQuery.error.message + : 'Unable to load site configuration schema from the server.' + : undefined + + const siteFieldMap = useMemo(() => { + const data = siteSchemaQuery.data as + | { + schema?: UiSchema + } + | undefined + const schema = siteSchema ?? data?.schema ?? null + return schema ? collectSchemaFieldMap(schema) : null + }, [siteSchema, siteSchemaQuery.data]) + + const siteFieldKeys = useMemo( + () => + siteFieldMap + ? (Array.from(siteFieldMap.keys()) as TenantSiteFieldKey[]) + : (SITE_SETTINGS_KEYS as TenantSiteFieldKey[]), + [siteFieldMap], + ) + + const getStepFields = useCallback( + (stepId: (typeof REGISTRATION_STEPS)[number]['id']) => { + if (stepId === 'site') { + return siteFieldKeys as Array + } + return STEP_FIELDS[stepId] + }, + [siteFieldKeys], + ) + + useEffect(() => { + const root = contentRef.current + if (!root) return + + const rafId = requestAnimationFrame(() => { + const selector = [ + 'input:not([type="hidden"]):not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', + ].join(',') + + const candidates = Array.from(root.querySelectorAll(selector)) + const firstVisible = candidates.find((el) => { + if (el.getAttribute('aria-hidden') === 'true') return false + const rect = el.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return false + if ((el as HTMLInputElement).disabled) return false + return true + }) + + firstVisible?.focus({ preventScroll: true }) + }) + + return () => cancelAnimationFrame(rafId) + }, [currentStepIndex]) + + const canNavigateTo = useCallback((index: number) => index <= maxVisitedIndex, [maxVisitedIndex]) + + const onFieldInteraction = useCallback(() => { + if (error) { + clearError() + } + }, [clearError, error]) + + const jumpToStep = useCallback( + (index: number) => { + if (isLoading) return + if (index === currentStepIndex) return + if (!canNavigateTo(index)) return + onFieldInteraction() + setCurrentStepIndex(index) + setMaxVisitedIndex((prev) => Math.max(prev, index)) + }, + [canNavigateTo, currentStepIndex, isLoading, onFieldInteraction], + ) + + const handleBack = useCallback(() => { + if (isLoading) return + if (currentStepIndex === 0) return + onFieldInteraction() + setCurrentStepIndex((prev) => Math.max(0, prev - 1)) + }, [currentStepIndex, isLoading, onFieldInteraction]) + + const fieldHasError = useCallback( + (field: keyof TenantRegistrationFormState) => { + const meta = form.getFieldMeta(field) + return Boolean(meta?.errors?.length) + }, + [form], + ) + + const focusFirstInvalidStep = useCallback(() => { + const invalidStepIndex = REGISTRATION_STEPS.findIndex((step) => + getStepFields(step.id).some((field) => fieldHasError(field)), + ) + + if (invalidStepIndex !== -1 && invalidStepIndex !== currentStepIndex) { + setCurrentStepIndex(invalidStepIndex) + setMaxVisitedIndex((prev) => Math.max(prev, invalidStepIndex)) + } + }, [currentStepIndex, fieldHasError, getStepFields]) + + const ensureStepValid = useCallback( + async (stepId: (typeof REGISTRATION_STEPS)[number]['id']) => { + const fields = getStepFields(stepId) + + await Promise.all(fields.map((field) => form.validateField(field, 'submit'))) + + return fields.every((field) => !fieldHasError(field)) + }, + [fieldHasError, form, getStepFields], + ) + + const handleNext = useCallback(async () => { + if (isLoading) return + + const step = REGISTRATION_STEPS[currentStepIndex] + if (step.id === 'site') { + const result = siteSettingsSchema.safeParse(formValues) + if (!result.success) { + toast.error(`Error in ${result.error.issues.map((issue) => issue.message).join(', ')}`) + return + } + + setCurrentStepIndex(currentStepIndex + 1) + return + } + const stepIsValid = await ensureStepValid(step.id) + + if (!stepIsValid) { + focusFirstInvalidStep() + return + } + + if (step.id === 'review') { + await form.validateAllFields('submit') + const { state } = form + if (!state.isFormValid) { + focusFirstInvalidStep() + return + } + + onFieldInteraction() + + const trimmedTenantName = state.values.tenantName.trim() + const trimmedTenantSlug = state.values.tenantSlug.trim() + const siteSettings = ( + siteFieldMap && siteFieldMap.size > 0 + ? Array.from(siteFieldMap.entries()).map(([key, field]) => ({ + key, + value: serializeSiteFieldValue(field, state.values[key]), + })) + : siteFieldKeys.map((key) => { + const entry = state.values[key] + if (typeof entry === 'boolean') { + return { key, value: entry ? 'true' : 'false' } + } + const text = String(entry ?? '').trim() + return { key, value: text } + }) + ) as Array<{ key: TenantSiteFieldKey; value: string }> + + registerTenant({ + tenantName: trimmedTenantName, + tenantSlug: trimmedTenantSlug, + accountName: state.values.accountName.trim(), + email: state.values.email.trim(), + password: state.values.password, + settings: siteSettings, + }) + return + } + + setCurrentStepIndex((prev) => { + const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1) + setMaxVisitedIndex((visited) => Math.max(visited, nextIndex)) + return nextIndex + }) + }, [ + currentStepIndex, + ensureStepValid, + focusFirstInvalidStep, + form, + isLoading, + onFieldInteraction, + registerTenant, + siteFieldKeys, + siteFieldMap, + ]) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'Enter') return + if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return + const nativeEvent = event.nativeEvent as unknown as { isComposing?: boolean } + if (nativeEvent?.isComposing) return + + const target = event.target as HTMLElement + if (target.isContentEditable) return + if (target.tagName === 'TEXTAREA') return + if (target.tagName === 'BUTTON' || target.tagName === 'A') return + if (target.tagName === 'INPUT') { + const { type } = target as HTMLInputElement + if (type === 'checkbox' || type === 'radio') return + } + + event.preventDefault() + void handleNext() + }, + [handleNext], + ) + + const siteFieldErrors = useMemo(() => { + const result: Record = {} + for (const key of siteFieldKeys) { + const meta = fieldMeta?.[key] + const message = meta ? firstErrorMessage(meta.errors) : undefined + if (message) { + result[key] = message + } + } + return result + }, [fieldMeta, siteFieldKeys]) + + const StepComponent = useMemo(() => { + const step = REGISTRATION_STEPS[currentStepIndex] + switch (step.id) { + case 'workspace': { + return ( + + ) + } + case 'site': { + return ( + + ) + } + case 'admin': { + return + } + case 'review': { + return ( + + ) + } + default: { + return null + } + } + }, [ + currentStepIndex, + error, + form, + formValues, + isLoading, + onFieldInteraction, + siteFieldErrors, + siteSchema, + siteSchemaErrorMessage, + siteSchemaLoading, + ]) + + const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1 + + return ( +
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+ +
+ {StepComponent} +
+
+
+ +
+
+ { + void handleNext() + }} + /> +
+
+
+ + +

+ Already have an account?{' '} + + Sign in + + . +

+
+ ) +} diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/constants.ts b/be/apps/dashboard/src/modules/auth/components/registration-wizard/constants.ts new file mode 100644 index 00000000..8b6ec943 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/constants.ts @@ -0,0 +1,39 @@ +import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegistrationForm' + +export const REGISTRATION_STEPS = [ + { + id: 'workspace', + title: 'Workspace details', + description: 'Give your workspace a recognizable name and choose a slug for tenant URLs.', + }, + { + id: 'site', + title: 'Site information', + description: 'Configure the public gallery branding your visitors will see.', + }, + { + id: 'admin', + title: 'Administrator account', + description: 'Set up the primary administrator who will manage the workspace after creation.', + }, + { + id: 'review', + title: 'Review & confirm', + description: 'Verify everything looks right and accept the terms before provisioning the workspace.', + }, +] as const satisfies ReadonlyArray<{ + id: 'workspace' | 'site' | 'admin' | 'review' + title: string + description: string +}> + +export type RegistrationStepId = (typeof REGISTRATION_STEPS)[number]['id'] + +export const STEP_FIELDS: Record> = { + workspace: ['tenantName', 'tenantSlug'], + site: [], + admin: ['accountName', 'email', 'password', 'confirmPassword'], + review: ['termsAccepted'], +} + +export const progressForStep = (index: number) => Math.round((index / (REGISTRATION_STEPS.length - 1 || 1)) * 100) diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/AdminStep.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/AdminStep.tsx new file mode 100644 index 00000000..e4235be4 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/AdminStep.tsx @@ -0,0 +1,133 @@ +import { FormError, Input, Label } from '@afilmory/ui' +import type { FC } from 'react' + +import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons' +import type { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm' + +import { firstErrorMessage } from '../utils' + +type AdminStepProps = { + form: ReturnType + isSubmitting: boolean + onFieldInteraction: () => void +} + +export const AdminStep: FC = ({ form, isSubmitting, onFieldInteraction }) => ( +
+
+

Administrator

+

+ The first user becomes the workspace administrator and can invite additional members later. +

+
+
+ + {(field) => { + const error = firstErrorMessage(field.state.meta.errors) + return ( +
+ + { + onFieldInteraction() + field.handleChange(event.currentTarget.value) + }} + onBlur={field.handleBlur} + placeholder="Jane Doe" + disabled={isSubmitting} + error={Boolean(error)} + autoComplete="name" + /> + {error} +
+ ) + }} +
+ + {(field) => { + const error = firstErrorMessage(field.state.meta.errors) + return ( +
+ + { + onFieldInteraction() + field.handleChange(event.currentTarget.value) + }} + onBlur={field.handleBlur} + placeholder="jane@acme.studio" + disabled={isSubmitting} + error={Boolean(error)} + autoComplete="email" + /> + {error} +
+ ) + }} +
+ + {(field) => { + const error = firstErrorMessage(field.state.meta.errors) + return ( +
+ + { + onFieldInteraction() + field.handleChange(event.currentTarget.value) + }} + onBlur={field.handleBlur} + placeholder="Create a strong password" + disabled={isSubmitting} + error={Boolean(error)} + autoComplete="new-password" + /> + {error} +
+ ) + }} +
+ + {(field) => { + const error = firstErrorMessage(field.state.meta.errors) + return ( +
+ + { + onFieldInteraction() + field.handleChange(event.currentTarget.value) + }} + onBlur={field.handleBlur} + placeholder="Repeat your password" + disabled={isSubmitting} + error={Boolean(error)} + autoComplete="new-password" + /> + {error} +
+ ) + }} +
+
+

+ We recommend using a secure password manager to store credentials for critical roles like the administrator. +

+ +
+) diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/ReviewStep.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/ReviewStep.tsx new file mode 100644 index 00000000..1eba4e83 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/ReviewStep.tsx @@ -0,0 +1,173 @@ +import { Checkbox, FormError } from '@afilmory/ui' +import { cx, Spring } from '@afilmory/utils' +import { m } from 'motion/react' +import type { FC } from 'react' +import { useMemo } from 'react' + +import type { + TenantRegistrationFormState, + TenantSiteFieldKey, + useRegistrationForm, +} from '~/modules/auth/hooks/useRegistrationForm' +import type { SchemaFormValue, UiFieldNode, UiSchema } from '~/modules/schema-form/types' + +import { collectSiteFields, firstErrorMessage } from '../utils' + +type ReviewStepProps = { + form: ReturnType + values: TenantRegistrationFormState + siteSchema: UiSchema | null + siteSchemaLoading: boolean + siteSchemaError?: string + isSubmitting: boolean + serverError: string | null + onFieldInteraction: () => void +} + +export const ReviewStep: FC = ({ + form, + values, + siteSchema, + siteSchemaLoading, + siteSchemaError, + isSubmitting, + serverError, + onFieldInteraction, +}) => { + const formatSiteValue = (value: SchemaFormValue | undefined) => { + if (typeof value === 'boolean') { + return value ? 'Enabled' : 'Disabled' + } + if (value == null) { + return '—' + } + const text = String(value).trim() + return text || '—' + } + + const siteSummary = useMemo(() => { + if (!siteSchema) { + return [] as Array<{ field: UiFieldNode; value: SchemaFormValue | undefined }> + } + + return collectSiteFields(siteSchema.sections).map((field) => { + const key = field.key as TenantSiteFieldKey + return { + field, + value: values[key], + } + }) + }, [siteSchema, values]) + + return ( +
+
+

Confirm workspace configuration

+

+ Double-check the details below. You can go back to make adjustments before creating the workspace. +

+
+
+
+
Workspace name
+
{values.tenantName || '—'}
+
+
+
Workspace slug
+
{values.tenantSlug || '—'}
+
+
+
Administrator name
+
{values.accountName || '—'}
+
+
+
Administrator email
+
{values.email || '—'}
+
+
+ +
+

Site details

+ {siteSchemaLoading &&
} + {!siteSchemaLoading && siteSchemaError && ( +
{siteSchemaError}
+ )} + {!siteSchemaLoading && !siteSchemaError && siteSchema && ( +
+ {siteSummary.map(({ field, value }) => { + const spanClass = field.component?.type === 'textarea' ? 'md:col-span-2' : '' + const isMono = field.key === 'site.accentColor' + + return ( +
+
{field.title}
+
+ {formatSiteValue(value)} +
+
+ ) + })} +
+ )} +
+ + {serverError && ( + +

{serverError}

+
+ )} + +
+

Policies

+

+ Creating a workspace means you agree to comply with our usage guidelines and privacy practices. +

+
+ + {(field) => { + const error = firstErrorMessage(field.state.meta.errors) + return ( + <> + + {error} + + ) + }} + +
+
+
+ ) +} diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/SiteSettingsStep.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/SiteSettingsStep.tsx new file mode 100644 index 00000000..ddd93d7b --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/SiteSettingsStep.tsx @@ -0,0 +1,75 @@ +import type { FC } from 'react' + +import type { + TenantRegistrationFormState, + TenantSiteFieldKey, + useRegistrationForm, +} from '~/modules/auth/hooks/useRegistrationForm' +import { SiteStep } from '~/modules/onboarding/components/steps/SiteStep' +import type { SchemaFormState, UiSchema } from '~/modules/schema-form/types' + +type SiteSettingsStepProps = { + form: ReturnType + schema: UiSchema | null + isLoading: boolean + errorMessage?: string + values: TenantRegistrationFormState + errors: Record + onFieldInteraction: () => void +} + +export const SiteSettingsStep: FC = ({ + form, + schema, + isLoading, + errorMessage, + values, + errors, +}) => { + if (!schema) { + if (isLoading) { + return ( +
+
+

Site branding

+

+ These details appear on your public gallery, metadata, and social sharing cards. You can change them later + from the dashboard. +

+
+
+
+ ) + } + + return ( +
+
+

Site branding

+

+ We couldn't load the site configuration schema from the server. Refresh the page or contact support. +

+
+ {errorMessage && ( +
{errorMessage}
+ )} +
+ ) + } + + return ( +
+ {errorMessage && ( +
{errorMessage}
+ )} + } + errors={errors} + onFieldChange={(key, value) => { + form.state.values[key] = value + }} + /> +
+ ) +} diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/WorkspaceStep.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/WorkspaceStep.tsx new file mode 100644 index 00000000..07fb4fe4 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/steps/WorkspaceStep.tsx @@ -0,0 +1,93 @@ +import { FormError, Input, Label } from '@afilmory/ui' +import type { FC,MutableRefObject } from 'react' + +import type { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm' +import { slugify } from '~/modules/onboarding/utils' + +import { firstErrorMessage } from '../utils' + +type WorkspaceStepProps = { + form: ReturnType + slugManuallyEditedRef: MutableRefObject + isSubmitting: boolean + onFieldInteraction: () => void +} + +export const WorkspaceStep: FC = ({ + form, + slugManuallyEditedRef, + isSubmitting, + onFieldInteraction, +}) => ( +
+
+

Workspace basics

+

+ This information appears in navigation, invitations, and other tenant-facing areas. +

+
+
+ + {(field) => { + const error = firstErrorMessage(field.state.meta.errors) + + return ( +
+ + { + onFieldInteraction() + const nextValue = event.currentTarget.value + field.handleChange(nextValue) + if (!slugManuallyEditedRef.current) { + const nextSlug = slugify(nextValue) + if (nextSlug !== form.getFieldValue('tenantSlug')) { + form.setFieldValue('tenantSlug', () => nextSlug) + void form.validateField('tenantSlug', 'change') + } + } + }} + onBlur={field.handleBlur} + placeholder="Acme Studio" + disabled={isSubmitting} + error={Boolean(error)} + autoComplete="organization" + /> + {error} +
+ ) + }} +
+ + {(field) => { + const error = firstErrorMessage(field.state.meta.errors) + return ( +
+ + { + onFieldInteraction() + slugManuallyEditedRef.current = true + field.handleChange(event.currentTarget.value) + }} + onBlur={field.handleBlur} + placeholder="acme" + disabled={isSubmitting} + error={Boolean(error)} + autoComplete="off" + /> +

+ Lowercase letters, numbers, and hyphen are allowed. We'll ensure the slug is unique. +

+ {error} +
+ ) + }} +
+
+
+) diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/utils.ts b/be/apps/dashboard/src/modules/auth/components/registration-wizard/utils.ts new file mode 100644 index 00000000..b6936c17 --- /dev/null +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/utils.ts @@ -0,0 +1,32 @@ +import type { UiFieldNode, UiNode } from '~/modules/schema-form/types' + +export const collectSiteFields = (nodes: ReadonlyArray>): Array> => { + const fields: Array> = [] + + for (const node of nodes) { + if (node.type === 'field') { + fields.push(node) + continue + } + + fields.push(...collectSiteFields(node.children)) + } + + return fields +} + +export const firstErrorMessage = (errors: Array): string | undefined => { + if (!errors?.length) return undefined + const [first] = errors + if (first == null) return undefined + if (typeof first === 'string') return first + if (first instanceof Error) return first.message + if (typeof first === 'object' && 'message' in (first as Record)) { + return String((first as Record).message) + } + try { + return JSON.stringify(first) + } catch { + return String(first) + } +} diff --git a/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts b/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts index deed59ca..8868fcb8 100644 --- a/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts +++ b/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts @@ -5,12 +5,15 @@ import { useState } from 'react' import type { RegisterTenantPayload } from '~/modules/auth/api/registerTenant' import { registerTenant } from '~/modules/auth/api/registerTenant' +import type { TenantSiteFieldKey } from './useRegistrationForm' + interface TenantRegistrationRequest { tenantName: string tenantSlug: string accountName: string email: string password: string + settings: Array<{ key: TenantSiteFieldKey; value: string }> } const SECOND_LEVEL_PUBLIC_SUFFIXES = new Set(['ac', 'co', 'com', 'edu', 'gov', 'net', 'org']) @@ -84,6 +87,10 @@ export function useRegisterTenant() { }, } + if (data.settings.length > 0) { + payload.settings = data.settings + } + const response = await registerTenant(payload) const headerSlug = response.headers.get('x-tenant-slug')?.trim().toLowerCase() ?? null diff --git a/be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts b/be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts index e17cb64d..a7fa552c 100644 --- a/be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts +++ b/be/apps/dashboard/src/modules/auth/hooks/useRegistrationForm.ts @@ -1,8 +1,14 @@ -import { useState } from 'react' +import { useForm } from '@tanstack/react-form' +import { useMemo } from 'react' +import { z } from 'zod' -import { isLikelyEmail, slugify } from '~/modules/onboarding/utils' +import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/onboarding/siteSchema' +import type { SiteFormState } from '~/modules/onboarding/types' +import { isLikelyEmail } from '~/modules/onboarding/utils' -export interface TenantRegistrationFormState { +export type TenantSiteFieldKey = (typeof SITE_SETTINGS_KEYS)[number] + +export type TenantRegistrationFormState = SiteFormState & { tenantName: string tenantSlug: string accountName: string @@ -13,18 +19,59 @@ export interface TenantRegistrationFormState { } const REQUIRED_PASSWORD_LENGTH = 8 -const ALL_FIELDS: Array = [ - 'tenantName', - 'tenantSlug', - 'accountName', - 'email', - 'password', - 'confirmPassword', - 'termsAccepted', -] -export function useRegistrationForm(initial?: Partial) { - const [values, setValues] = useState({ +const baseRegistrationSchema = z.object({ + tenantName: z.string().min(1, { error: 'Workspace name is required' }), + tenantSlug: z + .string() + .min(1, { error: 'Slug is required' }) + .regex(/^[a-z0-9-]+$/, { error: 'Use lowercase letters, numbers, and hyphen only' }), + accountName: z.string().min(1, { error: 'Administrator name is required' }), + email: z + .string() + .min(1, { error: 'Email is required' }) + .refine((value) => isLikelyEmail(value), { error: 'Enter a valid email address' }), + password: z + .string() + .min(1, { error: 'Password is required' }) + .min(REQUIRED_PASSWORD_LENGTH, { + error: `Password must be at least ${REQUIRED_PASSWORD_LENGTH} characters`, + }), + confirmPassword: z.string().min(1, { error: 'Confirm your password' }), + termsAccepted: z.boolean({ + error: 'You must accept the terms to continue', + }), +}) + +export const tenantRegistrationSchema = siteSettingsSchema.merge(baseRegistrationSchema).superRefine((data, ctx) => { + if (data.confirmPassword !== '' && data.password !== data.confirmPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: 'Passwords do not match', + path: ['confirmPassword'], + }) + } +}) + +export function buildRegistrationInitialValues( + initial?: Partial, +): TenantRegistrationFormState { + const siteValues: SiteFormState = { ...DEFAULT_SITE_SETTINGS_VALUES } + + if (initial) { + for (const key of SITE_SETTINGS_KEYS) { + const value = initial[key] + if (value === undefined || value === null) { + continue + } + + if (typeof value === 'boolean' || typeof value === 'string' || typeof value === 'number') { + siteValues[key] = value + } + } + } + + return { tenantName: initial?.tenantName ?? '', tenantSlug: initial?.tenantSlug ?? '', accountName: initial?.accountName ?? '', @@ -32,125 +79,46 @@ export function useRegistrationForm(initial?: Partial>>({}) - const [slugManuallyEdited, setSlugManuallyEdited] = useState(false) - - const updateValue = ( - field: K, - value: TenantRegistrationFormState[K], - ) => { - setValues((prev) => { - if (field === 'tenantName' && !slugManuallyEdited) { - return { - ...prev, - tenantName: value as string, - tenantSlug: slugify(value as string), - } - } - - if (field === 'tenantSlug') { - setSlugManuallyEdited(true) - } - - return { ...prev, [field]: value } - }) - setErrors((prev) => { - const next = { ...prev } - delete next[field] - return next - }) - } - - const fieldError = (field: keyof TenantRegistrationFormState): string | undefined => { - switch (field) { - case 'tenantName': { - return values.tenantName.trim() ? undefined : 'Workspace name is required' - } - case 'tenantSlug': { - const slug = values.tenantSlug.trim() - if (!slug) return 'Slug is required' - if (!/^[a-z0-9-]+$/.test(slug)) return 'Use lowercase letters, numbers, and hyphen only' - return undefined - } - case 'email': { - const email = values.email.trim() - if (!email) return 'Email is required' - if (!isLikelyEmail(email)) return 'Enter a valid email address' - return undefined - } - case 'accountName': { - return values.accountName.trim() ? undefined : 'Administrator name is required' - } - case 'password': { - if (!values.password) return 'Password is required' - if (values.password.length < REQUIRED_PASSWORD_LENGTH) { - return `Password must be at least ${REQUIRED_PASSWORD_LENGTH} characters` - } - return undefined - } - case 'confirmPassword': { - if (!values.confirmPassword) return 'Confirm your password' - if (values.confirmPassword !== values.password) return 'Passwords do not match' - return undefined - } - case 'termsAccepted': { - return values.termsAccepted ? undefined : 'You must accept the terms to continue' - } - } - - return undefined - } - - const validate = (fields?: Array) => { - const fieldsToValidate = fields ?? ALL_FIELDS - const stepErrors: Partial> = {} - let hasErrors = false - - for (const field of fieldsToValidate) { - const error = fieldError(field) - if (error) { - stepErrors[field] = error - hasErrors = true - } - } - - setErrors((prev) => { - const next = { ...prev } - for (const field of fieldsToValidate) { - const error = stepErrors[field] - if (error) { - next[field] = error - } else { - delete next[field] - } - } - return next - }) - - return !hasErrors - } - - const reset = () => { - setValues({ - tenantName: '', - tenantSlug: '', - accountName: '', - email: '', - password: '', - confirmPassword: '', - termsAccepted: false, - }) - setErrors({}) - setSlugManuallyEdited(false) - } - - return { - values, - errors, - updateValue, - validate, - getFieldError: fieldError, - reset, + ...siteValues, } } + +export function validateRegistrationValues(values: TenantRegistrationFormState): Record { + const result = tenantRegistrationSchema.safeParse(values) + + if (result.success) { + return {} + } + + const fieldErrors: Record = {} + + for (const issue of result.error.issues) { + const path = issue.path.join('.') + + if (!path || fieldErrors[path]) { + continue + } + + fieldErrors[path] = issue.message + } + + return fieldErrors +} + +export function useRegistrationForm(initial?: Partial) { + const defaultValues = useMemo(() => buildRegistrationInitialValues(initial), [initial]) + + return useForm({ + defaultValues, + validators: { + onChange: ({ value }) => { + const fieldErrors = validateRegistrationValues(value) + return Object.keys(fieldErrors).length > 0 ? { fields: fieldErrors } : undefined + }, + onSubmit: ({ value }) => { + const fieldErrors = validateRegistrationValues(value) + return Object.keys(fieldErrors).length > 0 ? { fields: fieldErrors } : undefined + }, + }, + }) +} diff --git a/be/apps/dashboard/src/modules/onboarding/api.ts b/be/apps/dashboard/src/modules/onboarding/api.ts index c8b8ffbd..ff63c73a 100644 --- a/be/apps/dashboard/src/modules/onboarding/api.ts +++ b/be/apps/dashboard/src/modules/onboarding/api.ts @@ -1,6 +1,6 @@ import { coreApi } from '~/lib/api-client' -import type { OnboardingSettingKey } from './constants' +import type { OnboardingSettingKey, OnboardingSiteSettingKey } from './constants' export type OnboardingStatusResponse = { initialized: boolean @@ -17,7 +17,7 @@ export type OnboardingInitPayload = { slug: string } settings?: Array<{ - key: OnboardingSettingKey + key: OnboardingSettingKey | OnboardingSiteSettingKey value: unknown }> } @@ -35,6 +35,12 @@ export async function getOnboardingStatus() { }) } +export async function getOnboardingSiteSchema() { + return await coreApi('/onboarding/site-schema', { + method: 'GET', + }) +} + export async function postOnboardingInit(payload: OnboardingInitPayload) { return await coreApi('/onboarding/init', { method: 'POST', diff --git a/be/apps/dashboard/src/modules/onboarding/components/LinearBorderContainer.tsx b/be/apps/dashboard/src/modules/onboarding/components/LinearBorderContainer.tsx index 73c095b8..01445e35 100644 --- a/be/apps/dashboard/src/modules/onboarding/components/LinearBorderContainer.tsx +++ b/be/apps/dashboard/src/modules/onboarding/components/LinearBorderContainer.tsx @@ -30,14 +30,14 @@ export const LinearBorderContainer: FC = ({ children, className, - tint = 'var(--color-text)', + tint = 'var(--color-text-secondary)', }) => { // Generate inline styles for gradients with dynamic tint color const horizontalGradient = { background: `linear-gradient(to right, transparent, ${tint}, transparent)`, } const verticalGradient = { - background: `linear-gradient(to bottom, transparent, ${tint}, transparent)`, + background: `linear-gradient(to bottom, transparent -15%, ${tint} 50%, transparent 115%)`, } // Advanced mode: uses flex layout for borders that span full dimensions diff --git a/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx b/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx index 6a4beeba..5cd760e6 100644 --- a/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx +++ b/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx @@ -14,6 +14,7 @@ import { LoadingState } from './states/LoadingState' import { AdminStep } from './steps/AdminStep' import { ReviewStep } from './steps/ReviewStep' import { SettingsStep } from './steps/SettingsStep' +import { SiteStep } from './steps/SiteStep' import { TenantStep } from './steps/TenantStep' import { WelcomeStep } from './steps/WelcomeStep' @@ -30,6 +31,7 @@ export const OnboardingWizard: FC = () => { canNavigateTo, tenant, admin, + site, settingsState, acknowledged, setAcknowledged, @@ -39,7 +41,11 @@ export const OnboardingWizard: FC = () => { updateAdminField, toggleSetting, updateSettingValue, + updateSiteField, reviewSettings, + siteSchema, + siteSchemaLoading, + siteSchemaError, } = wizard // Autofocus management: focus first focusable control when step changes @@ -117,12 +123,21 @@ export const OnboardingWizard: FC = () => { return } + if (siteSchemaLoading || !siteSchema) { + return + } + + if (siteSchemaError) { + return + } + const stepContent: Record = { welcome: , tenant: ( ), admin: , + site: , settings: ( { + siteSchemaLoading?: boolean + siteSchemaError?: string | null reviewSettings: ReviewSettingEntry[] acknowledged: boolean errors: OnboardingErrors onAcknowledgeChange: (checked: boolean) => void } +const optionalSiteValue = (value: SchemaFormValue | undefined) => { + if (typeof value === 'boolean') { + return value ? 'Enabled' : 'Disabled' + } + + if (typeof value === 'string') { + if (value.length === 0) { + return '—' + } + const lowered = value.toLowerCase() + if (lowered === 'true' || lowered === 'false') { + return lowered === 'true' ? 'Enabled' : 'Disabled' + } + return value + } + + if (value == null) { + return '—' + } + + return String(value) +} + +function collectSiteFields( + nodes: ReadonlyArray>, +): Array> { + const fields: Array> = [] + + for (const node of nodes) { + if (node.type === 'field') { + fields.push(node) + continue + } + + fields.push(...collectSiteFields(node.children)) + } + + return fields +} + export const ReviewStep: FC = ({ tenant, admin, + site, + siteSchema, + siteSchemaLoading = false, + siteSchemaError = null, reviewSettings, acknowledged, errors, @@ -60,6 +110,29 @@ export const ReviewStep: FC = ({
+
+

Site information

+ {siteSchemaLoading &&
} + {!siteSchemaLoading && siteSchemaError && ( +
+ {siteSchemaError} +
+ )} + {!siteSchemaLoading && !siteSchemaError && ( +
+ {collectSiteFields(siteSchema.sections).map((field) => { + const spanClass = field.component?.type === 'textarea' ? 'md:col-span-2' : '' + return ( +
+
{field.title}
+
{optionalSiteValue(site[field.key])}
+
+ ) + })} +
+ )} +
+

Enabled integrations

{reviewSettings.length === 0 ? ( diff --git a/be/apps/dashboard/src/modules/onboarding/components/steps/SiteStep.tsx b/be/apps/dashboard/src/modules/onboarding/components/steps/SiteStep.tsx new file mode 100644 index 00000000..0bd3dc29 --- /dev/null +++ b/be/apps/dashboard/src/modules/onboarding/components/steps/SiteStep.tsx @@ -0,0 +1,48 @@ +import type { FC } from 'react' +import { useMemo } from 'react' + +import { SchemaFormRendererUncontrolled } from '~/modules/schema-form/SchemaFormRenderer' +import type { SchemaFormValue, UiSchema } from '~/modules/schema-form/types' + +import type { OnboardingSiteSettingKey } from '../../constants' +import type { SiteFormState } from '../../types' + +type SiteStepProps = { + schema: UiSchema + values: SiteFormState + errors: Record + onFieldChange: (key: OnboardingSiteSettingKey, value: string | boolean) => void +} + +export const SiteStep: FC = ({ schema, values, errors, onFieldChange }) => { + const schemaWithErrors = useMemo(() => { + return { + ...schema, + sections: schema.sections.map((section) => ({ + ...section, + children: section.children.map((child: any) => { + if (child.type !== 'field') { + return child + } + const error = errors[child.key] + return { + ...child, + helperText: error ?? child.helperText ?? null, + } + }), + })), + } + }, [errors, schema]) + + return ( +
+ { + onFieldChange(key, typeof value === 'boolean' ? value : value == null ? '' : String(value)) + }} + /> +
+ ) +} diff --git a/be/apps/dashboard/src/modules/onboarding/constants.ts b/be/apps/dashboard/src/modules/onboarding/constants.ts index 1dc7a2e0..6f9791d3 100644 --- a/be/apps/dashboard/src/modules/onboarding/constants.ts +++ b/be/apps/dashboard/src/modules/onboarding/constants.ts @@ -5,6 +5,8 @@ export type OnboardingSettingKey = | 'http.cors.allowedOrigins' | 'services.amap.apiKey' +export type OnboardingSiteSettingKey = 'site.name' | 'site.title' | 'site.description' + export type SettingFieldDefinition = { key: OnboardingSettingKey label: string @@ -81,8 +83,8 @@ export const ONBOARDING_SETTING_SECTIONS: SettingSectionDefinition[] = [ }, ] -export const ONBOARDING_TOTAL_STEPS = 5 as const -export const ONBOARDING_STEP_ORDER = ['welcome', 'tenant', 'admin', 'settings', 'review'] as const +export const ONBOARDING_TOTAL_STEPS = 6 as const +export const ONBOARDING_STEP_ORDER = ['welcome', 'tenant', 'site', 'admin', 'settings', 'review'] as const export type OnboardingStepId = (typeof ONBOARDING_STEP_ORDER)[number] @@ -103,6 +105,11 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ title: 'Tenant Profile', description: 'Name your workspace and choose a slug.', }, + { + id: 'site', + title: 'Site Branding', + description: 'Set the public gallery information shown to your visitors.', + }, { id: 'admin', title: 'Administrator', diff --git a/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts b/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts index 359a3028..75296036 100644 --- a/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts +++ b/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts @@ -1,14 +1,27 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { FetchError } from 'ofetch' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { toast } from 'sonner' +import type { UiFieldNode, UiSchema } from '~/modules/schema-form/types' + import type { OnboardingInitPayload } from '../api' -import { getOnboardingStatus, postOnboardingInit } from '../api' -import type { OnboardingSettingKey, OnboardingStepId } from '../constants' +import { getOnboardingSiteSchema, getOnboardingStatus, postOnboardingInit } from '../api' +import type { OnboardingSettingKey, OnboardingSiteSettingKey, OnboardingStepId } from '../constants' import { ONBOARDING_STEPS } from '../constants' -import type { AdminFormState, OnboardingErrors, SettingFormState, TenantFormState } from '../types' -import { createInitialSettingsState, getFieldByKey, isLikelyEmail, maskSecret, slugify } from '../utils' +import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '../siteSchema' +import type { AdminFormState, OnboardingErrors, SettingFormState, SiteFormState, TenantFormState } from '../types' +import { + coerceSiteFieldValue, + collectSchemaFieldMap, + createInitialSettingsState, + createInitialSiteStateFromFieldMap, + getFieldByKey, + isLikelyEmail, + maskSecret, + serializeSiteFieldValue, + slugify, +} from '../utils' const INITIAL_STEP_INDEX = 0 @@ -26,8 +39,10 @@ export function useOnboardingWizard() { confirmPassword: '', }) const [settingsState, setSettingsState] = useState(createInitialSettingsState) + const [site, setSite] = useState(() => ({ ...DEFAULT_SITE_SETTINGS_VALUES })) const [acknowledged, setAcknowledged] = useState(false) const [errors, setErrors] = useState({}) + const [siteDefaultsApplied, setSiteDefaultsApplied] = useState(false) const currentStep = ONBOARDING_STEPS[currentStepIndex] ?? ONBOARDING_STEPS[INITIAL_STEP_INDEX] @@ -37,6 +52,45 @@ export function useOnboardingWizard() { staleTime: Infinity, }) + const siteSchemaQuery = useQuery({ + queryKey: ['onboarding', 'site-schema'], + queryFn: getOnboardingSiteSchema, + staleTime: Infinity, + }) + + const siteSchemaData = siteSchemaQuery.data as + | { + schema?: UiSchema + values?: Partial> + } + | undefined + + const siteSchema = siteSchemaData?.schema ?? null + + useEffect(() => { + if (!siteSchema || siteDefaultsApplied) { + return + } + + const fieldMap = collectSchemaFieldMap(siteSchema) + const defaults = createInitialSiteStateFromFieldMap(fieldMap) + const values = siteSchemaData?.values ?? {} + const next: SiteFormState = { ...DEFAULT_SITE_SETTINGS_VALUES, ...defaults } + + for (const [key, field] of fieldMap) { + const coerced = coerceSiteFieldValue(field, values[key]) + if (coerced !== undefined) { + next[key] = coerced + } + } + + setSite(next) + setSiteDefaultsApplied(true) + }, [siteDefaultsApplied, siteSchema, siteSchemaData?.values]) + + const siteSchemaLoading = siteSchemaQuery.isLoading && !siteSchema + const siteSchemaError = siteSchemaQuery.isError + const mutation = useMutation({ mutationFn: (payload: OnboardingInitPayload) => postOnboardingInit(payload), onSuccess: () => { @@ -144,6 +198,31 @@ export function useOnboardingWizard() { return valid } + const validateSite = () => { + const candidate: Record = {} + for (const key of SITE_SETTINGS_KEYS) { + candidate[key] = site[key] ?? DEFAULT_SITE_SETTINGS_VALUES[key] + } + + const result = siteSettingsSchema.safeParse(candidate) + const fieldErrors: Record = {} + + if (!result.success) { + for (const issue of result.error.issues) { + const pathKey = issue.path[0] + if (typeof pathKey === 'string' && !(pathKey in fieldErrors)) { + fieldErrors[pathKey] = issue.message + } + } + } + + for (const key of SITE_SETTINGS_KEYS) { + setFieldError(key, fieldErrors[key] ?? null) + } + + return result.success + } + const validateSettings = () => { let valid = true for (const [key, entry] of Object.entries(settingsState) as Array< @@ -175,6 +254,7 @@ export function useOnboardingWizard() { const validators: Partial boolean>> = { welcome: () => true, tenant: validateTenant, + site: validateSite, admin: validateAdmin, settings: validateSettings, review: validateAcknowledgement, @@ -200,8 +280,18 @@ export function useOnboardingWizard() { value: entry.value.trim(), })) - if (settingEntries.length > 0) { - payload.settings = settingEntries + const fieldMap = siteSchema + ? collectSchemaFieldMap(siteSchema) + : new Map>() + const siteEntries = Array.from(fieldMap.entries()).map(([key, field]) => ({ + key, + value: serializeSiteFieldValue(field, site[key]), + })) + + const combined = [...settingEntries, ...siteEntries] + + if (combined.length > 0) { + payload.settings = combined as Array<{ key: OnboardingSettingKey | OnboardingSiteSettingKey; value: string }> } mutation.mutate(payload) @@ -278,9 +368,21 @@ export function useOnboardingWizard() { value: entry.value.trim(), })) + const updateSiteField = (key: OnboardingSiteSettingKey, value: string | boolean) => { + setSite((prev) => ({ + ...prev, + [key]: + typeof value === 'boolean' ? value : value == null ? '' : typeof value === 'number' ? String(value) : value, + })) + setFieldError(key, null) + } + return { query, mutation, + siteSchema, + siteSchemaLoading, + siteSchemaError, currentStepIndex, currentStep, goToNext, @@ -289,6 +391,7 @@ export function useOnboardingWizard() { canNavigateTo: (index: number) => index <= currentStepIndex, tenant, admin, + site, settingsState, acknowledged, setAcknowledged: (value: boolean) => { @@ -303,6 +406,7 @@ export function useOnboardingWizard() { updateAdminField, toggleSetting, updateSettingValue, + updateSiteField, reviewSettings, maskSecret, } diff --git a/be/apps/dashboard/src/modules/onboarding/siteSchema.ts b/be/apps/dashboard/src/modules/onboarding/siteSchema.ts new file mode 100644 index 00000000..47586911 --- /dev/null +++ b/be/apps/dashboard/src/modules/onboarding/siteSchema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' + +import type { OnboardingSiteSettingKey } from './constants' + +const stringValue = (validator: z.ZodString) => z.preprocess((value) => (value == null ? '' : String(value)), validator) + +const trimmed = (min: number, message: string) => stringValue(z.string().trim().min(min, { message })) + +export const siteSettingsSchema = z + .object({ + 'site.name': trimmed(1, 'Site name is required'), + 'site.title': trimmed(1, 'Home title is required'), + 'site.description': trimmed(1, 'Site description is required'), + }) + .passthrough() + +export type SiteSettingsSchema = typeof siteSettingsSchema +export type SiteSettingsValues = z.infer + +export const SITE_SETTINGS_KEYS = Object.keys(siteSettingsSchema.shape) as OnboardingSiteSettingKey[] + +export const DEFAULT_SITE_SETTINGS_VALUES: SiteSettingsValues = { + 'site.name': '', + 'site.title': '', + 'site.description': '', +} diff --git a/be/apps/dashboard/src/modules/onboarding/types.ts b/be/apps/dashboard/src/modules/onboarding/types.ts index bd18cd10..d772a061 100644 --- a/be/apps/dashboard/src/modules/onboarding/types.ts +++ b/be/apps/dashboard/src/modules/onboarding/types.ts @@ -1,4 +1,6 @@ -import type { OnboardingSettingKey } from './constants' +import type { SchemaFormState } from '~/modules/schema-form/types' + +import type { OnboardingSettingKey, OnboardingSiteSettingKey } from './constants' export type TenantFormState = { name: string @@ -20,4 +22,6 @@ export type SettingFormState = Record< } > +export type SiteFormState = SchemaFormState + export type OnboardingErrors = Record diff --git a/be/apps/dashboard/src/modules/onboarding/utils.ts b/be/apps/dashboard/src/modules/onboarding/utils.ts index 9699a0cd..714d8e47 100644 --- a/be/apps/dashboard/src/modules/onboarding/utils.ts +++ b/be/apps/dashboard/src/modules/onboarding/utils.ts @@ -1,5 +1,8 @@ +import type { SchemaFormState, SchemaFormValue, UiFieldNode, UiNode, UiSchema } from '~/modules/schema-form/types' + import type { OnboardingSettingKey, SettingFieldDefinition } from './constants' import { ONBOARDING_SETTING_SECTIONS, ONBOARDING_STEPS } from './constants' +import { DEFAULT_SITE_SETTINGS_VALUES } from './siteSchema' import type { SettingFormState } from './types' export function createInitialSettingsState(): SettingFormState { @@ -47,3 +50,126 @@ export function getFieldByKey(key: OnboardingSettingKey): SettingFieldDefinition } throw new Error(`Unknown onboarding setting key: ${key}`) } + +const traverseSchemaNodes = ( + nodes: ReadonlyArray>, + map: Map>, +) => { + for (const node of nodes) { + if (node.type === 'field') { + map.set(node.key, node) + continue + } + + traverseSchemaNodes(node.children, map) + } +} + +export function collectSchemaFieldMap(schema: UiSchema): Map> { + const map = new Map>() + traverseSchemaNodes(schema.sections, map) + return map +} + +export function collectSchemaFieldKeys(schema: UiSchema): Key[] { + return [...collectSchemaFieldMap(schema).keys()] +} + +export function createInitialSiteStateFromFieldMap( + fieldMap: Map>, +): SchemaFormState { + const state = {} as SchemaFormState + for (const [key, field] of fieldMap) { + const defaultValue = (DEFAULT_SITE_SETTINGS_VALUES as Record)[key as string] + if (defaultValue !== undefined) { + state[key] = defaultValue + continue + } + + if (field.component.type === 'switch') { + state[key] = false + continue + } + + state[key] = '' + } + return state +} + +export function createInitialSiteStateFromSchema(schema: UiSchema): SchemaFormState { + return createInitialSiteStateFromFieldMap(collectSchemaFieldMap(schema)) +} + +export function coerceSiteFieldValue( + field: UiFieldNode, + raw: unknown, +): SchemaFormValue | undefined { + if (raw == null) { + return undefined + } + + if (field.component.type === 'switch') { + if (typeof raw === 'boolean') { + return raw + } + if (typeof raw === 'string') { + return raw.toLowerCase() === 'true' + } + return Boolean(raw) + } + + if (typeof raw === 'string') { + return raw + } + + if (typeof raw === 'number') { + return String(raw) + } + + if (typeof raw === 'boolean') { + return raw ? 'true' : 'false' + } + + try { + return String(raw) + } catch { + return '' + } +} + +export function serializeSiteFieldValue( + field: UiFieldNode, + value: SchemaFormValue | undefined, +): string { + if (field.component.type === 'switch') { + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + if (typeof value === 'string') { + return value.toLowerCase() === 'true' ? 'true' : 'false' + } + return 'false' + } + + if (value == null) { + return '' + } + + if (typeof value === 'string') { + return value.trim() + } + + if (typeof value === 'number') { + return String(value) + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + try { + return String(value) + } catch { + return '' + } +} diff --git a/be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx b/be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx index 319e1346..a8bcd0bd 100644 --- a/be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx +++ b/be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx @@ -14,7 +14,7 @@ import { import { clsxm } from '@afilmory/utils' import { DynamicIcon } from 'lucide-react/dynamic' import type { ReactNode } from 'react' -import { Fragment, useState } from 'react' +import { Fragment, useMemo, useState } from 'react' import { LinearBorderPanel } from '../../components/common/GlassPanel' import type { @@ -312,6 +312,27 @@ export interface SchemaFormRendererProps { renderSlot?: SlotRenderer } +export function SchemaFormRendererUncontrolled({ + initialValues, + onChange, + ...rest +}: Omit, 'values'> & { initialValues: SchemaFormState }) { + const [values, setValues] = useState(initialValues) + + const handleChange = useMemo( + () => (key: Key, value: SchemaFormValue) => { + setValues((prev) => { + const next = { ...prev, [key]: value } + onChange?.(key, value) + return next + }) + }, + [onChange], + ) + + return +} + export function SchemaFormRenderer({ schema, values, diff --git a/be/apps/dashboard/src/modules/settings/components/SettingsNavigation.tsx b/be/apps/dashboard/src/modules/settings/components/SettingsNavigation.tsx index 7fa0675a..232d98f9 100644 --- a/be/apps/dashboard/src/modules/settings/components/SettingsNavigation.tsx +++ b/be/apps/dashboard/src/modules/settings/components/SettingsNavigation.tsx @@ -2,9 +2,9 @@ import { PageTabs } from '~/components/navigation/PageTabs' const SETTINGS_TABS = [ { - id: 'general', - label: '通用设置', - path: '/settings', + id: 'site', + label: '站点设置', + path: '/settings/site', end: true, }, ] as const diff --git a/be/apps/dashboard/src/modules/site-settings/api.ts b/be/apps/dashboard/src/modules/site-settings/api.ts new file mode 100644 index 00000000..eafb8856 --- /dev/null +++ b/be/apps/dashboard/src/modules/site-settings/api.ts @@ -0,0 +1,16 @@ +import { coreApi } from '~/lib/api-client' + +import type { SiteSettingEntryInput, SiteSettingUiSchemaResponse } from './types' + +const SITE_SETTINGS_ENDPOINT = '/site/settings' + +export async function getSiteSettingUiSchema() { + return await coreApi(`${SITE_SETTINGS_ENDPOINT}/ui-schema`) +} + +export async function updateSiteSettings(entries: readonly SiteSettingEntryInput[]) { + return await coreApi<{ updated: readonly SiteSettingEntryInput[] }>(`${SITE_SETTINGS_ENDPOINT}`, { + method: 'POST', + body: { entries }, + }) +} diff --git a/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx b/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx new file mode 100644 index 00000000..6dc20920 --- /dev/null +++ b/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx @@ -0,0 +1,252 @@ +import { Button } from '@afilmory/ui' +import { Spring } from '@afilmory/utils' +import { m } from 'motion/react' +import { startTransition, useCallback, useEffect, useId, useMemo, useState } from 'react' + +import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout' + +import { SchemaFormRenderer } from '../../schema-form/SchemaFormRenderer' +import type { SchemaFormValue, UiFieldNode } from '../../schema-form/types' +import { collectFieldNodes } from '../../schema-form/utils' +import { useSiteSettingUiSchemaQuery, useUpdateSiteSettingsMutation } from '../hooks' +import type { SiteSettingEntryInput, SiteSettingUiSchemaResponse, SiteSettingValueState } from '../types' + +function coerceInitialValue(field: UiFieldNode, rawValue: string | null): SchemaFormValue { + const { component } = field + + if (component.type === 'switch') { + if (typeof rawValue !== 'string') { + return false + } + + const normalized = rawValue.trim().toLowerCase() + if (normalized === 'true') { + return true + } + + if (normalized === 'false') { + return false + } + + return false + } + + return typeof rawValue === 'string' ? rawValue : '' +} + +function buildInitialState( + schema: SiteSettingUiSchemaResponse['schema'], + values: SiteSettingUiSchemaResponse['values'], +): SiteSettingValueState { + const state: SiteSettingValueState = {} as SiteSettingValueState + const fields = collectFieldNodes(schema.sections) + + for (const field of fields) { + const rawValue = values[field.key] ?? null + state[field.key] = coerceInitialValue(field, rawValue) + } + + return state +} + +function serializeValue(field: UiFieldNode, value: SchemaFormValue | undefined): string { + if (field.component.type === 'switch') { + return value ? 'true' : 'false' + } + + if (typeof value === 'string') { + return value + } + + if (value == null) { + return '' + } + + return String(value) +} + +export function SiteSettingsForm() { + const { data, isLoading, isError, error } = useSiteSettingUiSchemaQuery() + const updateSettingsMutation = useUpdateSiteSettingsMutation() + const { setHeaderActionState } = useMainPageLayout() + const formId = useId() + const [formState, setFormState] = useState>({} as SiteSettingValueState) + const [initialState, setInitialState] = useState | null>(null) + + const fieldMap = useMemo(() => { + if (!data) { + return new Map>() + } + + const fields = collectFieldNodes(data.schema.sections) + return new Map(fields.map((field) => [field.key, field])) + }, [data]) + + useEffect(() => { + if (!data) { + return + } + + const initialValues = buildInitialState(data.schema, data.values) + startTransition(() => { + setFormState(initialValues) + setInitialState(initialValues) + }) + }, [data]) + + const changedEntries = useMemo(() => { + if (!initialState) { + return [] + } + + const entries: SiteSettingEntryInput[] = [] + + for (const [key, value] of Object.entries(formState)) { + if (!Object.prototype.hasOwnProperty.call(initialState, key)) { + continue + } + + if (initialState[key] === value) { + continue + } + + const field = fieldMap.get(key) + if (!field) { + continue + } + + entries.push({ + key, + value: serializeValue(field, value), + }) + } + + return entries + }, [fieldMap, formState, initialState]) + + const handleChange = useCallback( + (key: string, value: SchemaFormValue) => { + const field = fieldMap.get(key) + const normalizedValue: SchemaFormValue = value == null ? (field?.component.type === 'switch' ? false : '') : value + + setFormState((prev) => ({ + ...prev, + [key]: normalizedValue, + })) + }, + [fieldMap], + ) + + const handleSubmit: React.FormEventHandler = (event) => { + event.preventDefault() + if (changedEntries.length === 0 || updateSettingsMutation.isPending) { + return + } + + updateSettingsMutation.mutate(changedEntries) + } + + const mutationErrorMessage = + updateSettingsMutation.isError && updateSettingsMutation.error + ? updateSettingsMutation.error instanceof Error + ? updateSettingsMutation.error.message + : '未知错误' + : null + + useEffect(() => { + setHeaderActionState((prev) => { + const nextState = { + disabled: isLoading || isError || changedEntries.length === 0, + loading: updateSettingsMutation.isPending, + } + return prev.disabled === nextState.disabled && prev.loading === nextState.loading ? prev : nextState + }) + + return () => { + setHeaderActionState({ disabled: false, loading: false }) + } + }, [changedEntries.length, isError, isLoading, setHeaderActionState, updateSettingsMutation.isPending]) + + const headerActionPortal = ( + + + + ) + + if (isLoading) { + return ( + <> + {headerActionPortal} + +
+
+
+ {['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key) => ( +
+ ))} +
+
+ + + ) + } + + if (isError) { + return ( + <> + {headerActionPortal} + +
+ + {`无法加载站点设置:${error instanceof Error ? error.message : '未知错误'}`} +
+
+ + ) + } + + if (!data) { + return headerActionPortal + } + + const { schema } = data + + return ( + <> + {headerActionPortal} + + + +
+
+ {mutationErrorMessage + ? `保存失败:${mutationErrorMessage}` + : updateSettingsMutation.isSuccess && changedEntries.length === 0 + ? '保存成功,站点设置已同步' + : changedEntries.length > 0 + ? `有 ${changedEntries.length} 项设置待保存` + : '所有设置已同步'} +
+
+
+ + ) +} diff --git a/be/apps/dashboard/src/modules/site-settings/hooks.ts b/be/apps/dashboard/src/modules/site-settings/hooks.ts new file mode 100644 index 00000000..938fd1e7 --- /dev/null +++ b/be/apps/dashboard/src/modules/site-settings/hooks.ts @@ -0,0 +1,28 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { getSiteSettingUiSchema, updateSiteSettings } from './api' +import type { SiteSettingEntryInput } from './types' + +export const SITE_SETTING_UI_SCHEMA_QUERY_KEY = ['site-settings', 'ui-schema'] as const + +export function useSiteSettingUiSchemaQuery() { + return useQuery({ + queryKey: SITE_SETTING_UI_SCHEMA_QUERY_KEY, + queryFn: getSiteSettingUiSchema, + }) +} + +export function useUpdateSiteSettingsMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (entries: readonly SiteSettingEntryInput[]) => { + await updateSiteSettings(entries) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: SITE_SETTING_UI_SCHEMA_QUERY_KEY, + }) + }, + }) +} diff --git a/be/apps/dashboard/src/modules/site-settings/index.ts b/be/apps/dashboard/src/modules/site-settings/index.ts new file mode 100644 index 00000000..f4fe0cea --- /dev/null +++ b/be/apps/dashboard/src/modules/site-settings/index.ts @@ -0,0 +1,4 @@ +export * from './api' +export * from './components/SiteSettingsForm' +export * from './hooks' +export * from './types' diff --git a/be/apps/dashboard/src/modules/site-settings/types.ts b/be/apps/dashboard/src/modules/site-settings/types.ts new file mode 100644 index 00000000..feeb488b --- /dev/null +++ b/be/apps/dashboard/src/modules/site-settings/types.ts @@ -0,0 +1,13 @@ +import type { SchemaFormValue, UiSchema } from '../schema-form/types' + +export interface SiteSettingUiSchemaResponse { + readonly schema: UiSchema + readonly values: Partial> +} + +export type SiteSettingValueState = Record + +export type SiteSettingEntryInput = { + readonly key: Key + readonly value: string +} diff --git a/be/apps/dashboard/src/pages/(main)/settings/index.tsx b/be/apps/dashboard/src/pages/(main)/settings/index.tsx index 78e273a7..a251bf59 100644 --- a/be/apps/dashboard/src/pages/(main)/settings/index.tsx +++ b/be/apps/dashboard/src/pages/(main)/settings/index.tsx @@ -5,7 +5,7 @@ export function Component() { return (
- +
diff --git a/be/apps/dashboard/src/pages/(main)/settings/site.tsx b/be/apps/dashboard/src/pages/(main)/settings/site.tsx new file mode 100644 index 00000000..19aa54e8 --- /dev/null +++ b/be/apps/dashboard/src/pages/(main)/settings/site.tsx @@ -0,0 +1,14 @@ +import { MainPageLayout } from '~/components/layouts/MainPageLayout' +import { SettingsNavigation } from '~/modules/settings' +import { SiteSettingsForm } from '~/modules/site-settings' + +export function Component() { + return ( + +
+ + +
+
+ ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f26a30b6..d4eca14c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,7 +603,7 @@ importers: version: 1.19.6(hono@4.10.4) better-auth: specifier: 1.3.34 - version: 1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@types/pg@8.15.6)(@vercel/postgres@0.10.0)(kysely@0.28.8)(pg@8.16.3)(postgres@3.4.7) @@ -719,12 +719,15 @@ importers: '@remixicon/react': specifier: 4.7.0 version: 4.7.0(react@19.2.0) + '@tanstack/react-form': + specifier: 1.23.8 + version: 1.23.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-query': specifier: 5.90.5 version: 5.90.5(react@19.2.0) better-auth: specifier: 1.3.34 - version: 1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -742,7 +745,7 @@ importers: version: 10.2.0 jotai: specifier: 2.15.0 - version: 2.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0) + version: 2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0) lucide-react: specifier: 0.552.0 version: 0.552.0(react@19.2.0) @@ -769,7 +772,7 @@ importers: version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-scan: specifier: 0.4.3 - version: 0.4.3(@types/react@19.2.2)(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5) + version: 0.4.3(@types/react@19.2.2)(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5) sonner: specifier: 2.0.7 version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1341,7 +1344,7 @@ importers: version: 0.15.12(typescript@5.9.3) unplugin-dts: specifier: 1.0.0-beta.6 - version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.2))(esbuild@0.25.11)(rolldown@1.0.0-beta.45)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.2))(esbuild@0.25.11)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) vite: specifier: 7.1.12 version: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) @@ -5197,20 +5200,49 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/devtools-event-client@0.3.4': + resolution: {integrity: sha512-eq+PpuutUyubXu+ycC1GIiVwBs86NF/8yYJJAKSpPcJLWl6R/761F1H4F/9ziX6zKezltFUH1ah3Cz8Ah+KJrw==} + engines: {node: '>=18'} + + '@tanstack/form-core@1.24.4': + resolution: {integrity: sha512-+eIR7DiDamit1zvTVgaHxuIRA02YFgJaXMUGxsLRJoBpUjGl/g/nhUocQoNkRyfXqOlh8OCMTanjwDprWSRq6w==} + + '@tanstack/pacer@0.15.4': + resolution: {integrity: sha512-vGY+CWsFZeac3dELgB6UZ4c7OacwsLb8hvL2gLS6hTgy8Fl0Bm/aLokHaeDIP+q9F9HUZTnp360z9uv78eg8pg==} + engines: {node: '>=18'} + '@tanstack/query-core@5.90.5': resolution: {integrity: sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==} + '@tanstack/react-form@1.23.8': + resolution: {integrity: sha512-ivfkiOHAI3aIWkCY4FnPWVAL6SkQWGWNVjtwIZpaoJE4ulukZWZ1KB8TQKs8f4STl+egjTsMHrWJuf2fv3Xh1w==} + peerDependencies: + '@tanstack/react-start': ^1.130.10 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + '@tanstack/react-query@5.90.5': resolution: {integrity: sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==} peerDependencies: react: ^18 || ^19 + '@tanstack/react-store@0.7.7': + resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.13.12': resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + '@tanstack/virtual-core@3.13.12': resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} @@ -6698,6 +6730,9 @@ packages: supports-color: optional: true + decode-formdata@0.9.0: + resolution: {integrity: sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -6752,6 +6787,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.4.2: + resolution: {integrity: sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -16272,19 +16310,51 @@ snapshots: tailwindcss: 4.1.16 vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + '@tanstack/devtools-event-client@0.3.4': {} + + '@tanstack/form-core@1.24.4': + dependencies: + '@tanstack/devtools-event-client': 0.3.4 + '@tanstack/pacer': 0.15.4 + '@tanstack/store': 0.7.7 + + '@tanstack/pacer@0.15.4': + dependencies: + '@tanstack/devtools-event-client': 0.3.4 + '@tanstack/store': 0.7.7 + '@tanstack/query-core@5.90.5': {} + '@tanstack/react-form@1.23.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/form-core': 1.24.4 + '@tanstack/react-store': 0.7.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + decode-formdata: 0.9.0 + devalue: 5.4.2 + react: 19.2.0 + transitivePeerDependencies: + - react-dom + '@tanstack/react-query@5.90.5(react@19.2.0)': dependencies: '@tanstack/query-core': 5.90.5 react: 19.2.0 + '@tanstack/react-store@0.7.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/store': 0.7.7 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + '@tanstack/react-virtual@3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@tanstack/virtual-core': 3.13.12 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@tanstack/store@0.7.7': {} + '@tanstack/virtual-core@3.13.12': {} '@tokenizer/inflate@0.2.7': @@ -17325,7 +17395,7 @@ snapshots: batch-cluster@15.0.1: {} - better-auth@1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) @@ -17342,7 +17412,7 @@ snapshots: nanostores: 1.0.1 zod: 4.1.12 optionalDependencies: - next: 16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -18013,6 +18083,8 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + decode-formdata@0.9.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -18055,6 +18127,8 @@ snapshots: detect-node-es@1.1.0: {} + devalue@5.4.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -19776,13 +19850,6 @@ snapshots: jose@6.1.0: {} - jotai@2.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0): - optionalDependencies: - '@babel/core': 7.28.4 - '@babel/template': 7.27.2 - '@types/react': 19.2.2 - react: 19.2.0 - jotai@2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0): optionalDependencies: '@babel/core': 7.28.5 @@ -20815,30 +20882,6 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - '@next/env': 16.0.1 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001752 - postcss: 8.4.31 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.0) - optionalDependencies: - '@next/swc-darwin-arm64': 16.0.1 - '@next/swc-darwin-x64': 16.0.1 - '@next/swc-linux-arm64-gnu': 16.0.1 - '@next/swc-linux-arm64-musl': 16.0.1 - '@next/swc-linux-x64-gnu': 16.0.1 - '@next/swc-linux-x64-musl': 16.0.1 - '@next/swc-win32-arm64-msvc': 16.0.1 - '@next/swc-win32-x64-msvc': 16.0.1 - sharp: 0.34.4 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - optional: true - next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.0.1 @@ -21949,7 +21992,7 @@ snapshots: optionalDependencies: react-dom: 19.2.0(react@19.2.0) - react-scan@0.4.3(@types/react@19.2.2)(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5): + react-scan@0.4.3(@types/react@19.2.2)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2): dependencies: '@babel/core': 7.28.4 '@babel/generator': 7.28.3 @@ -21958,7 +22001,7 @@ snapshots: '@clack/prompts': 0.8.2 '@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@preact/signals': 1.3.2(preact@10.27.2) - '@rollup/pluginutils': 5.3.0(rollup@4.52.5) + '@rollup/pluginutils': 5.3.0(rollup@2.79.2) '@types/node': 20.19.24 bippy: 0.3.27(@types/react@19.2.2)(react@19.2.0) esbuild: 0.25.11 @@ -21971,7 +22014,7 @@ snapshots: react-dom: 19.2.0(react@19.2.0) tsx: 4.20.6 optionalDependencies: - next: 16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) unplugin: 2.1.0 @@ -21980,7 +22023,7 @@ snapshots: - rollup - supports-color - react-scan@0.4.3(@types/react@19.2.2)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2): + react-scan@0.4.3(@types/react@19.2.2)(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5): dependencies: '@babel/core': 7.28.4 '@babel/generator': 7.28.3 @@ -21989,7 +22032,7 @@ snapshots: '@clack/prompts': 0.8.2 '@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@preact/signals': 1.3.2(preact@10.27.2) - '@rollup/pluginutils': 5.3.0(rollup@2.79.2) + '@rollup/pluginutils': 5.3.0(rollup@4.52.5) '@types/node': 20.19.24 bippy: 0.3.27(@types/react@19.2.2)(react@19.2.0) esbuild: 0.25.11 @@ -22848,14 +22891,6 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.2.0): - dependencies: - client-only: 0.0.1 - react: 19.2.0 - optionalDependencies: - '@babel/core': 7.28.4 - optional: true - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0): dependencies: client-only: 0.0.1 @@ -23285,7 +23320,7 @@ snapshots: magic-string-ast: 1.0.3 unplugin: 2.3.10 - unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.2))(esbuild@0.25.11)(rolldown@1.0.0-beta.45)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): + unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.2))(esbuild@0.25.11)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.52.5) '@volar/typescript': 2.4.23 @@ -23299,7 +23334,6 @@ snapshots: optionalDependencies: '@microsoft/api-extractor': 7.52.13(@types/node@24.9.2) esbuild: 0.25.11 - rolldown: 1.0.0-beta.45 rollup: 4.52.5 vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: