feat: implement site settings management and onboarding enhancements

- Added site settings management with a dedicated UI for configuration.
- Introduced new onboarding steps for site branding, including site name, title, and description.
- Updated API endpoints for site settings retrieval and updates.
- Enhanced the onboarding wizard to include site settings integration.
- Refactored related components and hooks for better state management and validation.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-07 23:58:14 +08:00
parent 89f2825a1b
commit 1dcb2945ca
61 changed files with 2904 additions and 881 deletions

View File

@@ -1,3 +1,4 @@
export interface InjectConfig {
useApi: boolean
useCloud: boolean
}

View File

@@ -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<ErrorCode, ErrorDescriptor> = {
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',

View File

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

View File

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

View File

@@ -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<string> {
@@ -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<RegisterTenantAccountInput>,
tenantInput: RegisterTenantInput['tenant'],
headers: Headers,
settings?: RegisterTenantInput['settings'],
): Promise<RegisterTenantResult> {
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, SettingDefinition>
export const DEFAULT_SETTING_METADATA = Object.fromEntries(

View File

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

View File

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

View File

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

View File

@@ -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<SiteSettingUiSchemaResponse> {
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<SiteSettingUiSchemaResponse> {
const allowedKeys = new Set<SiteSettingKey>(ONBOARDING_SITE_SETTING_KEYS)
const schema = this.filterSchema(SITE_SETTING_UI_SCHEMA, allowedKeys)
return {
schema,
values: {},
}
}
async setMany(entries: readonly SiteSettingEntryInput[]): Promise<void> {
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<SiteSettingKey>,
): SiteSettingUiSchemaResponse['schema'] {
const filterNodes = (nodes: ReadonlyArray<UiNode<SiteSettingKey>>): Array<UiNode<SiteSettingKey>> => {
const filtered: Array<UiNode<SiteSettingKey>> = []
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'],
}
}
}

View File

@@ -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<SettingEntryInput, { key: SiteSettingKey }>
export interface SiteSettingUiSchemaResponse {
readonly schema: UiSchema<SiteSettingKey>
readonly values: Partial<Record<SiteSettingKey, string | null>>
}

View File

@@ -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<SiteSettingKey> = {
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<UiNode<SiteSettingKey>>): 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[]

View File

@@ -2,9 +2,11 @@
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="description" content="Afilmory Dashboard for managing your gallery" />
<meta name="favicon" content="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<title>Afilmory Dashboard</title>
</head>
<body>
<div id="root"></div>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@@ -14,6 +14,10 @@ export interface RegisterTenantPayload {
name: string
slug: string | null
}
settings?: Array<{
key: string
value: unknown
}>
}
export type RegisterTenantResult = FetchResponse<unknown>

View File

@@ -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<keyof TenantRegistrationFormState>> = {
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<SidebarProps> = ({ currentStepIndex, canNavigateTo, onStepSelect }) => (
<aside className="hidden min-h-full flex-col gap-6 p-6 lg:flex">
<div>
<p className="text-accent text-xs font-medium">Workspace Setup</p>
<h2 className="text-text mt-2 text-base font-semibold">Create your tenant</h2>
</div>
<div className="relative flex-1">
{REGISTRATION_STEPS.map((step, index) => {
const status: 'done' | 'current' | 'pending' =
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : 'pending'
const isLast = index === REGISTRATION_STEPS.length - 1
const isClickable = canNavigateTo(index)
return (
<div key={step.id} className="relative flex gap-3">
{!isLast && (
<div className="absolute top-7 bottom-0 left-[13px] w-[1.5px]">
{status === 'done' && <div className="bg-accent h-full w-full" />}
{status === 'current' && (
<div
className="h-full w-full"
style={{
background:
'linear-gradient(to bottom, var(--color-accent) 0%, var(--color-accent) 35%, color-mix(in srgb, var(--color-text) 15%, transparent) 100%)',
}}
/>
)}
{status === 'pending' && <div className="bg-text/15 h-full w-full" />}
</div>
)}
<button
type="button"
className={cx(
'group relative flex w-full items-start gap-3 pb-6 text-left transition-all duration-200',
isClickable ? 'cursor-pointer' : 'cursor-default',
!isClickable && 'opacity-60',
)}
onClick={() => {
if (isClickable) onStepSelect(index)
}}
disabled={!isClickable}
>
<div className="relative z-10 shrink-0 pt-0.5">
<div
className={cx(
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200',
status === 'done' && 'bg-accent text-white ring-4 ring-accent/10',
status === 'current' && 'bg-accent text-white ring-4 ring-accent/25',
status === 'pending' && 'border-[1.5px] border-text/20 bg-background text-text-tertiary',
)}
>
{status === 'done' ? <i className="i-mingcute-check-fill text-sm" /> : <span>{index + 1}</span>}
</div>
</div>
<div className="min-w-0 flex-1 pt-0.5">
<p
className={cx(
'text-sm font-medium transition-colors duration-200',
status === 'done' && 'text-text',
status === 'current' && 'text-accent',
status === 'pending' && 'text-text-tertiary',
isClickable && status !== 'current' && 'group-hover:text-text',
)}
>
{step.title}
</p>
<p
className={cx(
'mt-0.5 text-xs transition-colors duration-200',
status === 'done' && 'text-text-secondary',
status === 'current' && 'text-text-secondary',
status === 'pending' && 'text-text-tertiary',
)}
>
{step.description}
</p>
</div>
</button>
</div>
)
})}
</div>
<div className="pt-4">
<div className="via-text/20 mb-4 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="text-text-tertiary mb-2 flex items-center justify-between text-xs">
<span>Progress</span>
<span className="text-accent font-medium">{progressForStep(currentStepIndex)}%</span>
</div>
<div className="bg-fill-tertiary relative h-1.5 overflow-hidden rounded-full">
<div
className="bg-accent absolute top-0 left-0 h-full transition-all duration-500 ease-out"
style={{ width: `${progressForStep(currentStepIndex)}%` }}
/>
</div>
</div>
</aside>
)
type HeaderProps = {
currentStepIndex: number
}
const RegistrationHeader: FC<HeaderProps> = ({ currentStepIndex }) => {
const step = REGISTRATION_STEPS[currentStepIndex]
return (
<header className="p-8 pb-6">
<div className="bg-accent/10 text-accent inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium">
Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length}
</div>
<h1 className="text-text mt-4 text-3xl font-bold">{step.title}</h1>
<p className="text-text-secondary mt-2 max-w-2xl text-sm">{step.description}</p>
</header>
)
}
type FooterProps = {
disableBack: boolean
isSubmitting: boolean
isLastStep: boolean
onBack: () => void
onNext: () => void
}
const RegistrationFooter: FC<FooterProps> = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => (
<footer className="flex flex-col gap-3 p-8 pt-6 sm:flex-row sm:items-center sm:justify-between">
{!disableBack ? (
<div className="text-text-tertiary text-xs">
Adjustments are always possibleuse the sidebar or go back to modify earlier details.
</div>
) : (
<div />
)}
<div className="flex gap-2">
{!disableBack && (
<Button
type="button"
variant="ghost"
size="md"
className="text-text-secondary hover:text-text hover:bg-fill/50 min-w-[140px]"
onClick={onBack}
disabled={isSubmitting}
>
Back
</Button>
)}
<Button type="button" variant="primary" size="md" className="min-w-40" onClick={onNext} isLoading={isSubmitting}>
{isLastStep ? 'Create workspace' : 'Continue'}
</Button>
</div>
</footer>
)
type StepCommonProps = {
values: TenantRegistrationFormState
errors: Partial<Record<keyof TenantRegistrationFormState, string>>
onFieldChange: <Field extends keyof TenantRegistrationFormState>(
field: Field,
value: TenantRegistrationFormState[Field],
) => void
isLoading: boolean
}
const WorkspaceStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoading }) => (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Workspace basics</h2>
<p className="text-text-secondary text-sm">
This information appears in navigation, invitations, and other tenant-facing areas.
</p>
</section>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-name">Workspace name</Label>
<Input
id="tenant-name"
value={values.tenantName}
onChange={(event) => onFieldChange('tenantName', event.currentTarget.value)}
placeholder="Acme Studio"
disabled={isLoading}
error={Boolean(errors.tenantName)}
autoComplete="organization"
/>
<FormError>{errors.tenantName}</FormError>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Workspace slug</Label>
<Input
id="tenant-slug"
value={values.tenantSlug}
onChange={(event) => onFieldChange('tenantSlug', event.currentTarget.value)}
placeholder="acme"
disabled={isLoading}
error={Boolean(errors.tenantSlug)}
autoComplete="off"
/>
<p className="text-text-tertiary text-xs">
Lowercase letters, numbers, and hyphen are allowed. We&apos;ll ensure the slug is unique.
</p>
<FormError>{errors.tenantSlug}</FormError>
</div>
</div>
</div>
)
const AdminStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoading }) => (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Administrator</h2>
<p className="text-text-secondary text-sm">
The first user becomes the workspace administrator and can invite additional members later.
</p>
</section>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="account-name">Full name</Label>
<Input
id="account-name"
value={values.accountName}
onChange={(event) => onFieldChange('accountName', event.currentTarget.value)}
placeholder="Jane Doe"
disabled={isLoading}
error={Boolean(errors.accountName)}
autoComplete="name"
/>
<FormError>{errors.accountName}</FormError>
</div>
<div className="space-y-2">
<Label htmlFor="account-email">Work email</Label>
<Input
id="account-email"
type="email"
value={values.email}
onChange={(event) => onFieldChange('email', event.currentTarget.value)}
placeholder="jane@acme.studio"
disabled={isLoading}
error={Boolean(errors.email)}
autoComplete="email"
/>
<FormError>{errors.email}</FormError>
</div>
<div className="space-y-2">
<Label htmlFor="account-password">Password</Label>
<Input
id="account-password"
type="password"
value={values.password}
onChange={(event) => onFieldChange('password', event.currentTarget.value)}
placeholder="Create a strong password"
disabled={isLoading}
error={Boolean(errors.password)}
autoComplete="new-password"
/>
<FormError>{errors.password}</FormError>
</div>
<div className="space-y-2">
<Label htmlFor="account-confirm-password">Confirm password</Label>
<Input
id="account-confirm-password"
type="password"
value={values.confirmPassword}
onChange={(event) => onFieldChange('confirmPassword', event.currentTarget.value)}
placeholder="Repeat your password"
disabled={isLoading}
error={Boolean(errors.confirmPassword)}
autoComplete="new-password"
/>
<FormError>{errors.confirmPassword}</FormError>
</div>
</div>
<p className="text-text-tertiary text-xs">
We recommend using a secure password manager to store credentials for critical roles like the administrator.
</p>
<SocialAuthButtons
className="border border-white/5 bg-fill/40 rounded-2xl p-4"
title="Or use single sign-on"
requestSignUp
/>
</div>
)
type ReviewStepProps = Omit<StepCommonProps, 'onFieldChange'> & {
onToggleTerms: (value: boolean) => void
serverError: string | null
}
const ReviewStep: FC<ReviewStepProps> = ({ values, errors, onToggleTerms, isLoading, serverError }) => (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Confirm workspace configuration</h2>
<p className="text-text-secondary text-sm">
Double-check the details below. You can go back to make adjustments before creating the workspace.
</p>
</section>
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace name</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.tenantName || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace slug</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.tenantSlug || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator name</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.accountName || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator email</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.email || '—'}</dd>
</div>
</dl>
{serverError && (
<m.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={Spring.presets.snappy}
className="border-red/60 bg-red/10 rounded-xl border px-4 py-3"
>
<p className="text-red text-sm">{serverError}</p>
</m.div>
)}
<section className="space-y-3">
<h3 className="text-text text-base font-semibold">Policies</h3>
<p className="text-text-tertiary text-sm">
Creating a workspace means you agree to comply with our usage guidelines and privacy practices.
</p>
<div className="space-y-2">
<label className="text-text flex items-center gap-3 text-sm">
<Checkbox
checked={values.termsAccepted}
onCheckedChange={(checked) => onToggleTerms(checked === true)}
disabled={isLoading}
className="mt-0.5"
/>
<span className="text-text-secondary">
I agree to the{' '}
<a href="/terms" target="_blank" rel="noreferrer" className="text-accent hover:underline">
Terms of Service
</a>{' '}
and{' '}
<a href="/privacy" target="_blank" rel="noreferrer" className="text-accent hover:underline">
Privacy Policy
</a>
.
</span>
</label>
<FormError>{errors.termsAccepted}</FormError>
</div>
</section>
</div>
)
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<HTMLElement | null>(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<HTMLElement>(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 extends keyof TenantRegistrationFormState>(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 <WorkspaceStep values={values} errors={errors} onFieldChange={handleFieldChange} isLoading={isLoading} />
}
case 'admin': {
return <AdminStep values={values} errors={errors} onFieldChange={handleFieldChange} isLoading={isLoading} />
}
case 'review': {
return (
<ReviewStep
values={values}
errors={errors}
onToggleTerms={(accepted) => handleFieldChange('termsAccepted', accepted)}
isLoading={isLoading}
serverError={error}
/>
)
}
default: {
return null
}
}
}, [currentStepIndex, error, errors, handleFieldChange, isLoading, values])
const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1
return (
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4 py-10">
<LinearBorderContainer className="bg-background-tertiary h-[85vh] w-full max-w-5xl">
<div className="grid h-full lg:grid-cols-[280px_1fr]">
<div className="relative h-full">
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
<RegistrationSidebar
currentStepIndex={currentStepIndex}
canNavigateTo={canNavigateTo}
onStepSelect={jumpToStep}
/>
</div>
<main className="flex h-full w-[700px] flex-col">
<div className="shrink-0">
<RegistrationHeader currentStepIndex={currentStepIndex} />
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
</div>
<div className="relative flex h-0 flex-1">
<ScrollArea rootClassName="absolute! inset-0 h-full w-full">
<section ref={contentRef} className="p-12" onKeyDown={handleKeyDown}>
{StepComponent}
</section>
</ScrollArea>
</div>
<div className="shrink-0">
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<RegistrationFooter
disableBack={currentStepIndex === 0}
isSubmitting={isLoading}
isLastStep={isLastStep}
onBack={handleBack}
onNext={handleNext}
/>
</div>
</main>
</div>
</LinearBorderContainer>
<p className="text-text-tertiary mt-6 text-sm">
Already have an account?{' '}
<Link to="/login" className="text-accent hover:underline">
Sign in
</Link>
.
</p>
</div>
)
}
export { RegistrationWizard } from './registration-wizard/RegistrationWizard'

View File

@@ -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<FooterProps> = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => (
<footer className="flex flex-col gap-3 p-8 pt-6 sm:flex-row sm:items-center sm:justify-between">
<div />
<div className="flex gap-2">
{!disableBack && (
<Button
type="button"
variant="ghost"
size="md"
className="text-text-secondary hover:text-text hover:bg-fill/50 min-w-[140px]"
onClick={onBack}
disabled={isSubmitting}
>
Back
</Button>
)}
<Button type="button" variant="primary" size="md" className="min-w-40" onClick={onNext} isLoading={isSubmitting}>
{isLastStep ? 'Create workspace' : 'Continue'}
</Button>
</div>
</footer>
)

View File

@@ -0,0 +1,20 @@
import type { FC } from 'react'
import { REGISTRATION_STEPS } from './constants'
type HeaderProps = {
currentStepIndex: number
}
export const RegistrationHeader: FC<HeaderProps> = ({ currentStepIndex }) => {
const step = REGISTRATION_STEPS[currentStepIndex]
return (
<header className="p-8 pb-6">
<div className="bg-accent/10 text-accent inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium">
Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length}
</div>
<h1 className="text-text mt-4 text-3xl font-bold">{step.title}</h1>
<p className="text-text-secondary mt-2 max-w-2xl text-sm">{step.description}</p>
</header>
)
}

View File

@@ -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<SidebarProps> = ({ currentStepIndex, canNavigateTo, onStepSelect }) => (
<aside className="hidden min-h-full flex-col gap-6 p-6 lg:flex">
<div>
<p className="text-accent text-xs font-medium">Workspace Setup</p>
<h2 className="text-text mt-2 text-base font-semibold">Create your tenant</h2>
</div>
<div className="relative flex-1">
{REGISTRATION_STEPS.map((step, index) => {
const status: 'done' | 'current' | 'pending' =
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : 'pending'
const isLast = index === REGISTRATION_STEPS.length - 1
const isClickable = canNavigateTo(index)
return (
<div key={step.id} className="relative flex gap-3">
{!isLast && (
<div className="absolute top-7 bottom-0 left-[13px] w-[1.5px]">
{status === 'done' && <div className="bg-accent h-full w-full" />}
{status === 'current' && (
<div
className="h-full w-full"
style={{
background:
'linear-gradient(to bottom, var(--color-accent) 0%, var(--color-accent) 35%, color-mix(in srgb, var(--color-text) 15%, transparent) 100%)',
}}
/>
)}
{status === 'pending' && <div className="bg-text/15 h-full w-full" />}
</div>
)}
<button
type="button"
className={cx(
'group relative flex w-full items-start gap-3 pb-6 text-left transition-all duration-200',
isClickable ? 'cursor-pointer' : 'cursor-default',
!isClickable && 'opacity-60',
)}
onClick={() => {
if (isClickable) onStepSelect(index)
}}
disabled={!isClickable}
>
<div className="relative z-10 shrink-0 pt-0.5">
<div
className={cx(
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200',
status === 'done' && 'bg-accent text-white ring-4 ring-accent/10',
status === 'current' && 'bg-accent text-white ring-4 ring-accent/25',
status === 'pending' && 'border-[1.5px] border-text/20 bg-background text-text-tertiary',
)}
>
{status === 'done' ? <i className="i-mingcute-check-fill text-sm" /> : <span>{index + 1}</span>}
</div>
</div>
<div className="min-w-0 flex-1 pt-0.5">
<p
className={cx(
'text-sm font-medium transition-colors duration-200',
status === 'done' && 'text-text',
status === 'current' && 'text-accent',
status === 'pending' && 'text-text-tertiary',
isClickable && status !== 'current' && 'group-hover:text-text',
)}
>
{step.title}
</p>
<p
className={cx(
'mt-0.5 text-xs transition-colors duration-200',
status === 'done' && 'text-text-secondary',
status === 'current' && 'text-text-secondary',
status === 'pending' && 'text-text-tertiary',
)}
>
{step.description}
</p>
</div>
</button>
</div>
)
})}
</div>
<div className="pt-4">
<div className="via-text/20 mb-4 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="text-text-tertiary mb-2 flex items-center justify-between text-xs">
<span>Progress</span>
<span className="text-accent font-medium">{progressForStep(currentStepIndex)}%</span>
</div>
<div className="bg-fill-tertiary relative h-1.5 overflow-hidden rounded-full">
<div
className="bg-accent absolute top-0 left-0 h-full transition-all duration-500 ease-out"
style={{ width: `${progressForStep(currentStepIndex)}%` }}
/>
</div>
</div>
</aside>
)

View File

@@ -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<HTMLElement | null>(null)
const slugManuallyEditedRef = useRef(false)
const siteDefaultsAppliedRef = useRef(false)
const siteSchemaQuery = useQuery({
queryKey: ['onboarding', 'site-schema'],
queryFn: getOnboardingSiteSchema,
staleTime: Infinity,
})
const [siteSchema, setSiteSchema] = useState<UiSchema<TenantSiteFieldKey> | null>(null)
useEffect(() => {
const data = siteSchemaQuery.data as
| {
schema?: UiSchema<TenantSiteFieldKey>
values?: Partial<Record<TenantSiteFieldKey, SchemaFormValue>>
}
| 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<TenantSiteFieldKey>
}
| 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<keyof TenantRegistrationFormState>
}
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<HTMLElement>(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<string, string> = {}
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 (
<WorkspaceStep
form={form}
slugManuallyEditedRef={slugManuallyEditedRef}
isSubmitting={isLoading}
onFieldInteraction={onFieldInteraction}
/>
)
}
case 'site': {
return (
<SiteSettingsStep
form={form}
schema={siteSchema}
isLoading={siteSchemaLoading}
errorMessage={siteSchemaErrorMessage}
values={formValues}
errors={siteFieldErrors}
onFieldInteraction={onFieldInteraction}
/>
)
}
case 'admin': {
return <AdminStep form={form} isSubmitting={isLoading} onFieldInteraction={onFieldInteraction} />
}
case 'review': {
return (
<ReviewStep
form={form}
values={formValues}
siteSchema={siteSchema}
siteSchemaLoading={siteSchemaLoading}
siteSchemaError={siteSchemaErrorMessage}
isSubmitting={isLoading}
serverError={error}
onFieldInteraction={onFieldInteraction}
/>
)
}
default: {
return null
}
}
}, [
currentStepIndex,
error,
form,
formValues,
isLoading,
onFieldInteraction,
siteFieldErrors,
siteSchema,
siteSchemaErrorMessage,
siteSchemaLoading,
])
const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1
return (
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4 py-10">
<LinearBorderContainer className="bg-background-tertiary h-[85vh] w-full max-w-5xl">
<div className="grid h-full lg:grid-cols-[280px_1fr]">
<div className="relative h-full">
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
<RegistrationSidebar
currentStepIndex={currentStepIndex}
canNavigateTo={canNavigateTo}
onStepSelect={jumpToStep}
/>
</div>
<main className="flex h-full w-[700px] flex-col">
<div className="shrink-0">
<RegistrationHeader currentStepIndex={currentStepIndex} />
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
</div>
<div className="relative flex h-0 flex-1">
<ScrollArea rootClassName="absolute! inset-0 h-full w-full">
<section ref={contentRef} className="p-12" onKeyDown={handleKeyDown}>
{StepComponent}
</section>
</ScrollArea>
</div>
<div className="shrink-0">
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<RegistrationFooter
disableBack={currentStepIndex === 0}
isSubmitting={isLoading}
isLastStep={isLastStep}
onBack={handleBack}
onNext={() => {
void handleNext()
}}
/>
</div>
</main>
</div>
</LinearBorderContainer>
<p className="text-text-tertiary mt-6 text-sm">
Already have an account?{' '}
<Link to="/login" className="text-accent hover:underline">
Sign in
</Link>
.
</p>
</div>
)
}

View File

@@ -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<RegistrationStepId, Array<keyof TenantRegistrationFormState>> = {
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)

View File

@@ -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<typeof useRegistrationForm>
isSubmitting: boolean
onFieldInteraction: () => void
}
export const AdminStep: FC<AdminStepProps> = ({ form, isSubmitting, onFieldInteraction }) => (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Administrator</h2>
<p className="text-text-secondary text-sm">
The first user becomes the workspace administrator and can invite additional members later.
</p>
</section>
<div className="grid gap-6 md:grid-cols-2">
<form.Field name="accountName">
{(field) => {
const error = firstErrorMessage(field.state.meta.errors)
return (
<div className="space-y-2">
<Label htmlFor={field.name}>Full name</Label>
<Input
id={field.name}
value={field.state.value}
onChange={(event) => {
onFieldInteraction()
field.handleChange(event.currentTarget.value)
}}
onBlur={field.handleBlur}
placeholder="Jane Doe"
disabled={isSubmitting}
error={Boolean(error)}
autoComplete="name"
/>
<FormError>{error}</FormError>
</div>
)
}}
</form.Field>
<form.Field name="email">
{(field) => {
const error = firstErrorMessage(field.state.meta.errors)
return (
<div className="space-y-2">
<Label htmlFor={field.name}>Work email</Label>
<Input
id={field.name}
type="email"
value={field.state.value}
onChange={(event) => {
onFieldInteraction()
field.handleChange(event.currentTarget.value)
}}
onBlur={field.handleBlur}
placeholder="jane@acme.studio"
disabled={isSubmitting}
error={Boolean(error)}
autoComplete="email"
/>
<FormError>{error}</FormError>
</div>
)
}}
</form.Field>
<form.Field name="password">
{(field) => {
const error = firstErrorMessage(field.state.meta.errors)
return (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
type="password"
value={field.state.value}
onChange={(event) => {
onFieldInteraction()
field.handleChange(event.currentTarget.value)
}}
onBlur={field.handleBlur}
placeholder="Create a strong password"
disabled={isSubmitting}
error={Boolean(error)}
autoComplete="new-password"
/>
<FormError>{error}</FormError>
</div>
)
}}
</form.Field>
<form.Field name="confirmPassword">
{(field) => {
const error = firstErrorMessage(field.state.meta.errors)
return (
<div className="space-y-2">
<Label htmlFor={field.name}>Confirm password</Label>
<Input
id={field.name}
type="password"
value={field.state.value}
onChange={(event) => {
onFieldInteraction()
field.handleChange(event.currentTarget.value)
}}
onBlur={field.handleBlur}
placeholder="Repeat your password"
disabled={isSubmitting}
error={Boolean(error)}
autoComplete="new-password"
/>
<FormError>{error}</FormError>
</div>
)
}}
</form.Field>
</div>
<p className="text-text-tertiary text-xs">
We recommend using a secure password manager to store credentials for critical roles like the administrator.
</p>
<SocialAuthButtons
className="border border-white/5 bg-fill/40 rounded-2xl p-4"
title="Or use single sign-on"
requestSignUp
/>
</div>
)

View File

@@ -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<typeof useRegistrationForm>
values: TenantRegistrationFormState
siteSchema: UiSchema<string> | null
siteSchemaLoading: boolean
siteSchemaError?: string
isSubmitting: boolean
serverError: string | null
onFieldInteraction: () => void
}
export const ReviewStep: FC<ReviewStepProps> = ({
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<string>; value: SchemaFormValue | undefined }>
}
return collectSiteFields(siteSchema.sections).map((field) => {
const key = field.key as TenantSiteFieldKey
return {
field,
value: values[key],
}
})
}, [siteSchema, values])
return (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Confirm workspace configuration</h2>
<p className="text-text-secondary text-sm">
Double-check the details below. You can go back to make adjustments before creating the workspace.
</p>
</section>
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace name</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.tenantName || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace slug</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.tenantSlug || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator name</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.accountName || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator email</dt>
<dd className="text-text mt-1 text-sm font-medium">{values.email || '—'}</dd>
</div>
</dl>
<section className="space-y-4">
<h3 className="text-text text-base font-semibold">Site details</h3>
{siteSchemaLoading && <div className="bg-fill/40 border border-white/5 h-32 animate-pulse rounded-2xl" />}
{!siteSchemaLoading && siteSchemaError && (
<div className="border-red/60 bg-red/10 rounded-2xl border px-4 py-3 text-sm text-red">{siteSchemaError}</div>
)}
{!siteSchemaLoading && !siteSchemaError && siteSchema && (
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
{siteSummary.map(({ field, value }) => {
const spanClass = field.component?.type === 'textarea' ? 'md:col-span-2' : ''
const isMono = field.key === 'site.accentColor'
return (
<div key={field.id} className={cx(spanClass, 'min-w-0')}>
<dt className="text-text-tertiary text-xs uppercase tracking-wide">{field.title}</dt>
<dd
className={cx(
'text-text mt-1 text-sm font-medium wrap-break-word',
isMono && 'font-mono text-xs tracking-wide text-text-secondary',
)}
>
{formatSiteValue(value)}
</dd>
</div>
)
})}
</dl>
)}
</section>
{serverError && (
<m.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={Spring.presets.snappy}
className="border-red/60 bg-red/10 rounded-xl border px-4 py-3"
>
<p className="text-red text-sm">{serverError}</p>
</m.div>
)}
<section className="space-y-3">
<h3 className="text-text text-base font-semibold">Policies</h3>
<p className="text-text-tertiary text-sm">
Creating a workspace means you agree to comply with our usage guidelines and privacy practices.
</p>
<div className="space-y-2">
<form.Field name="termsAccepted">
{(field) => {
const error = firstErrorMessage(field.state.meta.errors)
return (
<>
<label className="text-text flex items-center gap-3 text-sm">
<Checkbox
checked={field.state.value}
onCheckedChange={(checked) => {
onFieldInteraction()
field.handleChange(checked === true)
}}
disabled={isSubmitting}
className="mt-0.5"
/>
<span className="text-text-secondary">
I agree to the{' '}
<a href="/terms" target="_blank" rel="noreferrer" className="text-accent hover:underline">
Terms of Service
</a>{' '}
and{' '}
<a href="/privacy" target="_blank" rel="noreferrer" className="text-accent hover:underline">
Privacy Policy
</a>
.
</span>
</label>
<FormError>{error}</FormError>
</>
)
}}
</form.Field>
</div>
</section>
</div>
)
}

View File

@@ -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<typeof useRegistrationForm>
schema: UiSchema<TenantSiteFieldKey> | null
isLoading: boolean
errorMessage?: string
values: TenantRegistrationFormState
errors: Record<string, string>
onFieldInteraction: () => void
}
export const SiteSettingsStep: FC<SiteSettingsStepProps> = ({
form,
schema,
isLoading,
errorMessage,
values,
errors,
}) => {
if (!schema) {
if (isLoading) {
return (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Site branding</h2>
<p className="text-text-secondary text-sm">
These details appear on your public gallery, metadata, and social sharing cards. You can change them later
from the dashboard.
</p>
</section>
<div className="bg-fill/40 border border-white/5 h-56 animate-pulse rounded-2xl" />
</div>
)
}
return (
<div className="space-y-6">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Site branding</h2>
<p className="text-text-secondary text-sm">
We couldn&apos;t load the site configuration schema from the server. Refresh the page or contact support.
</p>
</section>
{errorMessage && (
<div className="border-red/50 bg-red/10 rounded-xl border px-4 py-3 text-sm text-red">{errorMessage}</div>
)}
</div>
)
}
return (
<div className="space-y-8 -mx-6 -mt-12">
{errorMessage && (
<div className="border-red/50 bg-red/10 rounded-xl border px-4 py-3 text-sm text-red">{errorMessage}</div>
)}
<SiteStep
schema={schema}
values={values as SchemaFormState<TenantSiteFieldKey>}
errors={errors}
onFieldChange={(key, value) => {
form.state.values[key] = value
}}
/>
</div>
)
}

View File

@@ -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<typeof useRegistrationForm>
slugManuallyEditedRef: MutableRefObject<boolean>
isSubmitting: boolean
onFieldInteraction: () => void
}
export const WorkspaceStep: FC<WorkspaceStepProps> = ({
form,
slugManuallyEditedRef,
isSubmitting,
onFieldInteraction,
}) => (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">Workspace basics</h2>
<p className="text-text-secondary text-sm">
This information appears in navigation, invitations, and other tenant-facing areas.
</p>
</section>
<div className="grid gap-6 md:grid-cols-2">
<form.Field name="tenantName">
{(field) => {
const error = firstErrorMessage(field.state.meta.errors)
return (
<div className="space-y-2">
<Label htmlFor={field.name}>Workspace name</Label>
<Input
id={field.name}
value={field.state.value}
onChange={(event) => {
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"
/>
<FormError>{error}</FormError>
</div>
)
}}
</form.Field>
<form.Field name="tenantSlug">
{(field) => {
const error = firstErrorMessage(field.state.meta.errors)
return (
<div className="space-y-2">
<Label htmlFor={field.name}>Workspace slug</Label>
<Input
id={field.name}
value={field.state.value}
onChange={(event) => {
onFieldInteraction()
slugManuallyEditedRef.current = true
field.handleChange(event.currentTarget.value)
}}
onBlur={field.handleBlur}
placeholder="acme"
disabled={isSubmitting}
error={Boolean(error)}
autoComplete="off"
/>
<p className="text-text-tertiary text-xs">
Lowercase letters, numbers, and hyphen are allowed. We&apos;ll ensure the slug is unique.
</p>
<FormError>{error}</FormError>
</div>
)
}}
</form.Field>
</div>
</div>
)

View File

@@ -0,0 +1,32 @@
import type { UiFieldNode, UiNode } from '~/modules/schema-form/types'
export const collectSiteFields = (nodes: ReadonlyArray<UiNode<string>>): Array<UiFieldNode<string>> => {
const fields: Array<UiFieldNode<string>> = []
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<unknown>): 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<string, unknown>)) {
return String((first as Record<string, unknown>).message)
}
try {
return JSON.stringify(first)
} catch {
return String(first)
}
}

View File

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

View File

@@ -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<keyof TenantRegistrationFormState> = [
'tenantName',
'tenantSlug',
'accountName',
'email',
'password',
'confirmPassword',
'termsAccepted',
]
export function useRegistrationForm(initial?: Partial<TenantRegistrationFormState>) {
const [values, setValues] = useState<TenantRegistrationFormState>({
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>,
): 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<TenantRegistrationFormStat
password: initial?.password ?? '',
confirmPassword: initial?.confirmPassword ?? '',
termsAccepted: initial?.termsAccepted ?? false,
})
const [errors, setErrors] = useState<Partial<Record<keyof TenantRegistrationFormState, string>>>({})
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
const updateValue = <K extends keyof TenantRegistrationFormState>(
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<keyof TenantRegistrationFormState>) => {
const fieldsToValidate = fields ?? ALL_FIELDS
const stepErrors: Partial<Record<keyof TenantRegistrationFormState, string>> = {}
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<string, string> {
const result = tenantRegistrationSchema.safeParse(values)
if (result.success) {
return {}
}
const fieldErrors: Record<string, string> = {}
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<TenantRegistrationFormState>) {
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
},
},
})
}

View File

@@ -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<OnboardingInitResponse>('/onboarding/init', {
method: 'POST',

View File

@@ -30,14 +30,14 @@ export const LinearBorderContainer: FC<LinearBorderContainerProps> = ({
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

View File

@@ -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 <InitializedState />
}
if (siteSchemaLoading || !siteSchema) {
return <LoadingState />
}
if (siteSchemaError) {
return <ErrorState />
}
const stepContent: Record<typeof currentStep.id, ReactNode> = {
welcome: <WelcomeStep />,
tenant: (
<TenantStep tenant={tenant} errors={errors} onNameChange={updateTenantName} onSlugChange={updateTenantSlug} />
),
admin: <AdminStep admin={admin} errors={errors} onChange={updateAdminField} />,
site: <SiteStep schema={siteSchema} values={site} errors={errors} onFieldChange={updateSiteField} />,
settings: (
<SettingsStep
settingsState={settingsState}
@@ -135,6 +150,10 @@ export const OnboardingWizard: FC = () => {
<ReviewStep
tenant={tenant}
admin={admin}
site={site}
siteSchema={siteSchema}
siteSchemaLoading={false}
siteSchemaError={null}
reviewSettings={reviewSettings}
acknowledged={acknowledged}
errors={errors}

View File

@@ -1,8 +1,10 @@
import { Checkbox } from '@afilmory/ui'
import type { FC } from 'react'
import type { SettingFieldDefinition } from '../../constants'
import type { AdminFormState, OnboardingErrors, TenantFormState } from '../../types'
import type { SchemaFormValue, UiFieldNode, UiNode, UiSchema } from '~/modules/schema-form/types'
import type { OnboardingSiteSettingKey, SettingFieldDefinition } from '../../constants'
import type { AdminFormState, OnboardingErrors, SiteFormState, TenantFormState } from '../../types'
import { maskSecret } from '../../utils'
export type ReviewSettingEntry = {
@@ -13,15 +15,63 @@ export type ReviewSettingEntry = {
type ReviewStepProps = {
tenant: TenantFormState
admin: AdminFormState
site: SiteFormState
siteSchema: UiSchema<OnboardingSiteSettingKey>
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<UiNode<OnboardingSiteSettingKey>>,
): Array<UiFieldNode<OnboardingSiteSettingKey>> {
const fields: Array<UiFieldNode<OnboardingSiteSettingKey>> = []
for (const node of nodes) {
if (node.type === 'field') {
fields.push(node)
continue
}
fields.push(...collectSiteFields(node.children))
}
return fields
}
export const ReviewStep: FC<ReviewStepProps> = ({
tenant,
admin,
site,
siteSchema,
siteSchemaLoading = false,
siteSchemaError = null,
reviewSettings,
acknowledged,
errors,
@@ -60,6 +110,29 @@ export const ReviewStep: FC<ReviewStepProps> = ({
</dl>
</div>
<div className="border-fill-tertiary bg-background rounded-lg border p-6">
<h3 className="text-text mb-4 text-sm font-semibold">Site information</h3>
{siteSchemaLoading && <div className="bg-fill/60 border border-white/5 h-24 animate-pulse rounded-xl" />}
{!siteSchemaLoading && siteSchemaError && (
<div className="border-red/60 bg-red/10 mt-2 rounded-xl border px-4 py-3 text-sm text-red">
{siteSchemaError}
</div>
)}
{!siteSchemaLoading && !siteSchemaError && (
<dl className="text-text-secondary grid gap-4 text-sm md:grid-cols-2">
{collectSiteFields(siteSchema.sections).map((field) => {
const spanClass = field.component?.type === 'textarea' ? 'md:col-span-2' : ''
return (
<div key={field.id} className={`${spanClass} min-w-0`}>
<dt className="text-text font-semibold">{field.title}</dt>
<dd className="mt-1 leading-relaxed break-words">{optionalSiteValue(site[field.key])}</dd>
</div>
)
})}
</dl>
)}
</div>
<div className="border-fill-tertiary bg-background rounded-lg border p-6">
<h3 className="text-text mb-4 text-sm font-semibold">Enabled integrations</h3>
{reviewSettings.length === 0 ? (

View File

@@ -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<OnboardingSiteSettingKey>
values: SiteFormState
errors: Record<string, string>
onFieldChange: (key: OnboardingSiteSettingKey, value: string | boolean) => void
}
export const SiteStep: FC<SiteStepProps> = ({ 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 (
<div className="space-y-6">
<SchemaFormRendererUncontrolled
initialValues={values}
schema={schemaWithErrors}
onChange={(key: OnboardingSiteSettingKey, value: SchemaFormValue) => {
onFieldChange(key, typeof value === 'boolean' ? value : value == null ? '' : String(value))
}}
/>
</div>
)
}

View File

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

View File

@@ -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<SettingFormState>(createInitialSettingsState)
const [site, setSite] = useState<SiteFormState>(() => ({ ...DEFAULT_SITE_SETTINGS_VALUES }))
const [acknowledged, setAcknowledged] = useState(false)
const [errors, setErrors] = useState<OnboardingErrors>({})
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<OnboardingSiteSettingKey>
values?: Partial<Record<OnboardingSiteSettingKey, unknown>>
}
| 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<string, unknown> = {}
for (const key of SITE_SETTINGS_KEYS) {
candidate[key] = site[key] ?? DEFAULT_SITE_SETTINGS_VALUES[key]
}
const result = siteSettingsSchema.safeParse(candidate)
const fieldErrors: Record<string, string> = {}
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<Record<OnboardingStepId, () => 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<OnboardingSiteSettingKey, UiFieldNode<OnboardingSiteSettingKey>>()
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,
}

View File

@@ -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<SiteSettingsSchema>
export const SITE_SETTINGS_KEYS = Object.keys(siteSettingsSchema.shape) as OnboardingSiteSettingKey[]
export const DEFAULT_SITE_SETTINGS_VALUES: SiteSettingsValues = {
'site.name': '',
'site.title': '',
'site.description': '',
}

View File

@@ -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<OnboardingSiteSettingKey>
export type OnboardingErrors = Record<string, string>

View File

@@ -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 = <Key extends string>(
nodes: ReadonlyArray<UiNode<Key>>,
map: Map<Key, UiFieldNode<Key>>,
) => {
for (const node of nodes) {
if (node.type === 'field') {
map.set(node.key, node)
continue
}
traverseSchemaNodes(node.children, map)
}
}
export function collectSchemaFieldMap<Key extends string>(schema: UiSchema<Key>): Map<Key, UiFieldNode<Key>> {
const map = new Map<Key, UiFieldNode<Key>>()
traverseSchemaNodes(schema.sections, map)
return map
}
export function collectSchemaFieldKeys<Key extends string>(schema: UiSchema<Key>): Key[] {
return [...collectSchemaFieldMap(schema).keys()]
}
export function createInitialSiteStateFromFieldMap<Key extends string>(
fieldMap: Map<Key, UiFieldNode<Key>>,
): SchemaFormState<Key> {
const state = {} as SchemaFormState<Key>
for (const [key, field] of fieldMap) {
const defaultValue = (DEFAULT_SITE_SETTINGS_VALUES as Record<string, SchemaFormValue | undefined>)[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<Key extends string>(schema: UiSchema<Key>): SchemaFormState<Key> {
return createInitialSiteStateFromFieldMap(collectSchemaFieldMap(schema))
}
export function coerceSiteFieldValue<Key extends string>(
field: UiFieldNode<Key>,
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<Key extends string>(
field: UiFieldNode<Key>,
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 ''
}
}

View File

@@ -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<Key extends string> {
renderSlot?: SlotRenderer<Key>
}
export function SchemaFormRendererUncontrolled<Key extends string>({
initialValues,
onChange,
...rest
}: Omit<SchemaFormRendererProps<Key>, 'values'> & { initialValues: SchemaFormState<Key> }) {
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 <SchemaFormRenderer {...rest} values={values} onChange={handleChange} />
}
export function SchemaFormRenderer<Key extends string>({
schema,
values,

View File

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

View File

@@ -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<SiteSettingUiSchemaResponse>(`${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 },
})
}

View File

@@ -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<string>, 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<string> {
const state: SiteSettingValueState<string> = {} as SiteSettingValueState<string>
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<string>, 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<SiteSettingValueState<string>>({} as SiteSettingValueState<string>)
const [initialState, setInitialState] = useState<SiteSettingValueState<string> | null>(null)
const fieldMap = useMemo(() => {
if (!data) {
return new Map<string, UiFieldNode<string>>()
}
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<SiteSettingEntryInput[]>(() => {
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<HTMLFormElement> = (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 = (
<MainPageLayout.Actions>
<Button
type="submit"
form={formId}
disabled={changedEntries.length === 0}
isLoading={updateSettingsMutation.isPending}
loadingText="保存中…"
variant="primary"
size="sm"
>
</Button>
</MainPageLayout.Actions>
)
if (isLoading) {
return (
<>
{headerActionPortal}
<LinearBorderPanel className="p-6">
<div className="space-y-4">
<div className="bg-fill/40 h-5 w-1/2 animate-pulse rounded-lg" />
<div className="space-y-3">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key) => (
<div key={key} className="bg-fill/30 h-20 animate-pulse rounded-lg" />
))}
</div>
</div>
</LinearBorderPanel>
</>
)
}
if (isError) {
return (
<>
{headerActionPortal}
<LinearBorderPanel className="p-6">
<div className="text-red flex items-center gap-3 text-sm">
<i className="i-mingcute-close-circle-fill text-lg" />
<span>{`无法加载站点设置:${error instanceof Error ? error.message : '未知错误'}`}</span>
</div>
</LinearBorderPanel>
</>
)
}
if (!data) {
return headerActionPortal
}
const { schema } = data
return (
<>
{headerActionPortal}
<m.form
id={formId}
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
>
<SchemaFormRenderer schema={schema} values={formState} onChange={handleChange} />
<div className="flex justify-end">
<div className="text-text-tertiary text-xs">
{mutationErrorMessage
? `保存失败:${mutationErrorMessage}`
: updateSettingsMutation.isSuccess && changedEntries.length === 0
? '保存成功,站点设置已同步'
: changedEntries.length > 0
? `${changedEntries.length} 项设置待保存`
: '所有设置已同步'}
</div>
</div>
</m.form>
</>
)
}

View File

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

View File

@@ -0,0 +1,4 @@
export * from './api'
export * from './components/SiteSettingsForm'
export * from './hooks'
export * from './types'

View File

@@ -0,0 +1,13 @@
import type { SchemaFormValue, UiSchema } from '../schema-form/types'
export interface SiteSettingUiSchemaResponse<Key extends string = string> {
readonly schema: UiSchema<Key>
readonly values: Partial<Record<Key, string | null>>
}
export type SiteSettingValueState<Key extends string = string> = Record<Key, SchemaFormValue | undefined>
export type SiteSettingEntryInput<Key extends string = string> = {
readonly key: Key
readonly value: string
}

View File

@@ -5,7 +5,7 @@ export function Component() {
return (
<MainPageLayout title="系统设置" description="管理后台与核心功能的通用配置,修改后会立即同步生效。">
<div className="space-y-6">
<SettingsNavigation active="general" />
<SettingsNavigation active="site" />
<SettingsForm />
</div>
</MainPageLayout>

View File

@@ -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 (
<MainPageLayout title="站点设置" description="配置前台站点的品牌信息、社交渠道与地图展示。">
<div className="space-y-6">
<SettingsNavigation active="site" />
<SiteSettingsForm />
</div>
</MainPageLayout>
)
}

140
pnpm-lock.yaml generated
View File

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