>> {
- const { existingItem, livePhotoMap, processorOptions, builder } = options ?? {}
- const activeBuilder = builder ?? this.defaultBuilder
+ const { existingItem, livePhotoMap, processorOptions, builder, builderConfig } = options ?? {}
+ const activeBuilder = this.resolveBuilder(builder, builderConfig)
const mergedOptions: PhotoProcessorOptions = {
...DEFAULT_PROCESSOR_OPTIONS,
@@ -75,6 +66,20 @@ export class PhotoBuilderService {
return await processPhotoWithPipeline(context, activeBuilder)
}
+ private resolveBuilder(builder?: AfilmoryBuilder, builderConfig?: BuilderConfig): AfilmoryBuilder {
+ if (builder) {
+ return builder
+ }
+
+ if (builderConfig) {
+ return this.createBuilder(builderConfig)
+ }
+
+ throw new Error(
+ 'PhotoBuilderService requires a builder instance or configuration. Pass builder or builderConfig in ProcessPhotoOptions.',
+ )
+ }
+
private toLegacyObject(object: StorageObject): _Object {
return {
Key: object.key,
diff --git a/be/apps/dashboard/agents.md b/be/apps/dashboard/agents.md
index c0c67236..621711c3 100644
--- a/be/apps/dashboard/agents.md
+++ b/be/apps/dashboard/agents.md
@@ -169,6 +169,193 @@ export const Component = () => {
}
```
+UI Design Guidelines:
+
+This dashboard follows a **linear design language** with clean lines and subtle gradients. The design emphasizes simplicity and clarity without rounded corners or heavy visual effects.
+
+Core Design Principles:
+
+- **No rounded corners**: All elements use sharp, clean edges
+- **Linear gradient borders**: Use subtle gradient borders for visual separation
+- **Minimal backgrounds**: Use solid colors (`bg-background`, `bg-background-tertiary`)
+- **Clean typography**: Clear hierarchy with appropriate font sizes
+- **Subtle interactions**: Focus rings and hover states with minimal animation
+
+Form Elements (Inputs, Textareas, Selects):
+
+- **Shape**: **NO** `rounded-xl` - use straight edges
+- **Background**: Use `bg-background` for standard inputs
+- **Border**: Use `border border-fill-tertiary` for default state
+- **Padding**: Standard padding is `px-3 py-2` for inputs
+- **Text Size**: Use `text-sm` for consistency
+- **Text Colors**:
+ - Input text: `text-text`
+ - Placeholder: `placeholder:text-text-tertiary/70`
+ - Labels: `text-text` with `font-medium`
+- **Focus State**:
+ - Remove default outline: `focus:outline-none`
+ - Add focus ring: `focus:ring-2 focus:ring-accent/40`
+- **Error State**:
+ - Border: `border-red/60`
+ - Focus ring: `focus:ring-red/30`
+ - Error message: `text-xs text-red` with `mt-1` spacing
+- **Transitions**: Use `transition-all duration-200` for smooth interactions
+
+Example (text input):
+
+```tsx
+
+
+ Field Label
+
+
+ {error &&
{error}
}
+
+```
+
+Example (textarea):
+
+```tsx
+
+```
+
+Buttons:
+
+- **Shape**: **NO** `rounded-xl` - use straight edges
+- **Padding**: Standard is `px-6 py-2.5` for medium buttons
+- **Text Size**: Use `text-sm` with `font-medium`
+- **Primary Button**:
+ - Background: `bg-accent`
+ - Text: `text-white`
+ - Hover: `hover:bg-accent/90`
+ - Focus: `focus:outline-none focus:ring-2 focus:ring-accent/40`
+ - Active: `active:scale-[0.98]` for subtle press feedback
+- **Secondary/Ghost Button**:
+ - Background: `bg-transparent`
+ - Text: `text-text-secondary`
+ - Hover: `hover:text-text hover:bg-fill/50`
+ - **NO borders** for ghost buttons
+- **Transitions**: Use `transition-all duration-200`
+
+Example (primary button):
+
+```tsx
+
+ Submit
+
+```
+
+Example (ghost button):
+
+```tsx
+
+ Cancel
+
+```
+
+Cards and Containers:
+
+- **Shape**: **NO rounded corners** - use sharp edges
+- **Borders**: Use linear gradient borders for main containers
+- **Dividers**: Use horizontal gradient dividers for section separation
+ - Example: `
`
+- **Backgrounds**: Use solid colors (`bg-background`, `bg-background-tertiary`)
+- **Spacing**: Use consistent padding (e.g., `p-6`, `p-8`, `p-12` depending on size)
+
+Linear Gradient Border Pattern:
+
+```tsx
+
+ {/* Top border */}
+
+
+ {/* Right border */}
+
+
+ {/* Bottom border */}
+
+
+ {/* Left border */}
+
+
+
{/* Content */}
+
+```
+
+Interactive States:
+
+- **Hover**: Subtle background or color changes with `duration-200` transitions
+- **Focus**: Always include focus rings (`focus:ring-2 focus:ring-accent/40`)
+- **Active/Press**: Use `active:scale-[0.98]` for tactile feedback on clickable elements
+- **Disabled**: Add `opacity-70` and `cursor-default` or `cursor-not-allowed`
+
+Spacing and Layout:
+
+- **Form Fields**: Use `mb-6` between form fields, `mb-8` before submit buttons
+- **Grid Layouts**: Use `gap-5` for form grids (e.g., `grid gap-5 md:grid-cols-2`)
+- **Sections**: Use `space-y-6` for vertical spacing in forms
+
+Typography:
+
+- **Headings**: Use semantic sizes (e.g., `text-3xl font-bold` for page titles)
+- **Body Text**: Default is `text-sm` for forms and UI elements
+- **Labels**: `text-sm font-medium text-text`
+- **Helper Text**: `text-xs text-text-tertiary` with `mt-2` spacing
+- **Error Messages**: `text-xs text-red` with `mt-1` spacing
+
+Do NOT:
+
+- ❌ Use rounded corners (`rounded-xl`, `rounded-2xl`, `rounded-full`, etc.) - this design language uses **sharp edges**
+- ❌ Use heavy borders or box shadows - use subtle linear gradients instead
+- ❌ Use animated bottom borders or underlines on inputs (outdated pattern)
+- ❌ Use large padding (`py-3`, `py-4`) on standard inputs - stick to `py-2` or `py-2.5`
+- ❌ Use `border-separator` - use `border-fill-tertiary` instead
+- ❌ Skip focus states - always include `focus:ring-2 focus:ring-accent/40`
+- ❌ Use complex hover effects with gradients - keep it simple with opacity/color changes
+- ❌ Mix design patterns - maintain consistency with existing components
+- ❌ Add borders to ghost buttons - they should be borderless
+
Color system:
- Use the Pastel-based semantic tokens:
@@ -265,4 +452,9 @@ Change checklist (agents):
- Pastel color tokens used
- Atoms created via createAtomHooks; selectors stable
- No edits to auto-generated files
+- **UI Design**: Form inputs use **NO rounded corners**, `bg-background`, `border-fill-tertiary`, and `focus:ring-2 focus:ring-accent/40`
+- **UI Design**: Buttons use **NO rounded corners**, `px-6 py-2.5`, `text-sm font-medium`
+- **UI Design**: Main containers use linear gradient borders (see login.tsx example)
+- **UI Design**: All interactive elements have proper focus states and transitions
+- **UI Design**: NO `rounded-xl`, `rounded-2xl`, or any border-radius classes
- Code passes pnpm lint, pnpm format, and pnpm build
diff --git a/be/apps/dashboard/src/components/animate-ui/primitives/radix/switch.tsx b/be/apps/dashboard/src/components/animate-ui/primitives/radix/switch.tsx
index de8d414f..036b7f82 100644
--- a/be/apps/dashboard/src/components/animate-ui/primitives/radix/switch.tsx
+++ b/be/apps/dashboard/src/components/animate-ui/primitives/radix/switch.tsx
@@ -1,5 +1,6 @@
'use client'
+import { useControlledState } from '@afilmory/hooks'
import type {
HTMLMotionProps,
LegacyAnimationControls,
@@ -10,7 +11,6 @@ import { m as motion } from 'motion/react'
import { Switch as SwitchPrimitives } from 'radix-ui'
import * as React from 'react'
-import { useControlledState } from '~/hooks/use-controlled-state'
import { getStrictContext } from '~/lib/get-strict-context'
type SwitchContextType = {
diff --git a/be/apps/dashboard/src/components/common/Footer.tsx b/be/apps/dashboard/src/components/common/Footer.tsx
new file mode 100644
index 00000000..5c7b0208
--- /dev/null
+++ b/be/apps/dashboard/src/components/common/Footer.tsx
@@ -0,0 +1,15 @@
+import type { FC } from 'react'
+
+export const Footer: FC = () => {
+ return (
+
+ )
+}
diff --git a/be/apps/dashboard/src/global.d.ts b/be/apps/dashboard/src/global.d.ts
index 5f23477c..85779bc7 100644
--- a/be/apps/dashboard/src/global.d.ts
+++ b/be/apps/dashboard/src/global.d.ts
@@ -24,9 +24,12 @@ declare global {
} & {}
const APP_NAME: string
-}
-export {}
+ /**
+ * This function is a macro, will replace in the build stage.
+ */
+ export function tw(strings: TemplateStringsArray, ...values: any[]): string
+}
declare global {
export type Component = FC>
diff --git a/be/apps/dashboard/src/lib/api-client.ts b/be/apps/dashboard/src/lib/api-client.ts
new file mode 100644
index 00000000..289f121e
--- /dev/null
+++ b/be/apps/dashboard/src/lib/api-client.ts
@@ -0,0 +1,8 @@
+import { $fetch } from 'ofetch'
+
+const baseURL = import.meta.env.VITE_CORE_API_BASE?.replace(/\/$/, '') || '/api'
+
+export const coreApi = $fetch.create({
+ baseURL,
+ credentials: 'include',
+})
diff --git a/be/apps/dashboard/src/lib/utils.ts b/be/apps/dashboard/src/lib/utils.ts
deleted file mode 100644
index 28b74383..00000000
--- a/be/apps/dashboard/src/lib/utils.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { cn } from './cn'
diff --git a/be/apps/dashboard/src/modules/onboarding/api.ts b/be/apps/dashboard/src/modules/onboarding/api.ts
new file mode 100644
index 00000000..b7f2446a
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/api.ts
@@ -0,0 +1,42 @@
+import { coreApi } from '~/lib/api-client'
+
+import type { OnboardingSettingKey } from './constants'
+
+export type OnboardingStatusResponse = {
+ initialized: boolean
+}
+
+export type OnboardingInitPayload = {
+ admin: {
+ email: string
+ password: string
+ name: string
+ }
+ tenant: {
+ name: string
+ slug: string
+ domain?: string
+ }
+ settings?: Array<{
+ key: OnboardingSettingKey
+ value: unknown
+ }>
+}
+
+export type OnboardingInitResponse = {
+ ok: boolean
+ adminUserId: string
+ tenantId: string
+ superAdminUserId: string
+}
+
+export const getOnboardingStatus = async () =>
+ await coreApi('/onboarding/status', {
+ method: 'GET',
+ })
+
+export const postOnboardingInit = async (payload: OnboardingInitPayload) =>
+ await coreApi('/onboarding/init', {
+ method: 'POST',
+ body: payload,
+ })
diff --git a/be/apps/dashboard/src/modules/onboarding/components/LinearBorderBox.tsx b/be/apps/dashboard/src/modules/onboarding/components/LinearBorderBox.tsx
new file mode 100644
index 00000000..8120ca3b
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/LinearBorderBox.tsx
@@ -0,0 +1,32 @@
+import type { FC, ReactNode } from 'react'
+
+type LinearBorderBoxProps = {
+ children: ReactNode
+ className?: string
+}
+
+/**
+ * A container with linear gradient borders on all sides.
+ * Follows the design language from login page.
+ */
+export const LinearBorderBox: FC = ({
+ children,
+ className = '',
+}) => (
+
+ {/* Top border */}
+
+
+ {/* Right border */}
+
+
+ {/* Bottom border */}
+
+
+ {/* Left border */}
+
+
+ {children}
+
+)
+
diff --git a/be/apps/dashboard/src/modules/onboarding/components/OnboardingFooter.tsx b/be/apps/dashboard/src/modules/onboarding/components/OnboardingFooter.tsx
new file mode 100644
index 00000000..e7a9acfe
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/OnboardingFooter.tsx
@@ -0,0 +1,44 @@
+import { Button } from '@afilmory/ui'
+import type { FC } from 'react'
+
+type OnboardingFooterProps = {
+ onBack: () => void
+ onNext: () => void
+ disableBack: boolean
+ isSubmitting: boolean
+ isLastStep: boolean
+}
+
+export const OnboardingFooter: FC = ({
+ onBack,
+ onNext,
+ disableBack,
+ isSubmitting,
+ isLastStep,
+}) => (
+
+
+ Need to revisit an earlier step? Use the sidebar or go back to adjust your
+ inputs.
+
+
+
+ Back
+
+
+ {isLastStep ? 'Initialize' : 'Continue'}
+
+
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/OnboardingHeader.tsx b/be/apps/dashboard/src/modules/onboarding/components/OnboardingHeader.tsx
new file mode 100644
index 00000000..66a7464b
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/OnboardingHeader.tsx
@@ -0,0 +1,25 @@
+import type { FC } from 'react'
+
+import type { OnboardingStep } from '../constants'
+
+type OnboardingHeaderProps = {
+ currentStepIndex: number
+ totalSteps: number
+ step: OnboardingStep
+}
+
+export const OnboardingHeader: FC = ({
+ currentStepIndex,
+ totalSteps,
+ step,
+}) => (
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/OnboardingSidebar.tsx b/be/apps/dashboard/src/modules/onboarding/components/OnboardingSidebar.tsx
new file mode 100644
index 00000000..1e5f2cab
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/OnboardingSidebar.tsx
@@ -0,0 +1,94 @@
+import { cx } from '@afilmory/utils'
+import type { FC } from 'react'
+
+import type { OnboardingStepId } from '../constants'
+import { ONBOARDING_STEPS } from '../constants'
+import { stepProgress } from '../utils'
+
+type OnboardingSidebarProps = {
+ currentStepIndex: number
+ canNavigateTo: (index: number) => boolean
+ onStepSelect: (index: number) => void
+}
+
+export const OnboardingSidebar: FC = ({
+ currentStepIndex,
+ canNavigateTo,
+ onStepSelect,
+}) => (
+
+
+
+ Setup Journey
+
+
+ Launch your photo platform
+
+
+
+ {ONBOARDING_STEPS.map((step, index) => {
+ const status: 'done' | 'current' | 'pending' =
+ index < currentStepIndex
+ ? 'done'
+ : index === currentStepIndex
+ ? 'current'
+ : 'pending'
+
+ return (
+
{
+ if (canNavigateTo(index)) {
+ onStepSelect(index)
+ }
+ }}
+ >
+
+
+ {status === 'done' ? (
+
+ ) : (
+ index + 1
+ )}
+
+
+
{step.title}
+
{step.description}
+
+
+
+ )
+ })}
+
+
+ {/* Horizontal divider */}
+
+
+
+ Progress
+ {stepProgress(currentStepIndex)}%
+
+
+
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx b/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx
new file mode 100644
index 00000000..77974043
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/OnboardingWizard.tsx
@@ -0,0 +1,147 @@
+import type { FC, ReactNode } from 'react'
+
+import { ONBOARDING_STEPS } from '../constants'
+import { useOnboardingWizard } from '../hooks/useOnboardingWizard'
+import { OnboardingFooter } from './OnboardingFooter'
+import { OnboardingHeader } from './OnboardingHeader'
+import { OnboardingSidebar } from './OnboardingSidebar'
+import { ErrorState } from './states/ErrorState'
+import { InitializedState } from './states/InitializedState'
+import { LoadingState } from './states/LoadingState'
+import { AdminStep } from './steps/AdminStep'
+import { ReviewStep } from './steps/ReviewStep'
+import { SettingsStep } from './steps/SettingsStep'
+import { TenantStep } from './steps/TenantStep'
+import { WelcomeStep } from './steps/WelcomeStep'
+
+export const OnboardingWizard: FC = () => {
+ const wizard = useOnboardingWizard()
+ const {
+ query,
+ mutation,
+ currentStepIndex,
+ currentStep,
+ goToNext,
+ goToPrevious,
+ jumpToStep,
+ canNavigateTo,
+ tenant,
+ admin,
+ settingsState,
+ acknowledged,
+ setAcknowledged,
+ errors,
+ updateTenantName,
+ updateTenantSlug,
+ suggestSlug,
+ updateTenantDomain,
+ updateAdminField,
+ toggleSetting,
+ updateSettingValue,
+ reviewSettings,
+ } = wizard
+
+ if (query.isLoading) {
+ return
+ }
+
+ if (query.isError) {
+ return
+ }
+
+ if (query.data?.initialized) {
+ return
+ }
+
+ const stepContent: Record = {
+ welcome: ,
+ tenant: (
+
+ ),
+ admin: (
+
+ ),
+ settings: (
+
+ ),
+ review: (
+
+ ),
+ }
+
+ return (
+
+
+ {/* Top border */}
+
+
+ {/* Left border */}
+
+
+
+ {/* Sidebar */}
+
+
+ {/* Main content */}
+
+
+
+ {/* Horizontal divider */}
+
+
+ {stepContent[currentStep.id]}
+
+ {/* Horizontal divider */}
+
+
+
+
+
+
+ {/* Right border */}
+
+
+
+
+ {/* Bottom border */}
+
+
+
+ )
+}
diff --git a/be/apps/dashboard/src/modules/onboarding/components/states/ErrorState.tsx b/be/apps/dashboard/src/modules/onboarding/components/states/ErrorState.tsx
new file mode 100644
index 00000000..af56c84a
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/states/ErrorState.tsx
@@ -0,0 +1,22 @@
+import type { FC } from 'react'
+
+export const ErrorState: FC = () => (
+
+
+
+
+ Unable to connect
+
+
+ The dashboard could not reach the core service. Ensure the backend is
+ running and refresh the page.
+
+
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/states/InitializedState.tsx b/be/apps/dashboard/src/modules/onboarding/components/states/InitializedState.tsx
new file mode 100644
index 00000000..ceb623b3
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/states/InitializedState.tsx
@@ -0,0 +1,46 @@
+import type { FC } from 'react'
+
+export const InitializedState: FC = () => (
+
+
+
+
+
+
Next steps
+
+
+
+
+ Sign in as the tenant administrator you created during onboarding.
+
+
+
+
+
+ Look up the super administrator credentials printed in the core
+ service logs if you have not already stored them.
+
+
+
+
+
+ Open the settings panel to refine integrations, email providers,
+ and workspace preferences.
+
+
+
+
+
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/states/LoadingState.tsx b/be/apps/dashboard/src/modules/onboarding/components/states/LoadingState.tsx
new file mode 100644
index 00000000..b4179207
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/states/LoadingState.tsx
@@ -0,0 +1,14 @@
+import type { FC } from 'react'
+
+export const LoadingState: FC = () => (
+
+
+
+
+
+ Preparing onboarding experience
+
+
+
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/steps/AdminStep.tsx b/be/apps/dashboard/src/modules/onboarding/components/steps/AdminStep.tsx
new file mode 100644
index 00000000..78752304
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/steps/AdminStep.tsx
@@ -0,0 +1,120 @@
+import { cx } from '@afilmory/utils'
+import type { FC } from 'react'
+
+import type { AdminFormState, OnboardingErrors } from '../../types'
+
+type AdminStepProps = {
+ admin: AdminFormState
+ errors: OnboardingErrors
+ onChange: (
+ field: Field,
+ value: AdminFormState[Field],
+ ) => void
+}
+
+export const AdminStep: FC = ({ admin, errors, onChange }) => (
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/steps/ReviewStep.tsx b/be/apps/dashboard/src/modules/onboarding/components/steps/ReviewStep.tsx
new file mode 100644
index 00000000..4dfe54d1
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/steps/ReviewStep.tsx
@@ -0,0 +1,128 @@
+import { Checkbox } from '@afilmory/ui'
+import type { FC } from 'react'
+
+import type {
+ OnboardingSettingKey,
+ SettingFieldDefinition,
+} from '../../constants'
+import type {
+ AdminFormState,
+ OnboardingErrors,
+ TenantFormState,
+} from '../../types'
+import { maskSecret } from '../../utils'
+
+export type ReviewSettingEntry = {
+ definition: SettingFieldDefinition
+ value: string
+}
+
+type ReviewStepProps = {
+ tenant: TenantFormState
+ admin: AdminFormState
+ reviewSettings: ReviewSettingEntry[]
+ acknowledged: boolean
+ errors: OnboardingErrors
+ onAcknowledgeChange: (checked: boolean) => void
+}
+
+export const ReviewStep: FC = ({
+ tenant,
+ admin,
+ reviewSettings,
+ acknowledged,
+ errors,
+ onAcknowledgeChange,
+}) => (
+
+
+
Tenant summary
+
+
+
Name
+ {tenant.name || '—'}
+
+
+
Slug
+ {tenant.slug || '—'}
+
+
+
Domain
+ {tenant.domain || 'Not configured'}
+
+
+
+
+
+
Administrator
+
+
+
Name
+ {admin.name || '—'}
+
+
+
Email
+ {admin.email || '—'}
+
+
+
Password
+ {maskSecret(admin.password)}
+
+
+
+
+
+
+ Enabled integrations
+
+ {reviewSettings.length === 0 ? (
+
+ No integrations configured. You can enable OAuth providers, AI
+ services, or maps later from the settings panel.
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Important
+
+
+ Once you click initialize, the application becomes locked to this
+ initial administrator. The core service will print super administrator
+ credentials to stdout exactly once.
+
+
+ onAcknowledgeChange(Boolean(checked))}
+ className="mt-0.5"
+ />
+
+ I have noted the super administrator credentials will appear in the
+ backend logs and understand this action cannot be repeated.
+
+
+ {errors['review.ack'] && (
+
{errors['review.ack']}
+ )}
+
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/steps/SettingsStep.tsx b/be/apps/dashboard/src/modules/onboarding/components/steps/SettingsStep.tsx
new file mode 100644
index 00000000..c3e52aee
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/steps/SettingsStep.tsx
@@ -0,0 +1,117 @@
+import { Button } from '@afilmory/ui'
+import { cx } from '@afilmory/utils'
+import type { FC } from 'react'
+
+import type { OnboardingSettingKey } from '../../constants'
+import { ONBOARDING_SETTING_SECTIONS } from '../../constants'
+import type { OnboardingErrors, SettingFormState } from '../../types'
+
+type SettingsStepProps = {
+ settingsState: SettingFormState
+ errors: OnboardingErrors
+ onToggle: (key: OnboardingSettingKey, enabled: boolean) => void
+ onChange: (key: OnboardingSettingKey, value: string) => void
+}
+
+export const SettingsStep: FC = ({
+ settingsState,
+ errors,
+ onToggle,
+ onChange,
+}) => (
+
+ {ONBOARDING_SETTING_SECTIONS.map((section) => (
+
+
+ {section.title}
+ {section.description}
+
+
+
+ {section.fields.map((field) => {
+ const state = settingsState[field.key]
+ const errorKey = `settings.${field.key}`
+ const hasError = Boolean(errors[errorKey])
+
+ return (
+
+
+
+
+ {field.label}
+
+
+ {field.description}
+
+
+
onToggle(field.key, !state.enabled)}
+ >
+ {state.enabled ? 'Enabled' : 'Enable'}
+
+
+
+ {state.enabled && (
+
+ )}
+
+ )
+ })}
+
+
+ ))}
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/steps/TenantStep.tsx b/be/apps/dashboard/src/modules/onboarding/components/steps/TenantStep.tsx
new file mode 100644
index 00000000..e58c117c
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/steps/TenantStep.tsx
@@ -0,0 +1,101 @@
+import { Button } from '@afilmory/ui'
+import { cx } from '@afilmory/utils'
+import type { FC } from 'react'
+
+import type { OnboardingErrors, TenantFormState } from '../../types'
+
+type TenantStepProps = {
+ tenant: TenantFormState
+ errors: OnboardingErrors
+ onNameChange: (value: string) => void
+ onSlugChange: (value: string) => void
+ onDomainChange: (value: string) => void
+ onSuggestSlug: () => void
+}
+
+export const TenantStep: FC = ({
+ tenant,
+ errors,
+ onNameChange,
+ onSlugChange,
+ onDomainChange,
+ onSuggestSlug,
+}) => (
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/components/steps/WelcomeStep.tsx b/be/apps/dashboard/src/modules/onboarding/components/steps/WelcomeStep.tsx
new file mode 100644
index 00000000..1cea36ed
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/components/steps/WelcomeStep.tsx
@@ -0,0 +1,68 @@
+import type { FC } from 'react'
+
+export const WelcomeStep: FC = () => (
+
+
+
+
+ What happens next
+
+
+ We will create your first tenant, provision an administrator, and
+ bootstrap super administrator access for emergency management.
+
+
+
+
+
+
+
+
+ Requirements
+
+
+
+
+ Ensure the core service can access email providers or authentication
+ callbacks if configured.
+
+
+
+ Keep the terminal open to capture the super administrator
+ credentials printed after initialization.
+
+
+
+ Prepare OAuth credentials or continue without them; you can
+ configure integrations later.
+
+
+
+
+
+
+
+
What we will collect
+
+
+
Tenant profile
+
+ Workspace name, slug, and optional domain mapping.
+
+
+
+
Admin account
+
+ Email, name, and secure password for the first administrator.
+
+
+
+
Integrations
+
+ Optional OAuth, AI, and map provider credentials.
+
+
+
+
+
+)
diff --git a/be/apps/dashboard/src/modules/onboarding/constants.ts b/be/apps/dashboard/src/modules/onboarding/constants.ts
new file mode 100644
index 00000000..7963b8e4
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/constants.ts
@@ -0,0 +1,171 @@
+export type OnboardingSettingKey =
+ | 'ai.openai.apiKey'
+ | 'ai.openai.baseUrl'
+ | 'ai.embedding.model'
+ | 'auth.google.clientId'
+ | 'auth.google.clientSecret'
+ | 'auth.github.clientId'
+ | 'auth.github.clientSecret'
+ | 'http.cors.allowedOrigins'
+ | 'services.amap.apiKey'
+
+export type SettingFieldDefinition = {
+ key: OnboardingSettingKey
+ label: string
+ description: string
+ placeholder?: string
+ helper?: string
+ sensitive?: boolean
+ multiline?: boolean
+}
+
+export type SettingSectionDefinition = {
+ id: string
+ title: string
+ description: string
+ fields: SettingFieldDefinition[]
+}
+
+export const ONBOARDING_SETTING_SECTIONS: SettingSectionDefinition[] = [
+ {
+ id: 'auth',
+ title: 'Authentication Providers',
+ description:
+ 'Configure OAuth providers that will be available to your team. You can add them later from the settings panel as well.',
+ fields: [
+ {
+ key: 'auth.google.clientId',
+ label: 'Google Client ID',
+ description: 'Public identifier issued by Google OAuth.',
+ placeholder: '1234567890-abc.apps.googleusercontent.com',
+ },
+ {
+ key: 'auth.google.clientSecret',
+ label: 'Google Client Secret',
+ description:
+ 'Keep this secret safe. Required together with the client ID to enable Google sign-in.',
+ placeholder: 'GOCSPX-xxxxxxxxxxxxxxxxxx',
+ sensitive: true,
+ },
+ {
+ key: 'auth.github.clientId',
+ label: 'GitHub Client ID',
+ description: 'Public identifier for your GitHub OAuth App.',
+ placeholder: 'Iv1.0123456789abcdef',
+ },
+ {
+ key: 'auth.github.clientSecret',
+ label: 'GitHub Client Secret',
+ description: 'Used to authorize GitHub OAuth callbacks.',
+ placeholder: 'e3a2f9c0f2bdc...',
+ sensitive: true,
+ },
+ ],
+ },
+ {
+ id: 'ai',
+ title: 'AI & Embeddings',
+ description:
+ 'Optional integrations for AI powered features. Provide your OpenAI credentials and preferred embedding model.',
+ fields: [
+ {
+ key: 'ai.openai.apiKey',
+ label: 'OpenAI API Key',
+ description:
+ 'Used for generating captions, titles, and AI assistance across the platform.',
+ placeholder: 'sk-proj-xxxxxxxxxxxxxxxx',
+ sensitive: true,
+ },
+ {
+ key: 'ai.openai.baseUrl',
+ label: 'OpenAI Base URL',
+ description:
+ 'Override the default api.openai.com endpoint if you proxy requests.',
+ placeholder: 'https://api.openai.com/v1',
+ },
+ {
+ key: 'ai.embedding.model',
+ label: 'Embedding Model',
+ description:
+ 'Model identifier to compute embeddings for search and semantic features.',
+ placeholder: 'text-embedding-3-large',
+ },
+ ],
+ },
+ {
+ id: 'map',
+ title: 'Map Services',
+ description:
+ 'Connect Gaode (Amap) maps to unlock geolocation previews for your photos.',
+ fields: [
+ {
+ key: 'services.amap.apiKey',
+ label: 'Gaode (Amap) API Key',
+ description: 'Required to render photo locations on the dashboard.',
+ placeholder: 'your-amap-api-key',
+ sensitive: true,
+ },
+ ],
+ },
+ {
+ id: 'network',
+ title: 'Network & CORS',
+ description: 'Restrict which origins can access the backend APIs.',
+ fields: [
+ {
+ key: 'http.cors.allowedOrigins',
+ label: 'Allowed Origins',
+ description:
+ 'Comma separated list of origins. Example: https://dashboard.afilmory.com, https://afilmory.app',
+ placeholder: 'https://dashboard.afilmory.com, https://afilmory.app',
+ helper: 'Leave empty to keep the default wildcard policy during setup.',
+ multiline: true,
+ },
+ ],
+ },
+]
+
+export const ONBOARDING_TOTAL_STEPS = 5 as const
+export const ONBOARDING_STEP_ORDER = [
+ 'welcome',
+ 'tenant',
+ 'admin',
+ 'settings',
+ 'review',
+] as const
+
+export type OnboardingStepId = (typeof ONBOARDING_STEP_ORDER)[number]
+
+export type OnboardingStep = {
+ id: OnboardingStepId
+ title: string
+ description: string
+}
+
+export const ONBOARDING_STEPS: OnboardingStep[] = [
+ {
+ id: 'welcome',
+ title: 'Welcome',
+ description: 'Verify environment and prepare initialization.',
+ },
+ {
+ id: 'tenant',
+ title: 'Tenant Profile',
+ description: 'Name your workspace and optional domain.',
+ },
+ {
+ id: 'admin',
+ title: 'Administrator',
+ description: 'Create the first tenant admin account.',
+ },
+ {
+ id: 'settings',
+ title: 'Platform Settings',
+ description: 'Set optional integration keys before launch.',
+ },
+ {
+ id: 'review',
+ title: 'Review & Launch',
+ description: 'Confirm details and finalize initialization.',
+ },
+]
diff --git a/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts b/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts
new file mode 100644
index 00000000..3ad3d0f7
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/hooks/useOnboardingWizard.ts
@@ -0,0 +1,364 @@
+import { useMutation, useQuery } from '@tanstack/react-query'
+import { FetchError } from 'ofetch'
+import { useState } from 'react'
+import { toast } from 'sonner'
+
+import type { OnboardingInitPayload } from '../api'
+import { getOnboardingStatus, postOnboardingInit } from '../api'
+import type { OnboardingSettingKey, OnboardingStepId } from '../constants'
+import { ONBOARDING_STEPS } from '../constants'
+import type {
+ AdminFormState,
+ OnboardingErrors,
+ SettingFormState,
+ TenantFormState,
+} from '../types'
+import {
+ createInitialSettingsState,
+ getFieldByKey,
+ isLikelyEmail,
+ maskSecret,
+ slugify,
+} from '../utils'
+
+const INITIAL_STEP_INDEX = 0
+
+export const useOnboardingWizard = () => {
+ const [currentStepIndex, setCurrentStepIndex] = useState(INITIAL_STEP_INDEX)
+ const [tenant, setTenant] = useState({
+ name: '',
+ slug: '',
+ domain: '',
+ })
+ const [slugLocked, setSlugLocked] = useState(false)
+ const [admin, setAdmin] = useState({
+ name: '',
+ email: '',
+ password: '',
+ confirmPassword: '',
+ })
+ const [settingsState, setSettingsState] = useState(
+ createInitialSettingsState,
+ )
+ const [acknowledged, setAcknowledged] = useState(false)
+ const [errors, setErrors] = useState({})
+
+ const currentStep =
+ ONBOARDING_STEPS[currentStepIndex] ?? ONBOARDING_STEPS[INITIAL_STEP_INDEX]
+
+ const query = useQuery({
+ queryKey: ['onboarding', 'status'],
+ queryFn: getOnboardingStatus,
+ staleTime: Infinity,
+ })
+
+ const mutation = useMutation({
+ mutationFn: (payload: OnboardingInitPayload) => postOnboardingInit(payload),
+ onSuccess: () => {
+ toast.success('Initialization completed', {
+ description:
+ 'Super administrator credentials were printed to the core service logs. Store them securely before closing the terminal.',
+ })
+ void query.refetch()
+ },
+ onError: (error) => {
+ if (error instanceof FetchError) {
+ const message =
+ typeof error.data === 'object' &&
+ error.data &&
+ 'message' in error.data
+ ? String(error.data.message)
+ : 'Backend rejected the initialization request.'
+ toast.error('Initialization failed', { description: message })
+ } else {
+ toast.error('Initialization failed', {
+ description:
+ 'Unexpected error occurred. Please retry or inspect the logs.',
+ })
+ }
+ },
+ })
+
+ const setFieldError = (key: string, reason: string | null) => {
+ setErrors((prev) => {
+ const next = { ...prev }
+ if (!reason) {
+ delete next[key]
+ } else {
+ next[key] = reason
+ }
+ return next
+ })
+ }
+
+ const validateTenant = () => {
+ let valid = true
+ const name = tenant.name.trim()
+ if (!name) {
+ setFieldError('tenant.name', 'Workspace name is required')
+ valid = false
+ } else {
+ setFieldError('tenant.name', null)
+ }
+
+ const slug = tenant.slug.trim()
+ if (!slug) {
+ setFieldError('tenant.slug', 'Slug is required')
+ valid = false
+ } else if (!/^[a-z0-9-]+$/.test(slug)) {
+ setFieldError(
+ 'tenant.slug',
+ 'Only lowercase letters, numbers, and hyphen are allowed',
+ )
+ valid = false
+ } else {
+ setFieldError('tenant.slug', null)
+ }
+
+ const domain = tenant.domain.trim()
+ if (domain && !/^[a-z0-9.-]+$/.test(domain)) {
+ setFieldError(
+ 'tenant.domain',
+ 'Use lowercase letters, numbers, dot, or hyphen',
+ )
+ valid = false
+ } else {
+ setFieldError('tenant.domain', null)
+ }
+
+ return valid
+ }
+
+ const validateAdmin = () => {
+ let valid = true
+ const name = admin.name.trim()
+ if (!name) {
+ setFieldError('admin.name', 'Administrator name is required')
+ valid = false
+ } else if (/^root$/i.test(name)) {
+ setFieldError('admin.name', 'The name "root" is reserved')
+ valid = false
+ } else {
+ setFieldError('admin.name', null)
+ }
+
+ const email = admin.email.trim()
+ if (!email) {
+ setFieldError('admin.email', 'Email is required')
+ valid = false
+ } else if (!isLikelyEmail(email)) {
+ setFieldError('admin.email', 'Enter a valid email address')
+ valid = false
+ } else {
+ setFieldError('admin.email', null)
+ }
+
+ if (!admin.password) {
+ setFieldError('admin.password', 'Password is required')
+ valid = false
+ } else if (admin.password.length < 8) {
+ setFieldError('admin.password', 'Password must be at least 8 characters')
+ valid = false
+ } else {
+ setFieldError('admin.password', null)
+ }
+
+ if (!admin.confirmPassword) {
+ setFieldError('admin.confirmPassword', 'Confirm the password to continue')
+ valid = false
+ } else if (admin.confirmPassword !== admin.password) {
+ setFieldError('admin.confirmPassword', 'Passwords do not match')
+ valid = false
+ } else {
+ setFieldError('admin.confirmPassword', null)
+ }
+
+ return valid
+ }
+
+ const validateSettings = () => {
+ let valid = true
+ for (const [key, entry] of Object.entries(settingsState) as Array<
+ [OnboardingSettingKey, SettingFormState[OnboardingSettingKey]]
+ >) {
+ if (!entry.enabled) {
+ setFieldError(`settings.${key}`, null)
+ continue
+ }
+ if (!entry.value.trim()) {
+ setFieldError(
+ `settings.${key}`,
+ 'Value is required when the setting is enabled',
+ )
+ valid = false
+ } else {
+ setFieldError(`settings.${key}`, null)
+ }
+ }
+ return valid
+ }
+
+ const validateAcknowledgement = () => {
+ if (!acknowledged) {
+ setFieldError(
+ 'review.ack',
+ 'Please confirm you saved the super administrator credentials before continuing',
+ )
+ return false
+ }
+ setFieldError('review.ack', null)
+ return true
+ }
+
+ const validators: Partial boolean>> = {
+ welcome: () => true,
+ tenant: validateTenant,
+ admin: validateAdmin,
+ settings: validateSettings,
+ review: validateAcknowledgement,
+ }
+
+ const submitInitialization = () => {
+ const trimmedDomain = tenant.domain.trim()
+ const payload: OnboardingInitPayload = {
+ tenant: {
+ name: tenant.name.trim(),
+ slug: tenant.slug.trim(),
+ ...(trimmedDomain ? { domain: trimmedDomain } : {}),
+ },
+ admin: {
+ name: admin.name.trim(),
+ email: admin.email.trim(),
+ password: admin.password,
+ },
+ }
+
+ const settingEntries = Object.entries(settingsState)
+ .filter(([, entry]) => entry.enabled && entry.value.trim())
+ .map(([key, entry]) => ({
+ key: key as OnboardingSettingKey,
+ value: entry.value.trim(),
+ }))
+
+ if (settingEntries.length > 0) {
+ payload.settings = settingEntries
+ }
+
+ mutation.mutate(payload)
+ }
+
+ const goToNext = () => {
+ const validator = validators[currentStep.id]
+ if (validator && !validator()) {
+ return
+ }
+
+ if (currentStepIndex === ONBOARDING_STEPS.length - 1) {
+ submitInitialization()
+ return
+ }
+
+ setCurrentStepIndex((prev) =>
+ Math.min(prev + 1, ONBOARDING_STEPS.length - 1),
+ )
+ }
+
+ const goToPrevious = () => {
+ setCurrentStepIndex((prev) => Math.max(prev - 1, 0))
+ }
+
+ const jumpToStep = (index: number) => {
+ if (index <= currentStepIndex) {
+ setCurrentStepIndex(index)
+ }
+ }
+
+ const updateTenantName = (value: string) => {
+ setTenant((prev) => {
+ if (!slugLocked) {
+ return { ...prev, name: value, slug: slugify(value) }
+ }
+ return { ...prev, name: value }
+ })
+ setFieldError('tenant.name', null)
+ }
+
+ const updateTenantSlug = (value: string) => {
+ setSlugLocked(true)
+ setTenant((prev) => ({ ...prev, slug: value }))
+ setFieldError('tenant.slug', null)
+ }
+
+ const suggestSlug = () => {
+ setSlugLocked(false)
+ setTenant((prev) => ({ ...prev, slug: slugify(prev.name) }))
+ setFieldError('tenant.slug', null)
+ }
+
+ const updateTenantDomain = (value: string) => {
+ setTenant((prev) => ({ ...prev, domain: value }))
+ setFieldError('tenant.domain', null)
+ }
+
+ const updateAdminField = (field: keyof AdminFormState, value: string) => {
+ setAdmin((prev) => ({ ...prev, [field]: value }))
+ setFieldError(`admin.${field}`, null)
+ }
+
+ const toggleSetting = (key: OnboardingSettingKey, enabled: boolean) => {
+ setSettingsState((prev) => {
+ const next = { ...prev, [key]: { ...prev[key], enabled } }
+ if (!enabled) {
+ next[key].value = ''
+ setFieldError(`settings.${key}`, null)
+ }
+ return next
+ })
+ }
+
+ const updateSettingValue = (key: OnboardingSettingKey, value: string) => {
+ setSettingsState((prev) => ({
+ ...prev,
+ [key]: { ...prev[key], value },
+ }))
+ setFieldError(`settings.${key}`, null)
+ }
+
+ const reviewSettings = Object.entries(settingsState)
+ .filter(([, entry]) => entry.enabled && entry.value.trim())
+ .map(([key, entry]) => ({
+ definition: getFieldByKey(key as OnboardingSettingKey),
+ value: entry.value.trim(),
+ }))
+
+ return {
+ query,
+ mutation,
+ currentStepIndex,
+ currentStep,
+ goToNext,
+ goToPrevious,
+ jumpToStep,
+ canNavigateTo: (index: number) => index <= currentStepIndex,
+ tenant,
+ admin,
+ settingsState,
+ acknowledged,
+ setAcknowledged: (value: boolean) => {
+ setAcknowledged(value)
+ if (value) {
+ setFieldError('review.ack', null)
+ }
+ },
+ errors,
+ updateTenantName,
+ updateTenantSlug,
+ suggestSlug,
+ updateTenantDomain,
+ updateAdminField,
+ toggleSetting,
+ updateSettingValue,
+ reviewSettings,
+ maskSecret,
+ }
+}
diff --git a/be/apps/dashboard/src/modules/onboarding/types.ts b/be/apps/dashboard/src/modules/onboarding/types.ts
new file mode 100644
index 00000000..c3984a88
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/types.ts
@@ -0,0 +1,24 @@
+import type { OnboardingSettingKey } from './constants'
+
+export type TenantFormState = {
+ name: string
+ slug: string
+ domain: string
+}
+
+export type AdminFormState = {
+ name: string
+ email: string
+ password: string
+ confirmPassword: string
+}
+
+export type SettingFormState = Record<
+ OnboardingSettingKey,
+ {
+ enabled: boolean
+ value: string
+ }
+>
+
+export type OnboardingErrors = Record
diff --git a/be/apps/dashboard/src/modules/onboarding/utils.ts b/be/apps/dashboard/src/modules/onboarding/utils.ts
new file mode 100644
index 00000000..e76bd98d
--- /dev/null
+++ b/be/apps/dashboard/src/modules/onboarding/utils.ts
@@ -0,0 +1,52 @@
+import type { OnboardingSettingKey, SettingFieldDefinition } from './constants'
+import { ONBOARDING_SETTING_SECTIONS, ONBOARDING_STEPS } from './constants'
+import type { SettingFormState } from './types'
+
+export const createInitialSettingsState = (): SettingFormState => {
+ const state = {} as SettingFormState
+ for (const section of ONBOARDING_SETTING_SECTIONS) {
+ for (const field of section.fields) {
+ state[field.key] = { enabled: false, value: '' }
+ }
+ }
+ return state
+}
+
+export const maskSecret = (value: string) =>
+ value ? '•'.repeat(Math.min(10, value.length)) : ''
+
+export const slugify = (value: string) =>
+ value
+ .toLowerCase()
+ .trim()
+ .replaceAll(/[^a-z0-9-]+/g, '-')
+ .replaceAll(/-{2,}/g, '-')
+ .replaceAll(/^-+|-+$/g, '')
+
+export const isLikelyEmail = (value: string) => {
+ const trimmed = value.trim()
+ if (!trimmed.includes('@')) {
+ return false
+ }
+ const [local, domain] = trimmed.split('@')
+ if (!local || !domain || domain.startsWith('.') || domain.endsWith('.')) {
+ return false
+ }
+ return domain.includes('.')
+}
+
+export const stepProgress = (index: number) =>
+ Math.round((index / (ONBOARDING_STEPS.length - 1 || 1)) * 100)
+
+export const getFieldByKey = (
+ key: OnboardingSettingKey,
+): SettingFieldDefinition => {
+ for (const section of ONBOARDING_SETTING_SECTIONS) {
+ for (const field of section.fields) {
+ if (field.key === key) {
+ return field
+ }
+ }
+ }
+ throw new Error(`Unknown onboarding setting key: ${key}`)
+}
diff --git a/be/apps/dashboard/src/pages/(main)/index.sync.tsx b/be/apps/dashboard/src/pages/(main)/index.sync.tsx
index 483f0daf..0ace669b 100644
--- a/be/apps/dashboard/src/pages/(main)/index.sync.tsx
+++ b/be/apps/dashboard/src/pages/(main)/index.sync.tsx
@@ -1 +1,3 @@
-export const Component = () => null
+import { OnboardingWizard } from '~/modules/onboarding/components/OnboardingWizard'
+
+export const Component = () =>
diff --git a/be/apps/dashboard/src/pages/(main)/login.tsx b/be/apps/dashboard/src/pages/(main)/login.tsx
new file mode 100644
index 00000000..7519cfa9
--- /dev/null
+++ b/be/apps/dashboard/src/pages/(main)/login.tsx
@@ -0,0 +1,101 @@
+import { clsxm } from '@afilmory/utils'
+
+export const Component = () => (
+
+
+
+
+ {/* Linear gradient border y axis (right) */}
+
+
+
+
+)
diff --git a/be/apps/dashboard/src/providers/root-providers.tsx b/be/apps/dashboard/src/providers/root-providers.tsx
index 38e45076..09881c6e 100644
--- a/be/apps/dashboard/src/providers/root-providers.tsx
+++ b/be/apps/dashboard/src/providers/root-providers.tsx
@@ -1,17 +1,16 @@
+import { ModalContainer } from '@afilmory/ui'
+import { Toaster } from '@afilmory/ui/sonner.jsx'
+import { Spring } from '@afilmory/utils'
import { QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'jotai'
import { LazyMotion, MotionConfig } from 'motion/react'
import type { FC, PropsWithChildren } from 'react'
-import { ModalContainer } from '@afilmory/ui'
-import { Toaster } from '@afilmory/ui'
import { jotaiStore } from '~/lib/jotai'
import { queryClient } from '~/lib/query-client'
-import { Spring } from '@afilmory/utils'
import { ContextMenuProvider } from './context-menu-provider'
import { EventProvider } from './event-provider'
-import { SettingSync } from './setting-sync'
import { StableRouterProvider } from './stable-router-provider'
const loadFeatures = () =>
@@ -23,7 +22,7 @@ export const RootProviders: FC = ({ children }) => (
-
+
{children}
diff --git a/be/apps/dashboard/src/providers/setting-sync.tsx b/be/apps/dashboard/src/providers/setting-sync.tsx
deleted file mode 100644
index 85620e21..00000000
--- a/be/apps/dashboard/src/providers/setting-sync.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { useSyncThemeark } from '~/hooks/common'
-
-const useUISettingSync = () => {
- useSyncThemeark()
-}
-
-export const SettingSync = () => {
- useUISettingSync()
-
- return null
-}
diff --git a/be/apps/dashboard/src/styles/tailwind.css b/be/apps/dashboard/src/styles/tailwind.css
index b01b516c..b59453c0 100644
--- a/be/apps/dashboard/src/styles/tailwind.css
+++ b/be/apps/dashboard/src/styles/tailwind.css
@@ -8,7 +8,8 @@
@import '@pastel-palette/tailwindcss/dist/theme-oklch.css';
-@source "./src/**/*.{js,jsx,ts,tsx}";
+@source "../**/*.{js,jsx,ts,tsx}";
+@source "../../node_modules/@afilmory/ui/src/**/*.tsx";
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
[data-hand-cursor='true'] {
diff --git a/be/apps/dashboard/src/vite-env.d.ts b/be/apps/dashboard/src/vite-env.d.ts
index 11f02fe2..1cb5d89c 100644
--- a/be/apps/dashboard/src/vite-env.d.ts
+++ b/be/apps/dashboard/src/vite-env.d.ts
@@ -1 +1,9 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_CORE_API_BASE?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/be/apps/dashboard/vite.config.ts b/be/apps/dashboard/vite.config.ts
index 1c25bc41..a56273b3 100644
--- a/be/apps/dashboard/vite.config.ts
+++ b/be/apps/dashboard/vite.config.ts
@@ -8,6 +8,7 @@ import { checker } from 'vite-plugin-checker'
import { routeBuilderPlugin } from 'vite-plugin-route-builder'
import tsconfigPaths from 'vite-tsconfig-paths'
+import { astPlugin } from '../../../plugins/vite/ast'
import PKG from './package.json'
const ROOT = fileURLToPath(new URL('./', import.meta.url))
@@ -30,6 +31,7 @@ export default defineConfig({
outputPath: `${resolve(ROOT, './src/generated-routes.ts')}`,
enableInDev: true,
}),
+ astPlugin,
],
define: {
APP_DEV_CWD: JSON.stringify(process.cwd()),
diff --git a/package.json b/package.json
index 8b8e54cb..f6dc5c01 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@clack/prompts": "^0.11.0",
"@innei/prettier": "1.0.0",
"@types/node": "24.9.1",
+ "ast-kit": "2.1.3",
"consola": "3.4.2",
"dotenv-expand": "catalog:",
"eslint": "9.38.0",
@@ -54,6 +55,7 @@
"tailwindcss": "catalog:",
"tsx": "4.20.6",
"typescript": "catalog:",
+ "unplugin-ast": "0.15.3",
"vite": "7.1.12",
"vite-bundle-analyzer": "1.2.3",
"vite-plugin-babel": "1.3.2",
diff --git a/packages/builder/src/builder/builder.ts b/packages/builder/src/builder/builder.ts
index 615c0a58..bead509b 100644
--- a/packages/builder/src/builder/builder.ts
+++ b/packages/builder/src/builder/builder.ts
@@ -1,6 +1,5 @@
import path from 'node:path'
-
-import { builderConfig } from '@builder'
+import { deserialize as v8Deserialize, serialize as v8Serialize } from 'node:v8'
import { thumbnailExists } from '../image/thumbnail.js'
import { logger } from '../logger/index.js'
@@ -44,42 +43,19 @@ export class AfilmoryBuilder {
private storageManager: StorageManager
private config: BuilderConfig
- constructor(config?: Partial) {
- // 合并用户配置和默认配置
- this.config = this.mergeConfig(builderConfig, config)
+ constructor(config: BuilderConfig) {
+ // 创建配置副本,避免外部修改
+ this.config = cloneConfig(config)
// 创建存储管理器
this.storageManager = new StorageManager(this.config.storage)
- // 配置日志级别
+ // 配置日志级别(保留接口以便未来扩展)
this.configureLogging()
}
- private mergeConfig(
- baseConfig: BuilderConfig,
- userConfig?: Partial,
- ): BuilderConfig {
- if (!userConfig) return baseConfig
-
- return {
- repo: { ...baseConfig.repo, ...userConfig.repo },
- storage: { ...baseConfig.storage, ...userConfig.storage },
- options: { ...baseConfig.options, ...userConfig.options },
- logging: { ...baseConfig.logging, ...userConfig.logging },
- performance: {
- ...baseConfig.performance,
- ...userConfig.performance,
- worker: {
- ...baseConfig.performance.worker,
- ...userConfig.performance?.worker,
- },
- },
- }
- }
-
private configureLogging(): void {
- // 这里可以根据配置调整日志设置
- // 目前日志配置在 logger 模块中处理
+ // 日志配置在 logger 模块中处理,保留方法以兼容未来扩展
}
async buildManifest(options: BuilderOptions): Promise {
@@ -214,6 +190,7 @@ export class AfilmoryBuilder {
existingManifestMap,
livePhotoMap,
imageObjects: tasksToProcess,
+ builderConfig: this.getConfig(),
},
})
@@ -257,6 +234,7 @@ export class AfilmoryBuilder {
existingManifestMap,
legacyLivePhotoMap,
processorOptions,
+ this,
)
})
}
@@ -416,7 +394,7 @@ export class AfilmoryBuilder {
* 获取当前配置
*/
getConfig(): BuilderConfig {
- return { ...this.config }
+ return cloneConfig(this.config)
}
/**
@@ -542,5 +520,16 @@ export class AfilmoryBuilder {
}
}
-// 导出默认的构建器实例
-export const defaultBuilder = new AfilmoryBuilder()
+function cloneConfig(value: T): T {
+ const maybeStructuredClone = (
+ globalThis as typeof globalThis & {
+ structuredClone?: (input: U) => U
+ }
+ ).structuredClone
+
+ if (typeof maybeStructuredClone === 'function') {
+ return maybeStructuredClone(value)
+ }
+
+ return v8Deserialize(v8Serialize(value))
+}
diff --git a/packages/builder/src/builder/index.ts b/packages/builder/src/builder/index.ts
index 07dd53b7..faf3c867 100644
--- a/packages/builder/src/builder/index.ts
+++ b/packages/builder/src/builder/index.ts
@@ -1,2 +1,2 @@
export type { BuilderOptions, BuilderResult } from './builder.js'
-export { AfilmoryBuilder, defaultBuilder } from './builder.js'
+export { AfilmoryBuilder } from './builder.js'
diff --git a/packages/builder/src/cli.ts b/packages/builder/src/cli.ts
index a31d7a41..bd409e72 100644
--- a/packages/builder/src/cli.ts
+++ b/packages/builder/src/cli.ts
@@ -10,11 +10,13 @@ import process from 'node:process'
import { builderConfig } from '@builder'
import { $ } from 'execa'
-import { defaultBuilder } from './builder/index.js'
+import { AfilmoryBuilder } from './builder/index.js'
import { logger } from './logger/index.js'
import { workdir } from './path.js'
import { runAsWorker } from './runAsWorker.js'
+const cliBuilder = new AfilmoryBuilder(builderConfig)
+
/**
* 推送更新后的 manifest 到远程仓库
*/
@@ -262,7 +264,7 @@ async function main() {
// 显示配置信息
if (args.has('--config')) {
- const config = defaultBuilder.getConfig()
+ const config = cliBuilder.getConfig()
logger.main.info('🔧 当前配置:')
logger.main.info(` 存储提供商:${config.storage.provider}`)
@@ -322,7 +324,7 @@ async function main() {
runMode = '强制刷新缩略图'
}
- const config = defaultBuilder.getConfig()
+ const config = cliBuilder.getConfig()
const concurrencyLimit = config.performance.worker.workerCount
const finalConcurrency = concurrencyLimit ?? config.options.defaultConcurrency
const processingMode = config.performance.worker.useClusterMode
@@ -337,7 +339,7 @@ async function main() {
environmentCheck()
// 启动构建过程
- const buildResult = await defaultBuilder.buildManifest({
+ const buildResult = await cliBuilder.buildManifest({
isForceMode,
isForceManifest,
isForceThumbnails,
diff --git a/packages/builder/src/index.ts b/packages/builder/src/index.ts
index 84c1f42c..87d89fdd 100644
--- a/packages/builder/src/index.ts
+++ b/packages/builder/src/index.ts
@@ -1,6 +1,6 @@
export * from '../../utils/src/u8array.js'
export type { BuilderOptions, BuilderResult } from './builder/index.js'
-export { AfilmoryBuilder, defaultBuilder } from './builder/index.js'
+export { AfilmoryBuilder } from './builder/index.js'
export type {
PhotoProcessingContext,
ProcessedImageData,
diff --git a/packages/builder/src/photo/README.md b/packages/builder/src/photo/README.md
index c956b029..b23e0da8 100644
--- a/packages/builder/src/photo/README.md
+++ b/packages/builder/src/photo/README.md
@@ -67,7 +67,16 @@ const loggers = createPhotoProcessingLoggers(workerId, baseLogger)
setGlobalLoggers(loggers)
// 处理照片
-const result = await processPhoto(obj, index, workerId, totalImages, existingManifestMap, livePhotoMap, options)
+const result = await processPhoto(
+ obj,
+ index,
+ workerId,
+ totalImages,
+ existingManifestMap,
+ livePhotoMap,
+ options,
+ builder,
+)
```
#### 单独使用各个模块
@@ -80,7 +89,11 @@ import {
} from './index.js'
// Live Photo 处理
-const livePhotoResult = processLivePhoto(photoKey, livePhotoMap)
+const livePhotoResult = processLivePhoto(
+ photoKey,
+ livePhotoMap,
+ builder.getStorageManager(),
+)
// 缩略图处理
const thumbnailResult = await processThumbnailAndBlurhash(imageBuffer, photoId, width, height, existingItem, options)
diff --git a/packages/builder/src/photo/image-pipeline.ts b/packages/builder/src/photo/image-pipeline.ts
index 625bfc9f..77028330 100644
--- a/packages/builder/src/photo/image-pipeline.ts
+++ b/packages/builder/src/photo/image-pipeline.ts
@@ -6,7 +6,6 @@ import type { _Object } from '@aws-sdk/client-s3'
import sharp from 'sharp'
import type { AfilmoryBuilder } from '../builder/builder.js'
-import { defaultBuilder } from '../builder/builder.js'
import {
convertBmpToJpegSharpInstance,
getImageMetadataWithSharp,
@@ -43,22 +42,15 @@ export interface PhotoProcessingContext {
* 预处理图片数据
* 包括获取原始数据、格式转换、BMP 处理等
*/
-function resolveBuilder(builder?: AfilmoryBuilder): AfilmoryBuilder {
- return builder ?? defaultBuilder
-}
-
export async function preprocessImage(
photoKey: string,
- builder?: AfilmoryBuilder,
+ builder: AfilmoryBuilder,
): Promise<{ rawBuffer: Buffer; processedBuffer: Buffer } | null> {
const loggers = getGlobalLoggers()
- const activeBuilder = resolveBuilder(builder)
try {
// 获取图片数据
- const rawImageBuffer = await activeBuilder
- .getStorageManager()
- .getFile(photoKey)
+ const rawImageBuffer = await builder.getStorageManager().getFile(photoKey)
if (!rawImageBuffer) {
loggers.image.error(`无法获取图片数据:${photoKey}`)
return null
@@ -136,15 +128,15 @@ export async function processImageWithSharp(
}
/**
- * 生成带摘要后缀的ID
- * @param s3Key S3键
- * @returns 带摘要后缀的ID
+ * 生成带摘要后缀的 ID
+ * @param s3Key S3 键
+ * @returns 带摘要后缀的 ID
*/
async function generatePhotoId(
s3Key: string,
- builder?: AfilmoryBuilder,
+ builder: AfilmoryBuilder,
): Promise {
- const { options } = resolveBuilder(builder).getConfig()
+ const { options } = builder.getConfig()
const { digestSuffixLength } = options
if (!digestSuffixLength || digestSuffixLength <= 0) {
return path.basename(s3Key, path.extname(s3Key))
@@ -162,18 +154,17 @@ async function generatePhotoId(
*/
export async function executePhotoProcessingPipeline(
context: PhotoProcessingContext,
- builder?: AfilmoryBuilder,
+ builder: AfilmoryBuilder,
): Promise {
const { photoKey, obj, existingItem, livePhotoMap, options } = context
const loggers = getGlobalLoggers()
- const activeBuilder = resolveBuilder(builder)
-
// Generate the actual photo ID with digest suffix
- const photoId = await generatePhotoId(photoKey, activeBuilder)
+ const photoId = await generatePhotoId(photoKey, builder)
+ const storageManager = builder.getStorageManager()
try {
// 1. 预处理图片
- const imageData = await preprocessImage(photoKey, activeBuilder)
+ const imageData = await preprocessImage(photoKey, builder)
if (!imageData) return null
// 2. 处理图片并创建 Sharp 实例
@@ -214,7 +205,11 @@ export async function executePhotoProcessingPipeline(
const photoInfo = extractPhotoInfo(photoKey, exifData)
// 7. 处理 Live Photo
- const livePhotoResult = await processLivePhoto(photoKey, livePhotoMap)
+ const livePhotoResult = await processLivePhoto(
+ photoKey,
+ livePhotoMap,
+ storageManager,
+ )
// 8. 构建照片清单项
const aspectRatio = metadata.width / metadata.height
@@ -225,9 +220,7 @@ export async function executePhotoProcessingPipeline(
description: photoInfo.description,
dateTaken: photoInfo.dateTaken,
tags: photoInfo.tags,
- originalUrl: await activeBuilder
- .getStorageManager()
- .generatePublicUrl(photoKey),
+ originalUrl: await storageManager.generatePublicUrl(photoKey),
thumbnailUrl: thumbnailResult.thumbnailUrl,
thumbHash: thumbnailResult.thumbHash
? compressUint8Array(thumbnailResult.thumbHash)
@@ -261,7 +254,7 @@ export async function executePhotoProcessingPipeline(
*/
export async function processPhotoWithPipeline(
context: PhotoProcessingContext,
- builder?: AfilmoryBuilder,
+ builder: AfilmoryBuilder,
): Promise<{
item: PhotoManifestItem | null
type: 'new' | 'processed' | 'skipped' | 'failed'
@@ -269,8 +262,7 @@ export async function processPhotoWithPipeline(
const { photoKey, existingItem, obj, options } = context
const loggers = getGlobalLoggers()
- const activeBuilder = resolveBuilder(builder)
- const photoId = await generatePhotoId(photoKey, activeBuilder)
+ const photoId = await generatePhotoId(photoKey, builder)
// 检查是否需要处理
const { shouldProcess, reason } = await shouldProcessPhoto(
@@ -294,10 +286,7 @@ export async function processPhotoWithPipeline(
}
// 执行处理管道
- const processedItem = await executePhotoProcessingPipeline(
- context,
- activeBuilder,
- )
+ const processedItem = await executePhotoProcessingPipeline(context, builder)
if (!processedItem) {
return { item: null, type: 'failed' }
diff --git a/packages/builder/src/photo/live-photo-handler.ts b/packages/builder/src/photo/live-photo-handler.ts
index be2846da..9a39de50 100644
--- a/packages/builder/src/photo/live-photo-handler.ts
+++ b/packages/builder/src/photo/live-photo-handler.ts
@@ -1,6 +1,6 @@
import type { _Object } from '@aws-sdk/client-s3'
-import { defaultBuilder } from '../builder/builder.js'
+import type { StorageManager } from '../storage/index.js'
import type { StorageObject } from '../storage/interfaces.js'
import { getGlobalLoggers } from './logger-adapter.js'
@@ -14,11 +14,13 @@ export interface LivePhotoResult {
* 检测并处理 Live Photo
* @param photoKey 照片的 S3 key
* @param livePhotoMap Live Photo 映射表
+ * @param storageManager 存储管理器,用于生成公共访问链接
* @returns Live Photo 处理结果
*/
export async function processLivePhoto(
photoKey: string,
livePhotoMap: Map,
+ storageManager: StorageManager,
): Promise {
const loggers = getGlobalLoggers()
const livePhotoVideo = livePhotoMap.get(photoKey)
@@ -40,9 +42,7 @@ export async function processLivePhoto(
return { isLivePhoto: false }
}
- const livePhotoVideoUrl = await defaultBuilder
- .getStorageManager()
- .generatePublicUrl(videoKey)
+ const livePhotoVideoUrl = await storageManager.generatePublicUrl(videoKey)
loggers.image.info(`📱 检测到 Live Photo:${photoKey} -> ${videoKey}`)
diff --git a/packages/builder/src/photo/processor.ts b/packages/builder/src/photo/processor.ts
index 2c5c7a2b..55a398b8 100644
--- a/packages/builder/src/photo/processor.ts
+++ b/packages/builder/src/photo/processor.ts
@@ -1,5 +1,6 @@
import type { _Object } from '@aws-sdk/client-s3'
+import type { AfilmoryBuilder } from '../builder/builder.js'
import { logger } from '../logger/index.js'
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
import type { PhotoProcessingContext } from './image-pipeline.js'
@@ -24,6 +25,7 @@ export async function processPhoto(
existingManifestMap: Map,
livePhotoMap: Map,
options: PhotoProcessorOptions,
+ builder: AfilmoryBuilder,
): Promise {
const key = obj.Key
if (!key) {
@@ -49,5 +51,5 @@ export async function processPhoto(
}
// 使用处理管道
- return await processPhotoWithPipeline(context)
+ return await processPhotoWithPipeline(context, builder)
}
diff --git a/packages/builder/src/runAsWorker.ts b/packages/builder/src/runAsWorker.ts
index c7da8c0b..1cdba180 100644
--- a/packages/builder/src/runAsWorker.ts
+++ b/packages/builder/src/runAsWorker.ts
@@ -1,7 +1,9 @@
import process from 'node:process'
import { deserialize } from 'node:v8'
+import { AfilmoryBuilder } from './builder/builder.js'
import type { StorageObject } from './storage/interfaces'
+import type { BuilderConfig } from './types/config.js'
import type { PhotoManifestItem } from './types/photo'
import type {
BatchTaskMessage,
@@ -23,6 +25,7 @@ interface SharedData {
existingManifestMap: Map
livePhotoMap: Map
imageObjects: StorageObject[]
+ builderConfig: BuilderConfig
}
// Worker 进程处理逻辑
@@ -36,6 +39,7 @@ export async function runAsWorker() {
let imageObjects: StorageObject[]
let existingManifestMap: Map
let livePhotoMap: Map
+ let builder: AfilmoryBuilder
// 初始化函数,从主进程接收共享数据
const initializeWorker = async (
@@ -51,6 +55,7 @@ export async function runAsWorker() {
imageObjects = sharedData.imageObjects
existingManifestMap = sharedData.existingManifestMap
livePhotoMap = sharedData.livePhotoMap
+ builder = new AfilmoryBuilder(sharedData.builderConfig)
isInitialized = true
}
@@ -108,6 +113,7 @@ export async function runAsWorker() {
existingManifestMap,
legacyLivePhotoMap,
processorOptions,
+ builder,
)
// 发送结果回主进程
@@ -184,6 +190,7 @@ export async function runAsWorker() {
isForceManifest: process.env.FORCE_MANIFEST === 'true',
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
},
+ builder,
)
// 添加成功结果
@@ -268,7 +275,7 @@ export async function runAsWorker() {
process.send({ type: 'init-complete', workerId })
}
} catch (error) {
- console.error('Worker 初始化失败:', error)
+ console.error('Worker initialization failed', error)
process.exit(1)
}
return
diff --git a/packages/builder/src/worker/cluster-pool.ts b/packages/builder/src/worker/cluster-pool.ts
index 05312ce4..21ebe2b1 100644
--- a/packages/builder/src/worker/cluster-pool.ts
+++ b/packages/builder/src/worker/cluster-pool.ts
@@ -7,6 +7,7 @@ import { serialize } from 'node:v8'
import type { Logger } from '../logger/index.js'
import { logger } from '../logger/index.js'
+import type { BuilderConfig } from '../types/config.js'
export interface ClusterPoolOptions {
concurrency: number
@@ -18,6 +19,7 @@ export interface ClusterPoolOptions {
existingManifestMap: Map
livePhotoMap: Map
imageObjects: any[]
+ builderConfig: BuilderConfig
}
}
@@ -283,6 +285,7 @@ export class ClusterPool extends EventEmitter {
existingManifestMap: this.sharedData.existingManifestMap,
livePhotoMap: this.sharedData.livePhotoMap,
imageObjects: this.sharedData.imageObjects,
+ builderConfig: this.sharedData.builderConfig,
})
// 将 Buffer 转换为数组以通过 IPC 传输
diff --git a/packages/ui/package.json b/packages/ui/package.json
index fe58ddd4..c9a96a9e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -26,12 +26,14 @@
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tooltip": "1.2.8",
"clsx": "^2.1.1",
+ "jotai": "^2.15.0",
"motion": "^12.23.24",
"react-intersection-observer": "9.16.0",
"sonner": "2.0.7",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
- "thumbhash": "0.1.1"
+ "thumbhash": "0.1.1",
+ "usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@types/react": "^19.2.2",
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 7f97db26..f40d222b 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -7,9 +7,11 @@ export * from './dropdown-menu'
export * from './hover-card'
export * from './icons'
export * from './lazy-image'
+export * from './modal'
export * from './portal'
export * from './scroll-areas'
export * from './sonner'
+export * from './sonner'
export * from './switch'
export * from './thumbhash'
export * from './tooltip'
diff --git a/packages/ui/src/modal/Dialog.tsx b/packages/ui/src/modal/Dialog.tsx
new file mode 100644
index 00000000..e1b0390d
--- /dev/null
+++ b/packages/ui/src/modal/Dialog.tsx
@@ -0,0 +1,259 @@
+'use client'
+
+import { clsxm } from '@afilmory/utils'
+import * as DialogPrimitive from '@radix-ui/react-dialog'
+import type { HTMLMotionProps, Transition } from 'motion/react'
+import { AnimatePresence, m as motion } from 'motion/react'
+import * as React from 'react'
+
+type DialogContextType = {
+ isOpen: boolean
+}
+
+const DialogContext = React.createContext(
+ undefined,
+)
+
+function useDialog(): DialogContextType {
+ const context = React.use(DialogContext)
+ if (!context) {
+ throw new Error('useDialog must be used within a Dialog')
+ }
+ return context
+}
+
+type DialogProps = React.ComponentProps
+
+function Dialog({ children, ...props }: DialogProps) {
+ const [isOpen, setIsOpen] = React.useState(
+ props?.open ?? props?.defaultOpen ?? false,
+ )
+
+ React.useEffect(() => {
+ if (props?.open !== undefined) setIsOpen(props.open)
+ }, [props?.open])
+
+ const handleOpenChange = React.useCallback(
+ (open: boolean) => {
+ setIsOpen(open)
+ props.onOpenChange?.(open)
+ },
+ [props],
+ )
+
+ return (
+ ({ isOpen }), [isOpen])}>
+
+ {children}
+
+
+ )
+}
+
+type DialogTriggerProps = React.ComponentProps
+
+function DialogTrigger(props: DialogTriggerProps) {
+ return
+}
+
+type DialogPortalProps = React.ComponentProps
+
+function DialogPortal(props: DialogPortalProps) {
+ return
+}
+
+type DialogCloseProps = React.ComponentProps
+
+function DialogClose(props: DialogCloseProps) {
+ return (
+
+ )
+}
+
+type DialogOverlayProps = React.ComponentProps
+
+function DialogOverlay({ className, ...props }: DialogOverlayProps) {
+ return (
+
+ )
+}
+
+type FlipDirection = 'top' | 'bottom' | 'left' | 'right'
+
+export type DialogContentProps = React.ComponentProps<
+ typeof DialogPrimitive.Content
+> &
+ HTMLMotionProps<'div'> & {
+ from?: FlipDirection
+ transition?: Transition
+ }
+
+const contentTransition: Transition = {
+ type: 'spring',
+ stiffness: 150,
+ damping: 25,
+}
+function DialogContent({
+ className,
+ children,
+ from = 'top',
+ transition = contentTransition,
+ ...props
+}: DialogContentProps) {
+ const { isOpen } = useDialog()
+
+ const initialRotation = from === 'top' || from === 'left' ? '20deg' : '-20deg'
+ const isVertical = from === 'top' || from === 'bottom'
+ const rotateAxis = isVertical ? 'rotateX' : 'rotateY'
+
+ return (
+
+ {isOpen && (
+
+
+
+
+
+
+ {children}
+
+
+ Close
+
+
+
+
+ )}
+
+ )
+}
+
+type DialogHeaderProps = React.ComponentProps<'div'>
+
+function DialogHeader({ className, ...props }: DialogHeaderProps) {
+ return (
+
+ )
+}
+
+type DialogFooterProps = React.ComponentProps<'div'>
+
+function DialogFooter({ className, ...props }: DialogFooterProps) {
+ return (
+
+ )
+}
+
+type DialogTitleProps = React.ComponentProps
+
+function DialogTitle({ className, ...props }: DialogTitleProps) {
+ return (
+
+ )
+}
+
+type DialogDescriptionProps = React.ComponentProps<
+ typeof DialogPrimitive.Description
+>
+
+function DialogDescription({ className, ...props }: DialogDescriptionProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ type DialogCloseProps,
+ DialogContent,
+ type DialogContextType,
+ DialogDescription,
+ type DialogDescriptionProps,
+ DialogFooter,
+ type DialogFooterProps,
+ DialogHeader,
+ type DialogHeaderProps,
+ DialogOverlay,
+ type DialogOverlayProps,
+ DialogPortal,
+ type DialogPortalProps,
+ type DialogProps,
+ DialogTitle,
+ type DialogTitleProps,
+ DialogTrigger,
+ type DialogTriggerProps,
+}
diff --git a/packages/ui/src/modal/ModalContainer.tsx b/packages/ui/src/modal/ModalContainer.tsx
new file mode 100644
index 00000000..6075a93a
--- /dev/null
+++ b/packages/ui/src/modal/ModalContainer.tsx
@@ -0,0 +1,81 @@
+import { clsxm, Spring } from '@afilmory/utils'
+import { Dialog } from '@radix-ui/react-dialog'
+import { useAtomValue, useStore } from 'jotai'
+import { AnimatePresence } from 'motion/react'
+import { useEffect, useMemo, useState } from 'react'
+import { useEventCallback } from 'usehooks-ts'
+
+import { DialogContent } from './Dialog'
+import type { ModalItem } from './ModalManager'
+import { Modal, modalItemsAtom } from './ModalManager'
+import { modalStore } from './store'
+import type { ModalComponent } from './types'
+
+export function ModalContainer() {
+ const items = useAtomValue(modalItemsAtom, { store: modalStore })
+
+ return (
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ )
+}
+
+function ModalWrapper({ item }: { item: ModalItem }) {
+ const [open, setOpen] = useState(true)
+
+ useEffect(() => {
+ Modal.__registerCloser(item.id, () => setOpen(false))
+ return () => {
+ Modal.__unregisterCloser(item.id)
+ }
+ }, [item.id])
+
+ const dismiss = useMemo(
+ () => () => {
+ setOpen(false)
+ },
+ [],
+ )
+
+ const handleOpenChange = (o: boolean) => {
+ setOpen(o)
+ }
+
+ // After exit animation, remove from store
+ const handleAnimationComplete = useEventCallback(() => {
+ if (!open) {
+ const items = modalStore.get(modalItemsAtom)
+ modalStore.set(
+ modalItemsAtom,
+ items.filter((m) => m.id !== item.id),
+ )
+ }
+ })
+
+ const Component = item.component as ModalComponent
+
+ const { contentProps, contentClassName } = Component
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/modal/ModalManager.ts b/packages/ui/src/modal/ModalManager.ts
new file mode 100644
index 00000000..c746b27e
--- /dev/null
+++ b/packages/ui/src/modal/ModalManager.ts
@@ -0,0 +1,48 @@
+import { atom } from 'jotai'
+
+import { modalStore } from './store'
+import type { ModalComponent, ModalContentConfig, ModalItem } from './types'
+
+export const modalItemsAtom = atom([])
+
+const modalCloseRegistry = new Map void>()
+
+export const Modal = {
+ present(
+ Component: ModalComponent
,
+ props?: P,
+ modalContent?: ModalContentConfig,
+ ): string {
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
+ const items = modalStore.get(modalItemsAtom)
+ modalStore.set(modalItemsAtom, [
+ ...items,
+ { id, component: Component as ModalComponent, props, modalContent },
+ ])
+ return id
+ },
+
+ dismiss(id: string): void {
+ const closer = modalCloseRegistry.get(id)
+ if (closer) {
+ closer()
+ return
+ }
+ // Fallback: remove immediately if closer not registered yet
+ const items = modalStore.get(modalItemsAtom)
+ modalStore.set(
+ modalItemsAtom,
+ items.filter((m) => m.id !== id),
+ )
+ },
+
+ /** Internal: used by container to manage close hooks */
+ __registerCloser(id: string, fn: () => void) {
+ modalCloseRegistry.set(id, fn)
+ },
+ __unregisterCloser(id: string) {
+ modalCloseRegistry.delete(id)
+ },
+}
+
+export { type ModalItem } from './types'
diff --git a/packages/ui/src/modal/index.ts b/packages/ui/src/modal/index.ts
new file mode 100644
index 00000000..79202e72
--- /dev/null
+++ b/packages/ui/src/modal/index.ts
@@ -0,0 +1,3 @@
+export * from './ModalContainer'
+export * from './ModalManager'
+export * from './types'
diff --git a/packages/ui/src/modal/store.ts b/packages/ui/src/modal/store.ts
new file mode 100644
index 00000000..8b0782e7
--- /dev/null
+++ b/packages/ui/src/modal/store.ts
@@ -0,0 +1,3 @@
+import { createStore } from 'jotai'
+
+export const modalStore = createStore()
diff --git a/packages/ui/src/modal/types.ts b/packages/ui/src/modal/types.ts
new file mode 100644
index 00000000..a045f4f1
--- /dev/null
+++ b/packages/ui/src/modal/types.ts
@@ -0,0 +1,27 @@
+import type * as DialogPrimitive from '@radix-ui/react-dialog'
+import type { HTMLMotionProps } from 'motion/react'
+import type { FC } from 'react'
+
+export type DialogContentProps = React.ComponentProps<
+ typeof DialogPrimitive.Content
+> &
+ HTMLMotionProps<'div'>
+
+export type ModalComponentProps = {
+ modalId: string
+ dismiss: () => void
+}
+
+export type ModalComponent = FC & {
+ contentProps?: Partial
+ contentClassName?: string
+}
+
+export type ModalContentConfig = Partial
+
+export type ModalItem = {
+ id: string
+ component: ModalComponent
+ props?: unknown
+ modalContent?: ModalContentConfig
+}
diff --git a/plugins/vite/ast.ts b/plugins/vite/ast.ts
new file mode 100644
index 00000000..f5856778
--- /dev/null
+++ b/plugins/vite/ast.ts
@@ -0,0 +1,37 @@
+import { isTaggedFunctionCallOf } from 'ast-kit'
+import type { Transformer } from 'unplugin-ast'
+import { RemoveWrapperFunction } from 'unplugin-ast/transformers'
+import AST from 'unplugin-ast/vite'
+
+// Custom transformer for tw function that compresses template strings
+const TwTransformer: Transformer = {
+ // @ts-ignore
+ onNode: (node) => isTaggedFunctionCallOf(node, ['tw']),
+ transform(node) {
+ if (node.type === 'TaggedTemplateExpression') {
+ const { quasi } = node
+
+ // Process template literals
+ if (quasi.type === 'TemplateLiteral') {
+ // Get the raw string content
+ const rawString = quasi.quasis[0]?.value?.raw || ''
+
+ // Compress the string: remove extra whitespace, newlines, and normalize spaces
+ const compressedString = rawString
+ .replaceAll(/\s+/g, ' ') // Replace multiple whitespace with single space
+ .trim() // Remove leading and trailing whitespace
+
+ // Update the template literal
+ quasi.quasis[0].value.raw = compressedString
+ quasi.quasis[0].value.cooked = compressedString
+ }
+
+ return quasi
+ }
+ return node.arguments[0]
+ },
+}
+
+export const astPlugin = AST({
+ transformer: [TwTransformer, RemoveWrapperFunction(['definePageHandle'])],
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index af49855a..ab287374 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,6 +84,9 @@ importers:
'@types/node':
specifier: 24.9.1
version: 24.9.1
+ ast-kit:
+ specifier: 2.1.3
+ version: 2.1.3
consola:
specifier: 3.4.2
version: 3.4.2
@@ -123,6 +126,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 5.9.3
+ unplugin-ast:
+ specifier: 0.15.3
+ version: 0.15.3
vite:
specifier: 7.1.12
version: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
@@ -1234,6 +1240,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ jotai:
+ specifier: ^2.15.0
+ version: 2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
motion:
specifier: ^12.23.24
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1252,6 +1261,9 @@ importers:
thumbhash:
specifier: 0.1.1
version: 0.1.1
+ usehooks-ts:
+ specifier: ^3.1.1
+ version: 3.1.1(react@19.2.0)
devDependencies:
'@types/react':
specifier: ^19.2.2
@@ -1431,6 +1443,7 @@ packages:
'@aws-sdk/middleware-expect-continue@3.916.0':
resolution: {integrity: sha512-p7TMLZZ/j5NbC7/cz7xNgxLz/OHYuh91MeCZdCedJiyh3rx6gunFtl9eiDtrh+Y8hjs0EwR0zYIuhd6pL1O8zg==}
engines: {node: '>=18.0.0'}
+ deprecated: '@aws-sdk/middleware-expect-continue v3.916.0 contains an accidental console.log statement (https://github.com/aws/aws-sdk-js-v3/pull/7454), please upgrade to v3.917.0+'
'@aws-sdk/middleware-flexible-checksums@3.916.0':
resolution: {integrity: sha512-CBRRg6slHHBYAm26AWY/pECHK0vVO/peDoNhZiAzUNt4jV6VftotjszEJ904pKGOr7/86CfZxtCnP3CCs3lQjA==}
@@ -1663,11 +1676,6 @@ packages:
resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
engines: {node: '>=6.9.0'}
- '@babel/parser@7.28.0':
- resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
'@babel/parser@7.28.4':
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
engines: {node: '>=6.0.0'}
@@ -12055,7 +12063,7 @@ snapshots:
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4)
'@babel/helpers': 7.28.4
- '@babel/parser': 7.28.4
+ '@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
@@ -12090,7 +12098,7 @@ snapshots:
'@babel/generator@7.28.3':
dependencies:
- '@babel/parser': 7.28.4
+ '@babel/parser': 7.28.5
'@babel/types': 7.28.4
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
@@ -12106,7 +12114,7 @@ snapshots:
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
- '@babel/types': 7.28.4
+ '@babel/types': 7.28.5
'@babel/helper-compilation-targets@7.27.2':
dependencies:
@@ -12176,7 +12184,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
- '@babel/types': 7.28.4
+ '@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -12190,7 +12198,7 @@ snapshots:
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.28.0
- '@babel/types': 7.28.2
+ '@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -12214,7 +12222,7 @@ snapshots:
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
- '@babel/types': 7.28.4
+ '@babel/types': 7.28.5
'@babel/helper-plugin-utils@7.27.1': {}
@@ -12248,7 +12256,7 @@ snapshots:
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
- '@babel/types': 7.28.4
+ '@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -12273,10 +12281,6 @@ snapshots:
'@babel/template': 7.27.2
'@babel/types': 7.28.4
- '@babel/parser@7.28.0':
- dependencies:
- '@babel/types': 7.28.2
-
'@babel/parser@7.28.4':
dependencies:
'@babel/types': 7.28.4
@@ -12795,17 +12799,17 @@ snapshots:
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
- '@babel/parser': 7.28.0
+ '@babel/parser': 7.28.5
'@babel/types': 7.28.2
'@babel/traverse@7.28.0':
dependencies:
'@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.3
+ '@babel/generator': 7.28.5
'@babel/helper-globals': 7.28.0
- '@babel/parser': 7.28.4
+ '@babel/parser': 7.28.5
'@babel/template': 7.27.2
- '@babel/types': 7.28.2
+ '@babel/types': 7.28.5
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -12813,9 +12817,9 @@ snapshots:
'@babel/traverse@7.28.4':
dependencies:
'@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.3
+ '@babel/generator': 7.28.5
'@babel/helper-globals': 7.28.0
- '@babel/parser': 7.28.4
+ '@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/types': 7.28.4
debug: 4.4.3(supports-color@5.5.0)
@@ -16751,7 +16755,7 @@ snapshots:
'@unocss/rule-utils@66.5.1':
dependencies:
'@unocss/core': 66.5.1
- magic-string: 0.30.19
+ magic-string: 0.30.21
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
optional: true
@@ -16931,7 +16935,7 @@ snapshots:
'@vue/compiler-core@3.5.21':
dependencies:
- '@babel/parser': 7.28.4
+ '@babel/parser': 7.28.5
'@vue/shared': 3.5.21
entities: 4.5.0
estree-walker: 2.0.2
@@ -17147,7 +17151,7 @@ snapshots:
ast-kit@2.1.3:
dependencies:
- '@babel/parser': 7.28.4
+ '@babel/parser': 7.28.5
pathe: 2.0.3
ast-v8-to-istanbul@0.3.8:
@@ -20025,7 +20029,7 @@ snapshots:
magic-string-ast@1.0.3:
dependencies:
- magic-string: 0.30.19
+ magic-string: 0.30.21
magic-string@0.25.9:
dependencies:
@@ -22252,7 +22256,7 @@ snapshots:
rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.44)(typescript@5.9.3):
dependencies:
'@babel/generator': 7.28.3
- '@babel/parser': 7.28.4
+ '@babel/parser': 7.28.5
'@babel/types': 7.28.4
ast-kit: 2.1.3
birpc: 2.6.1
@@ -23185,7 +23189,7 @@ snapshots:
unplugin-ast@0.15.3:
dependencies:
- '@babel/generator': 7.28.3
+ '@babel/generator': 7.28.5
ast-kit: 2.1.3
magic-string-ast: 1.0.3
unplugin: 2.3.10