feat(server): onboarding init

This commit is contained in:
Innei
2025-10-26 18:37:56 +08:00
parent 37a28d10c3
commit bb3088208d
55 changed files with 2552 additions and 143 deletions

View File

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

@@ -0,0 +1 @@
../../.env

View File

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

View File

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

View File

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

View 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>
)
}

View File

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

View 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',
})

View File

@@ -1 +0,0 @@
export { cn } from './cn'

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.',
},
]

View File

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

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

View 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}`)
}

View File

@@ -1 +1,3 @@
export const Component = () => null
import { OnboardingWizard } from '~/modules/onboarding/components/OnboardingWizard'
export const Component = () => <OnboardingWizard />

View 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>
)

View File

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

View File

@@ -1,11 +0,0 @@
import { useSyncThemeark } from '~/hooks/common'
const useUISettingSync = () => {
useSyncThemeark()
}
export const SettingSync = () => {
useUISettingSync()
return null
}

View File

@@ -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'] {

View File

@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CORE_API_BASE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
export type { BuilderOptions, BuilderResult } from './builder.js'
export { AfilmoryBuilder, defaultBuilder } from './builder.js'
export { AfilmoryBuilder } from './builder.js'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
}

View 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>
)
}

View 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'

View File

@@ -0,0 +1,3 @@
export * from './ModalContainer'
export * from './ModalManager'
export * from './types'

View File

@@ -0,0 +1,3 @@
import { createStore } from 'jotai'
export const modalStore = createStore()

View 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
View 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
View File

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