mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(server): onboarding init
This commit is contained in:
2
apps/ssr/next-env.d.ts
vendored
2
apps/ssr/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import './.next/types/routes.d.ts'
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
1
be/apps/core/.env
Symbolic link
1
be/apps/core/.env
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.env
|
||||
@@ -22,21 +22,12 @@ export type ProcessPhotoOptions = {
|
||||
livePhotoMap?: Map<string, StorageObject>
|
||||
processorOptions?: Partial<PhotoProcessorOptions>
|
||||
builder?: AfilmoryBuilder
|
||||
builderConfig?: BuilderConfig
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class PhotoBuilderService {
|
||||
private readonly defaultBuilder: AfilmoryBuilder
|
||||
|
||||
constructor() {
|
||||
this.defaultBuilder = new AfilmoryBuilder()
|
||||
}
|
||||
|
||||
getDefaultBuilder(): AfilmoryBuilder {
|
||||
return this.defaultBuilder
|
||||
}
|
||||
|
||||
createBuilder(config?: Partial<BuilderConfig>): AfilmoryBuilder {
|
||||
createBuilder(config: BuilderConfig): AfilmoryBuilder {
|
||||
return new AfilmoryBuilder(config)
|
||||
}
|
||||
|
||||
@@ -56,8 +47,8 @@ export class PhotoBuilderService {
|
||||
object: StorageObject,
|
||||
options?: ProcessPhotoOptions,
|
||||
): Promise<Awaited<ReturnType<typeof processPhotoWithPipeline>>> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
<div>
|
||||
<label
|
||||
htmlFor="field-id"
|
||||
className="block text-sm font-medium text-text mb-2"
|
||||
>
|
||||
Field Label
|
||||
</label>
|
||||
<input
|
||||
id="field-id"
|
||||
type="text"
|
||||
className={cx(
|
||||
'w-full border border-fill-tertiary bg-background',
|
||||
'px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/40',
|
||||
'transition-all duration-200',
|
||||
hasError && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
placeholder="Enter value..."
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-red">{error}</p>}
|
||||
</div>
|
||||
```
|
||||
|
||||
Example (textarea):
|
||||
|
||||
```tsx
|
||||
<textarea
|
||||
className={cx(
|
||||
'w-full border border-fill-tertiary bg-background',
|
||||
'px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/40',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
rows={3}
|
||||
placeholder="Enter description..."
|
||||
/>
|
||||
```
|
||||
|
||||
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
|
||||
<button
|
||||
type="submit"
|
||||
className={cx(
|
||||
'px-6 py-2.5',
|
||||
'bg-accent text-white font-medium text-sm',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-accent/90',
|
||||
'active:scale-[0.98]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/40',
|
||||
)}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
```
|
||||
|
||||
Example (ghost button):
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
'px-6 py-2.5',
|
||||
'text-sm font-medium text-text-secondary',
|
||||
'hover:text-text hover:bg-fill/50',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
```
|
||||
|
||||
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: `<div className="h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent" />`
|
||||
- **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
|
||||
<div className="relative bg-background-tertiary">
|
||||
{/* Top border */}
|
||||
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text to-transparent" />
|
||||
|
||||
{/* Right border */}
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text to-transparent" />
|
||||
|
||||
{/* Bottom border */}
|
||||
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text to-transparent" />
|
||||
|
||||
{/* Left border */}
|
||||
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text to-transparent" />
|
||||
|
||||
<div className="p-12">{/* Content */}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
15
be/apps/dashboard/src/components/common/Footer.tsx
Normal file
15
be/apps/dashboard/src/components/common/Footer.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
export const Footer: FC = () => {
|
||||
return (
|
||||
<footer className="mt-10 flex flex-col items-center gap-2 pb-8 text-center text-xs text-text-tertiary">
|
||||
<p className="tracking-[0.24em] uppercase text-text-tertiary/70">
|
||||
afilmory dashboard
|
||||
</p>
|
||||
<p className="text-text-tertiary">
|
||||
Crafted for the first-run experience • You can revisit onboarding from
|
||||
settings later
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
7
be/apps/dashboard/src/global.d.ts
vendored
7
be/apps/dashboard/src/global.d.ts
vendored
@@ -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<P = object> = FC<Prettify<ComponentType & P>>
|
||||
|
||||
8
be/apps/dashboard/src/lib/api-client.ts
Normal file
8
be/apps/dashboard/src/lib/api-client.ts
Normal file
@@ -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',
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export { cn } from './cn'
|
||||
42
be/apps/dashboard/src/modules/onboarding/api.ts
Normal file
42
be/apps/dashboard/src/modules/onboarding/api.ts
Normal file
@@ -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<OnboardingStatusResponse>('/onboarding/status', {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
export const postOnboardingInit = async (payload: OnboardingInitPayload) =>
|
||||
await coreApi<OnboardingInitResponse>('/onboarding/init', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
@@ -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<LinearBorderBoxProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
}) => (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Top border */}
|
||||
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text to-transparent" />
|
||||
|
||||
{/* Right border */}
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text to-transparent" />
|
||||
|
||||
{/* Bottom border */}
|
||||
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text to-transparent" />
|
||||
|
||||
{/* Left border */}
|
||||
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text to-transparent" />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<OnboardingFooterProps> = ({
|
||||
onBack,
|
||||
onNext,
|
||||
disableBack,
|
||||
isSubmitting,
|
||||
isLastStep,
|
||||
}) => (
|
||||
<footer className="p-8 pt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
Need to revisit an earlier step? Use the sidebar or go back to adjust your
|
||||
inputs.
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="px-6 py-2.5 min-w-[120px] text-sm font-medium text-text-secondary hover:text-text hover:bg-fill/50 transition-all duration-200"
|
||||
onClick={onBack}
|
||||
disabled={disableBack || isSubmitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="px-6 py-2.5 min-w-[140px] bg-accent text-white text-sm font-medium hover:bg-accent/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200"
|
||||
onClick={onNext}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isLastStep ? 'Initialize' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
@@ -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<OnboardingHeaderProps> = ({
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
step,
|
||||
}) => (
|
||||
<header className="p-8 pb-6">
|
||||
<div className="inline-flex items-center gap-2 bg-accent/10 px-3 py-1.5 text-xs font-medium text-accent">
|
||||
Step {currentStepIndex + 1} of {totalSteps}
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-bold text-text">{step.title}</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-text-secondary">
|
||||
{step.description}
|
||||
</p>
|
||||
</header>
|
||||
)
|
||||
@@ -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<OnboardingSidebarProps> = ({
|
||||
currentStepIndex,
|
||||
canNavigateTo,
|
||||
onStepSelect,
|
||||
}) => (
|
||||
<aside className="hidden flex-col gap-6 p-6 lg:flex min-h-full">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-accent">
|
||||
Setup Journey
|
||||
</p>
|
||||
<h2 className="mt-2 text-base font-semibold text-text">
|
||||
Launch your photo platform
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{ONBOARDING_STEPS.map((step, index) => {
|
||||
const status: 'done' | 'current' | 'pending' =
|
||||
index < currentStepIndex
|
||||
? 'done'
|
||||
: index === currentStepIndex
|
||||
? 'current'
|
||||
: 'pending'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
className={cx(
|
||||
'w-full px-4 py-3 text-left transition-all duration-200',
|
||||
status === 'done' && 'bg-accent/5 text-text hover:bg-accent/10',
|
||||
status === 'current' && 'bg-accent/10 text-text',
|
||||
status === 'pending' && 'text-text-tertiary hover:bg-fill/50',
|
||||
!canNavigateTo(index) && 'cursor-default opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (canNavigateTo(index)) {
|
||||
onStepSelect(index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cx(
|
||||
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200',
|
||||
status === 'done' && 'bg-accent text-white',
|
||||
status === 'current' && 'bg-accent text-white',
|
||||
status === 'pending' && 'bg-fill text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{status === 'done' ? (
|
||||
<i className="i-mingcute-check-fill text-sm" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{step.title}</p>
|
||||
<p className="text-xs text-text-tertiary truncate">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-auto pt-4">
|
||||
{/* Horizontal divider */}
|
||||
<div className="h-[0.5px] bg-linear-to-r from-transparent via-text/30 to-transparent mb-4" />
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-text-tertiary mb-2">
|
||||
<span>Progress</span>
|
||||
<span className="font-medium">{stepProgress(currentStepIndex)}%</span>
|
||||
</div>
|
||||
<div className="relative h-1 bg-fill/30">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-accent transition-all duration-300"
|
||||
style={{ width: `${stepProgress(currentStepIndex)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
@@ -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 <LoadingState />
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return <ErrorState />
|
||||
}
|
||||
|
||||
if (query.data?.initialized) {
|
||||
return <InitializedState />
|
||||
}
|
||||
|
||||
const stepContent: Record<typeof currentStep.id, ReactNode> = {
|
||||
welcome: <WelcomeStep />,
|
||||
tenant: (
|
||||
<TenantStep
|
||||
tenant={tenant}
|
||||
errors={errors}
|
||||
onNameChange={updateTenantName}
|
||||
onSlugChange={updateTenantSlug}
|
||||
onDomainChange={updateTenantDomain}
|
||||
onSuggestSlug={suggestSlug}
|
||||
/>
|
||||
),
|
||||
admin: (
|
||||
<AdminStep admin={admin} errors={errors} onChange={updateAdminField} />
|
||||
),
|
||||
settings: (
|
||||
<SettingsStep
|
||||
settingsState={settingsState}
|
||||
errors={errors}
|
||||
onToggle={toggleSetting}
|
||||
onChange={updateSettingValue}
|
||||
/>
|
||||
),
|
||||
review: (
|
||||
<ReviewStep
|
||||
tenant={tenant}
|
||||
admin={admin}
|
||||
reviewSettings={reviewSettings}
|
||||
acknowledged={acknowledged}
|
||||
errors={errors}
|
||||
onAcknowledgeChange={setAcknowledged}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center px-4 py-10">
|
||||
<div className="w-full max-w-7xl flex flex-row bg-background-tertiary">
|
||||
{/* Top border */}
|
||||
<div className="absolute left-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text to-transparent" />
|
||||
|
||||
{/* Left border */}
|
||||
<div className="absolute top-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text to-transparent" />
|
||||
|
||||
<div className="grid lg:grid-cols-[280px_1fr]">
|
||||
{/* Sidebar */}
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-text/20" />
|
||||
<OnboardingSidebar
|
||||
currentStepIndex={currentStepIndex}
|
||||
canNavigateTo={canNavigateTo}
|
||||
onStepSelect={jumpToStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex flex-col">
|
||||
<OnboardingHeader
|
||||
currentStepIndex={currentStepIndex}
|
||||
totalSteps={ONBOARDING_STEPS.length}
|
||||
step={currentStep}
|
||||
/>
|
||||
|
||||
{/* Horizontal divider */}
|
||||
<div className="h-[0.5px] bg-linear-to-r from-transparent via-text/30 to-transparent" />
|
||||
|
||||
<section className="p-12">{stepContent[currentStep.id]}</section>
|
||||
|
||||
{/* Horizontal divider */}
|
||||
<div className="h-[0.5px] bg-linear-to-r from-transparent via-text/30 to-transparent" />
|
||||
|
||||
<OnboardingFooter
|
||||
onBack={goToPrevious}
|
||||
onNext={goToNext}
|
||||
disableBack={currentStepIndex === 0}
|
||||
isSubmitting={mutation.isPending}
|
||||
isLastStep={currentStepIndex === ONBOARDING_STEPS.length - 1}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
<div className="flex flex-col shrink-0">
|
||||
{/* Right border */}
|
||||
<div className="absolute bottom-0 top-0 w-[0.5px] bg-linear-to-b from-transparent via-text to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 w-[2px]">
|
||||
{/* Bottom border */}
|
||||
<div className="absolute left-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
export const ErrorState: FC = () => (
|
||||
<div className="min-h-screen flex items-center justify-center px-6">
|
||||
<div
|
||||
className="max-w-lg w-full rounded-3xl border border-red/20 bg-fill-secondary/60 p-8 text-center backdrop-blur-2xl"
|
||||
style={{
|
||||
boxShadow:
|
||||
'0 22px 40px color-mix(in srgb, var(--color-red) 12%, transparent)',
|
||||
}}
|
||||
>
|
||||
<i className="i-mingcute-alert-fill text-red text-3xl mb-3" />
|
||||
<h1 className="text-2xl font-semibold text-text mb-2">
|
||||
Unable to connect
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
The dashboard could not reach the core service. Ensure the backend is
|
||||
running and refresh the page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
export const InitializedState: FC = () => (
|
||||
<div className="min-h-screen bg-background px-6 py-16">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-8">
|
||||
<header className="text-center">
|
||||
<p className="inline-flex items-center gap-2 rounded-full border border-accent/20 bg-accent/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-accent">
|
||||
Initialized
|
||||
</p>
|
||||
<h1 className="mt-6 text-4xl font-semibold text-text">
|
||||
Afilmory Control Center is ready
|
||||
</h1>
|
||||
<p className="mt-4 text-base text-text-secondary">
|
||||
Your workspace has already been provisioned. Sign in with an existing
|
||||
administrator account or invite new members from the dashboard.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-3xl border border-accent/20 bg-fill-secondary/50 p-8 backdrop-blur-2xl">
|
||||
<h2 className="text-lg font-semibold text-text mb-2">Next steps</h2>
|
||||
<ul className="space-y-3 text-sm text-text-secondary">
|
||||
<li className="flex items-start gap-3">
|
||||
<i className="i-mingcute-shield-user-fill mt-0.5 text-accent" />
|
||||
<span>
|
||||
Sign in as the tenant administrator you created during onboarding.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<i className="i-mingcute-lock-password-fill mt-0.5 text-accent" />
|
||||
<span>
|
||||
Look up the super administrator credentials printed in the core
|
||||
service logs if you have not already stored them.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<i className="i-mingcute-settings-3-fill mt-0.5 text-accent" />
|
||||
<span>
|
||||
Open the settings panel to refine integrations, email providers,
|
||||
and workspace preferences.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
export const LoadingState: FC = () => (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="rounded-3xl border border-accent/20 px-8 py-6 text-center shadow-2xl">
|
||||
<div className="flex items-center justify-center gap-3 text-text-secondary">
|
||||
<i className="i-mingcute-loading-3-fill animate-spin text-accent" />
|
||||
<span className="text-sm font-medium uppercase tracking-[0.3em] text-text-tertiary">
|
||||
Preparing onboarding experience
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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 extends keyof AdminFormState>(
|
||||
field: Field,
|
||||
value: AdminFormState[Field],
|
||||
) => void
|
||||
}
|
||||
|
||||
export const AdminStep: FC<AdminStepProps> = ({ admin, errors, onChange }) => (
|
||||
<form className="space-y-6" onSubmit={(event) => event.preventDefault()}>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text" htmlFor="admin-name">
|
||||
Administrator name
|
||||
</label>
|
||||
<input
|
||||
id="admin-name"
|
||||
value={admin.name}
|
||||
onInput={(event) => onChange('name', event.currentTarget.value)}
|
||||
placeholder="Studio Admin"
|
||||
autoComplete="name"
|
||||
className={cx(
|
||||
'mt-2 w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
errors['admin.name'] && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
/>
|
||||
{errors['admin.name'] && (
|
||||
<p className="mt-1 text-xs text-red">{errors['admin.name']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text" htmlFor="admin-email">
|
||||
Administrator email
|
||||
</label>
|
||||
<input
|
||||
id="admin-email"
|
||||
value={admin.email}
|
||||
onInput={(event) => onChange('email', event.currentTarget.value)}
|
||||
placeholder="admin@afilmory.app"
|
||||
autoComplete="email"
|
||||
className={cx(
|
||||
'mt-2 w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
errors['admin.email'] && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
/>
|
||||
{errors['admin.email'] && (
|
||||
<p className="mt-1 text-xs text-red">{errors['admin.email']}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
className="text-sm font-medium text-text"
|
||||
htmlFor="admin-password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
value={admin.password}
|
||||
onInput={(event) => onChange('password', event.currentTarget.value)}
|
||||
placeholder="Minimum 8 characters"
|
||||
autoComplete="new-password"
|
||||
className={cx(
|
||||
'mt-2 w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
errors['admin.password'] && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
/>
|
||||
{errors['admin.password'] && (
|
||||
<p className="mt-1 text-xs text-red">{errors['admin.password']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="text-sm font-medium text-text"
|
||||
htmlFor="admin-confirm"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="admin-confirm"
|
||||
type="password"
|
||||
value={admin.confirmPassword}
|
||||
onInput={(event) =>
|
||||
onChange('confirmPassword', event.currentTarget.value)
|
||||
}
|
||||
placeholder="Repeat password"
|
||||
autoComplete="new-password"
|
||||
className={cx(
|
||||
'mt-2 w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
errors['admin.confirmPassword'] &&
|
||||
'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
/>
|
||||
{errors['admin.confirmPassword'] && (
|
||||
<p className="mt-1 text-xs text-red">
|
||||
{errors['admin.confirmPassword']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-tertiary">
|
||||
After onboarding completes a global super administrator will also be
|
||||
generated. Those credentials are written to the backend logs for security
|
||||
reasons.
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
@@ -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<ReviewStepProps> = ({
|
||||
tenant,
|
||||
admin,
|
||||
reviewSettings,
|
||||
acknowledged,
|
||||
errors,
|
||||
onAcknowledgeChange,
|
||||
}) => (
|
||||
<div className="space-y-6">
|
||||
<div className="border border-fill-tertiary bg-background p-6">
|
||||
<h3 className="text-sm font-semibold text-text mb-4">Tenant summary</h3>
|
||||
<dl className="grid gap-4 text-sm text-text-secondary sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-semibold text-text">Name</dt>
|
||||
<dd className="mt-1">{tenant.name || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-text">Slug</dt>
|
||||
<dd className="mt-1">{tenant.slug || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-text">Domain</dt>
|
||||
<dd className="mt-1">{tenant.domain || 'Not configured'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="border border-fill-tertiary bg-background p-6">
|
||||
<h3 className="text-sm font-semibold text-text mb-4">Administrator</h3>
|
||||
<dl className="grid gap-4 text-sm text-text-secondary sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-semibold text-text">Name</dt>
|
||||
<dd className="mt-1">{admin.name || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-text">Email</dt>
|
||||
<dd className="mt-1">{admin.email || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-text">Password</dt>
|
||||
<dd className="mt-1">{maskSecret(admin.password)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="border border-fill-tertiary bg-background p-6">
|
||||
<h3 className="text-sm font-semibold text-text mb-4">
|
||||
Enabled integrations
|
||||
</h3>
|
||||
{reviewSettings.length === 0 ? (
|
||||
<p className="text-sm text-text-tertiary">
|
||||
No integrations configured. You can enable OAuth providers, AI
|
||||
services, or maps later from the settings panel.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{reviewSettings.map(({ definition, value }) => (
|
||||
<li
|
||||
key={definition.key}
|
||||
className="border border-fill-tertiary bg-background px-4 py-3"
|
||||
>
|
||||
<p className="text-sm font-medium text-text">
|
||||
{definition.label}
|
||||
</p>
|
||||
<p className="mt-1 text-text-tertiary">
|
||||
{definition.sensitive ? maskSecret(value) : value}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-orange/40 bg-orange/5 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-orange mb-2">
|
||||
<i className="i-mingcute-alert-fill" />
|
||||
Important
|
||||
</h3>
|
||||
<p className="text-sm text-orange/90 leading-relaxed">
|
||||
Once you click initialize, the application becomes locked to this
|
||||
initial administrator. The core service will print super administrator
|
||||
credentials to stdout exactly once.
|
||||
</p>
|
||||
<label className="mt-4 flex items-start gap-3 text-sm text-text">
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
onCheckedChange={(checked) => onAcknowledgeChange(Boolean(checked))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
I have noted the super administrator credentials will appear in the
|
||||
backend logs and understand this action cannot be repeated.
|
||||
</span>
|
||||
</label>
|
||||
{errors['review.ack'] && (
|
||||
<p className="mt-2 text-xs text-red">{errors['review.ack']}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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<SettingsStepProps> = ({
|
||||
settingsState,
|
||||
errors,
|
||||
onToggle,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="space-y-6">
|
||||
{ONBOARDING_SETTING_SECTIONS.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="border border-fill-tertiary bg-background p-6"
|
||||
>
|
||||
<header className="flex flex-col gap-1 mb-5">
|
||||
<h3 className="text-sm font-semibold text-text">{section.title}</h3>
|
||||
<p className="text-sm text-text-tertiary">{section.description}</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
{section.fields.map((field) => {
|
||||
const state = settingsState[field.key]
|
||||
const errorKey = `settings.${field.key}`
|
||||
const hasError = Boolean(errors[errorKey])
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className="border border-fill-tertiary bg-fill p-5"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-text">
|
||||
{field.label}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.enabled ? 'primary' : 'ghost'}
|
||||
className={cx(
|
||||
'rounded-xl px-6 py-2.5 min-w-[110px] text-sm font-medium transition-all duration-200',
|
||||
state.enabled
|
||||
? 'bg-accent text-white hover:bg-accent/90'
|
||||
: 'border border-fill-tertiary text-text-secondary hover:bg-fill-tertiary',
|
||||
)}
|
||||
onClick={() => onToggle(field.key, !state.enabled)}
|
||||
>
|
||||
{state.enabled ? 'Enabled' : 'Enable'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state.enabled && (
|
||||
<div className="mt-4">
|
||||
{field.multiline ? (
|
||||
<textarea
|
||||
value={state.value}
|
||||
onInput={(event) =>
|
||||
onChange(field.key, event.currentTarget.value)
|
||||
}
|
||||
className={cx(
|
||||
'w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
hasError && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
rows={3}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field.sensitive ? 'password' : 'text'}
|
||||
value={state.value}
|
||||
onInput={(event) =>
|
||||
onChange(field.key, event.currentTarget.value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
className={cx(
|
||||
'w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
hasError && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
{errors[errorKey] && (
|
||||
<p className="mt-1 text-xs text-red">
|
||||
{errors[errorKey]}
|
||||
</p>
|
||||
)}
|
||||
{field.helper && (
|
||||
<p className="mt-2 text-[11px] text-text-tertiary">
|
||||
{field.helper}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -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<TenantStepProps> = ({
|
||||
tenant,
|
||||
errors,
|
||||
onNameChange,
|
||||
onSlugChange,
|
||||
onDomainChange,
|
||||
onSuggestSlug,
|
||||
}) => (
|
||||
<form className="space-y-6" onSubmit={(event) => event.preventDefault()}>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text" htmlFor="tenant-name">
|
||||
Workspace name
|
||||
</label>
|
||||
<input
|
||||
id="tenant-name"
|
||||
value={tenant.name}
|
||||
onInput={(event) => onNameChange(event.currentTarget.value)}
|
||||
placeholder="Afilmory Studio"
|
||||
className={cx(
|
||||
'mt-2 w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
errors['tenant.name'] && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
{errors['tenant.name'] && (
|
||||
<p className="mt-1 text-xs text-red">{errors['tenant.name']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text" htmlFor="tenant-slug">
|
||||
Tenant slug
|
||||
</label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
id="tenant-slug"
|
||||
value={tenant.slug}
|
||||
onInput={(event) => onSlugChange(event.currentTarget.value)}
|
||||
placeholder="afilmory"
|
||||
className={cx(
|
||||
'w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
errors['tenant.slug'] && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="px-6 py-2.5 min-w-[120px] text-sm font-medium text-text-secondary hover:text-text hover:bg-fill/50 transition-all duration-200"
|
||||
onClick={onSuggestSlug}
|
||||
>
|
||||
Suggest
|
||||
</Button>
|
||||
</div>
|
||||
{errors['tenant.slug'] && (
|
||||
<p className="mt-1 text-xs text-red">{errors['tenant.slug']}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text" htmlFor="tenant-domain">
|
||||
Custom domain (optional)
|
||||
</label>
|
||||
<input
|
||||
id="tenant-domain"
|
||||
value={tenant.domain}
|
||||
onInput={(event) => onDomainChange(event.currentTarget.value)}
|
||||
placeholder="gallery.afilmory.app"
|
||||
className={cx(
|
||||
'mt-2 w-full border border-fill-tertiary bg-background px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-all duration-200',
|
||||
errors['tenant.domain'] && 'border-red/60 focus:ring-red/30',
|
||||
)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors['tenant.domain'] && (
|
||||
<p className="mt-1 text-xs text-red">{errors['tenant.domain']}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-text-tertiary">
|
||||
Domains enable automatic routing for tenant-specific dashboards.
|
||||
Configure DNS separately after initialization.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
export const WelcomeStep: FC = () => (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-text mb-3">
|
||||
<i className="i-mingcute-compass-2-fill text-accent" />
|
||||
What happens next
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">
|
||||
We will create your first tenant, provision an administrator, and
|
||||
bootstrap super administrator access for emergency management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent" />
|
||||
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-text mb-3">
|
||||
<i className="i-mingcute-shield-fill text-accent" />
|
||||
Requirements
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-text-secondary">
|
||||
<li className="flex items-start gap-2">
|
||||
<i className="i-mingcute-check-line text-accent mt-0.5 flex-shrink-0" />
|
||||
<span>Ensure the core service can access email providers or authentication
|
||||
callbacks if configured.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<i className="i-mingcute-check-line text-accent mt-0.5 flex-shrink-0" />
|
||||
<span>Keep the terminal open to capture the super administrator
|
||||
credentials printed after initialization.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<i className="i-mingcute-check-line text-accent mt-0.5 flex-shrink-0" />
|
||||
<span>Prepare OAuth credentials or continue without them; you can
|
||||
configure integrations later.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text mb-4">What we will collect</h3>
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text">Tenant profile</p>
|
||||
<p className="mt-1.5 text-sm text-text-secondary leading-relaxed">
|
||||
Workspace name, slug, and optional domain mapping.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text">Admin account</p>
|
||||
<p className="mt-1.5 text-sm text-text-secondary leading-relaxed">
|
||||
Email, name, and secure password for the first administrator.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text">Integrations</p>
|
||||
<p className="mt-1.5 text-sm text-text-secondary leading-relaxed">
|
||||
Optional OAuth, AI, and map provider credentials.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
171
be/apps/dashboard/src/modules/onboarding/constants.ts
Normal file
171
be/apps/dashboard/src/modules/onboarding/constants.ts
Normal file
@@ -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.',
|
||||
},
|
||||
]
|
||||
@@ -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<TenantFormState>({
|
||||
name: '',
|
||||
slug: '',
|
||||
domain: '',
|
||||
})
|
||||
const [slugLocked, setSlugLocked] = useState(false)
|
||||
const [admin, setAdmin] = useState<AdminFormState>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const [settingsState, setSettingsState] = useState<SettingFormState>(
|
||||
createInitialSettingsState,
|
||||
)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [errors, setErrors] = useState<OnboardingErrors>({})
|
||||
|
||||
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<Record<OnboardingStepId, () => 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,
|
||||
}
|
||||
}
|
||||
24
be/apps/dashboard/src/modules/onboarding/types.ts
Normal file
24
be/apps/dashboard/src/modules/onboarding/types.ts
Normal file
@@ -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<string, string>
|
||||
52
be/apps/dashboard/src/modules/onboarding/utils.ts
Normal file
52
be/apps/dashboard/src/modules/onboarding/utils.ts
Normal file
@@ -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}`)
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export const Component = () => null
|
||||
import { OnboardingWizard } from '~/modules/onboarding/components/OnboardingWizard'
|
||||
|
||||
export const Component = () => <OnboardingWizard />
|
||||
|
||||
101
be/apps/dashboard/src/pages/(main)/login.tsx
Normal file
101
be/apps/dashboard/src/pages/(main)/login.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
|
||||
export const Component = () => (
|
||||
<div
|
||||
data-theme="dark"
|
||||
className="size-full min-h-dvh flex-1 relative flex flex-col"
|
||||
>
|
||||
<div className="bg-background flex-1 flex items-center justify-center">
|
||||
<form className={'w-[600px] bg-background-tertiary'}>
|
||||
{/* Linear gradient border y axis (left) */}
|
||||
<div className="absolute top-0 bg-linear-to-b from-transparent via-text to-transparent w-[0.5px] bottom-0" />
|
||||
|
||||
{/* Linear gradient border x axis (top) */}
|
||||
<div className="absolute left-0 bg-linear-to-r from-transparent via-text to-transparent h-[0.5px] right-0" />
|
||||
|
||||
<div className="p-12">
|
||||
<h1 className="text-3xl font-bold mb-10 text-text-primary">Login</h1>
|
||||
|
||||
{/* Username Field */}
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-text mb-2"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className={clsxm(
|
||||
'w-full rounded-xl border border-fill-tertiary bg-background',
|
||||
'px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/40',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="mb-8">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-text mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className={clsxm(
|
||||
'w-full rounded-xl border border-fill-tertiary bg-background',
|
||||
'px-3 py-2 text-sm text-text placeholder:text-text-tertiary/70',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/40',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className={clsxm(
|
||||
'w-full rounded-xl px-6 py-2.5 relative overflow-hidden',
|
||||
'bg-accent text-white font-medium text-sm',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-accent/90',
|
||||
'active:scale-[0.98]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/40',
|
||||
)}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
|
||||
{/* Additional Links */}
|
||||
<div className="mt-6 flex items-center justify-between text-sm">
|
||||
<a
|
||||
href="#"
|
||||
className="text-text-tertiary hover:text-accent transition-colors duration-200"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-text-tertiary hover:text-accent transition-colors duration-200"
|
||||
>
|
||||
Create account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* Linear gradient border x axis (bottom) */}
|
||||
<div className="absolute left-0 bg-linear-to-r from-transparent via-text to-transparent h-[0.5px] right-0" />
|
||||
</form>
|
||||
<div>
|
||||
{/* Linear gradient border y axis (right) */}
|
||||
<div className="absolute top-0 bg-linear-to-b from-transparent via-text to-transparent w-[0.5px] bottom-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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<PropsWithChildren> = ({ children }) => (
|
||||
<Provider store={jotaiStore}>
|
||||
<EventProvider />
|
||||
<StableRouterProvider />
|
||||
<SettingSync />
|
||||
|
||||
<ContextMenuProvider />
|
||||
<ModalContainer />
|
||||
{children}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useSyncThemeark } from '~/hooks/common'
|
||||
|
||||
const useUISettingSync = () => {
|
||||
useSyncThemeark()
|
||||
}
|
||||
|
||||
export const SettingSync = () => {
|
||||
useUISettingSync()
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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'] {
|
||||
|
||||
8
be/apps/dashboard/src/vite-env.d.ts
vendored
8
be/apps/dashboard/src/vite-env.d.ts
vendored
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_CORE_API_BASE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<BuilderConfig>) {
|
||||
// 合并用户配置和默认配置
|
||||
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>,
|
||||
): 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<BuilderResult> {
|
||||
@@ -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<T>(value: T): T {
|
||||
const maybeStructuredClone = (
|
||||
globalThis as typeof globalThis & {
|
||||
structuredClone?: <U>(input: U) => U
|
||||
}
|
||||
).structuredClone
|
||||
|
||||
if (typeof maybeStructuredClone === 'function') {
|
||||
return maybeStructuredClone(value)
|
||||
}
|
||||
|
||||
return v8Deserialize(v8Serialize(value))
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export type { BuilderOptions, BuilderResult } from './builder.js'
|
||||
export { AfilmoryBuilder, defaultBuilder } from './builder.js'
|
||||
export { AfilmoryBuilder } from './builder.js'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string> {
|
||||
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<PhotoManifestItem | null> {
|
||||
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' }
|
||||
|
||||
@@ -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<string, _Object | StorageObject>,
|
||||
storageManager: StorageManager,
|
||||
): Promise<LivePhotoResult> {
|
||||
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}`)
|
||||
|
||||
|
||||
@@ -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<string, PhotoManifestItem>,
|
||||
livePhotoMap: Map<string, _Object>,
|
||||
options: PhotoProcessorOptions,
|
||||
builder: AfilmoryBuilder,
|
||||
): Promise<ProcessPhotoResult> {
|
||||
const key = obj.Key
|
||||
if (!key) {
|
||||
@@ -49,5 +51,5 @@ export async function processPhoto(
|
||||
}
|
||||
|
||||
// 使用处理管道
|
||||
return await processPhotoWithPipeline(context)
|
||||
return await processPhotoWithPipeline(context, builder)
|
||||
}
|
||||
|
||||
@@ -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<string, PhotoManifestItem>
|
||||
livePhotoMap: Map<string, StorageObject>
|
||||
imageObjects: StorageObject[]
|
||||
builderConfig: BuilderConfig
|
||||
}
|
||||
|
||||
// Worker 进程处理逻辑
|
||||
@@ -36,6 +39,7 @@ export async function runAsWorker() {
|
||||
let imageObjects: StorageObject[]
|
||||
let existingManifestMap: Map<string, PhotoManifestItem>
|
||||
let livePhotoMap: Map<string, StorageObject>
|
||||
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
|
||||
|
||||
@@ -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<string, any>
|
||||
livePhotoMap: Map<string, any>
|
||||
imageObjects: any[]
|
||||
builderConfig: BuilderConfig
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +285,7 @@ export class ClusterPool<T> extends EventEmitter {
|
||||
existingManifestMap: this.sharedData.existingManifestMap,
|
||||
livePhotoMap: this.sharedData.livePhotoMap,
|
||||
imageObjects: this.sharedData.imageObjects,
|
||||
builderConfig: this.sharedData.builderConfig,
|
||||
})
|
||||
|
||||
// 将 Buffer 转换为数组以通过 IPC 传输
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
259
packages/ui/src/modal/Dialog.tsx
Normal file
259
packages/ui/src/modal/Dialog.tsx
Normal file
@@ -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<DialogContextType | undefined>(
|
||||
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<typeof DialogPrimitive.Root>
|
||||
|
||||
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 (
|
||||
<DialogContext value={React.useMemo(() => ({ isOpen }), [isOpen])}>
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
</DialogContext>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>
|
||||
|
||||
function DialogTrigger(props: DialogTriggerProps) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
type DialogPortalProps = React.ComponentProps<typeof DialogPrimitive.Portal>
|
||||
|
||||
function DialogPortal(props: DialogPortalProps) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>
|
||||
|
||||
function DialogClose(props: DialogCloseProps) {
|
||||
return (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
{...props}
|
||||
className={clsxm('contents', props.className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogOverlayProps = React.ComponentProps<typeof DialogPrimitive.Overlay>
|
||||
|
||||
function DialogOverlay({ className, ...props }: DialogOverlayProps) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={clsxm(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DialogPortal forceMount data-slot="dialog-portal">
|
||||
<DialogOverlay asChild forceMount>
|
||||
<motion.div
|
||||
key="dialog-overlay"
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
/>
|
||||
</DialogOverlay>
|
||||
<DialogPrimitive.Content asChild forceMount {...props}>
|
||||
<motion.div
|
||||
key="dialog-content"
|
||||
data-slot="dialog-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
className={clsxm(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background px-3 pt-4 pb-3 rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="focus:bg-fill data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-3 right-2 flex size-6 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
|
||||
<i
|
||||
className="i-mingcute-close-line h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogHeaderProps = React.ComponentProps<'div'>
|
||||
|
||||
function DialogHeader({ className, ...props }: DialogHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={clsxm(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogFooterProps = React.ComponentProps<'div'>
|
||||
|
||||
function DialogFooter({ className, ...props }: DialogFooterProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={clsxm(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogTitleProps) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={clsxm(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogDescriptionProps = React.ComponentProps<
|
||||
typeof DialogPrimitive.Description
|
||||
>
|
||||
|
||||
function DialogDescription({ className, ...props }: DialogDescriptionProps) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={clsxm('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
81
packages/ui/src/modal/ModalContainer.tsx
Normal file
81
packages/ui/src/modal/ModalContainer.tsx
Normal file
@@ -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 (
|
||||
<div id="global-modal-container">
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((item) => (
|
||||
<ModalWrapper key={item.id} item={item} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<any>
|
||||
|
||||
const { contentProps, contentClassName } = Component
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className={clsxm('w-full max-w-md', contentClassName)}
|
||||
transition={Spring.presets.smooth}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
{...contentProps}
|
||||
{...item.modalContent}
|
||||
>
|
||||
<Component
|
||||
modalId={item.id}
|
||||
dismiss={dismiss}
|
||||
{...(item.props as any)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
48
packages/ui/src/modal/ModalManager.ts
Normal file
48
packages/ui/src/modal/ModalManager.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
import { modalStore } from './store'
|
||||
import type { ModalComponent, ModalContentConfig, ModalItem } from './types'
|
||||
|
||||
export const modalItemsAtom = atom<ModalItem[]>([])
|
||||
|
||||
const modalCloseRegistry = new Map<string, () => void>()
|
||||
|
||||
export const Modal = {
|
||||
present<P = unknown>(
|
||||
Component: ModalComponent<P>,
|
||||
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<any>, 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'
|
||||
3
packages/ui/src/modal/index.ts
Normal file
3
packages/ui/src/modal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ModalContainer'
|
||||
export * from './ModalManager'
|
||||
export * from './types'
|
||||
3
packages/ui/src/modal/store.ts
Normal file
3
packages/ui/src/modal/store.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createStore } from 'jotai'
|
||||
|
||||
export const modalStore = createStore()
|
||||
27
packages/ui/src/modal/types.ts
Normal file
27
packages/ui/src/modal/types.ts
Normal file
@@ -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<P = unknown> = FC<ModalComponentProps & P> & {
|
||||
contentProps?: Partial<DialogContentProps>
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export type ModalContentConfig = Partial<DialogContentProps>
|
||||
|
||||
export type ModalItem = {
|
||||
id: string
|
||||
component: ModalComponent<any>
|
||||
props?: unknown
|
||||
modalContent?: ModalContentConfig
|
||||
}
|
||||
37
plugins/vite/ast.ts
Normal file
37
plugins/vite/ast.ts
Normal file
@@ -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<any> = {
|
||||
// @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'])],
|
||||
})
|
||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user