feat: unify dashboard design

This commit is contained in:
Innei
2025-10-28 13:52:01 +08:00
parent e2d2345b78
commit 7670d58aaa
23 changed files with 1262 additions and 713 deletions

View File

@@ -0,0 +1,158 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import type { Dispatch, ReactNode, SetStateAction } from 'react'
import {
createContext,
use,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { createPortal } from 'react-dom'
type HeaderActionState = {
disabled: boolean
loading: boolean
}
const defaultHeaderActionState: HeaderActionState = {
disabled: false,
loading: false,
}
type MainPageLayoutContextValue = {
headerActionsContainer: HTMLDivElement | null
headerActionState: HeaderActionState
setHeaderActionState: Dispatch<SetStateAction<HeaderActionState>>
registerPortalPresence: (mounted: boolean) => void
}
const MainPageLayoutContext = createContext<MainPageLayoutContextValue | null>(
null,
)
export const useMainPageLayout = () => {
const context = use(MainPageLayoutContext)
if (!context) {
throw new Error('useMainPageLayout must be used within MainPageLayout')
}
return context
}
type MainPageLayoutProps = {
title: string
description?: string
actions?: ReactNode
footer?: ReactNode
children: ReactNode
}
const MainPageLayoutBase = ({
title,
description,
actions,
footer,
children,
}: MainPageLayoutProps) => {
const [headerActionsContainer, setHeaderActionsContainer] =
useState<HTMLDivElement | null>(null)
const [portalMountCount, setPortalMountCount] = useState(0)
const [headerActionState, setHeaderActionState] = useState<HeaderActionState>(
defaultHeaderActionState,
)
const registerPortalPresence = useCallback((mounted: boolean) => {
setPortalMountCount((count) => count + (mounted ? 1 : -1))
}, [])
const assignActionsContainer = useCallback((node: HTMLDivElement | null) => {
setHeaderActionsContainer(node)
}, [])
const contextValue = useMemo<MainPageLayoutContextValue>(
() => ({
headerActionsContainer,
headerActionState,
setHeaderActionState,
registerPortalPresence,
}),
[headerActionsContainer, headerActionState, registerPortalPresence],
)
const showHeaderActions = Boolean(actions) || portalMountCount > 0
return (
<MainPageLayoutContext value={contextValue}>
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6 mt-8"
>
{/* Header - Sharp edges with gradient borders */}
<header className="relative flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
{/* Linear gradient borders */}
<div className="relative space-y-1.5">
<h1 className="text-2xl font-semibold text-text">{title}</h1>
{description ? (
<p className="text-sm text-text-secondary">{description}</p>
) : null}
</div>
{showHeaderActions ? (
<div className="relative flex flex-wrap items-center gap-2 md:justify-end">
{actions}
<div
ref={assignActionsContainer}
className="flex flex-wrap items-center gap-2"
/>
</div>
) : (
<div
ref={assignActionsContainer}
className="relative hidden flex-wrap items-center gap-2 md:flex"
/>
)}
</header>
<section>{children}</section>
{footer ? (
<footer className="relative py-4">
<div className="relative">{footer}</div>
</footer>
) : null}
</m.div>
</MainPageLayoutContext>
)
}
type MainPageLayoutActionsProps = {
children: ReactNode
}
const MainPageLayoutActions = ({ children }: MainPageLayoutActionsProps) => {
const { headerActionsContainer, registerPortalPresence } = useMainPageLayout()
useEffect(() => {
registerPortalPresence(true)
return () => {
registerPortalPresence(false)
}
}, [registerPortalPresence])
if (!headerActionsContainer) {
return null
}
return createPortal(children, headerActionsContainer)
}
export const MainPageLayout = Object.assign(MainPageLayoutBase, {
Actions: MainPageLayoutActions,
})

View File

@@ -30,16 +30,19 @@ export const OnboardingFooter: FC<OnboardingFooterProps> = ({
<Button
type="button"
variant="ghost"
className="rounded-lg shadow-none px-6 py-2.5 min-w-[140px] text-sm font-medium text-text-secondary hover:text-text hover:bg-fill/50 transition-all duration-200"
size="md"
className="min-w-[140px] text-text-secondary hover:text-text hover:bg-fill/50"
onClick={onBack}
isLoading={isSubmitting}
disabled={isSubmitting}
>
Back
</Button>
)}
<Button
type="button"
className="rounded-lg 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"
variant="primary"
size="md"
className="min-w-[140px]"
onClick={onNext}
isLoading={isSubmitting}
>

View File

@@ -1,4 +1,5 @@
import {
Button,
FormHelperText,
Input,
Label,
@@ -26,18 +27,6 @@ import type {
UiSlotComponent,
} from './types'
const glassCardStyles = {
backgroundImage:
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
boxShadow:
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.08)',
} as const
const glassGlowStyles = {
background:
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))',
} as const
export const GlassPanel = ({
className,
children,
@@ -45,17 +34,13 @@ export const GlassPanel = ({
className?: string
children: ReactNode
}) => (
<div
className={clsxm(
'group relative overflow-hidden rounded-2xl border border-accent/20 backdrop-blur-2xl',
className,
)}
style={glassCardStyles}
>
<div
className="pointer-events-none absolute inset-0 rounded-2xl opacity-60"
style={glassGlowStyles}
/>
<div className={clsxm('group relative overflow-hidden -mx-6', className)}>
{/* Linear gradient borders - sharp edges */}
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text/20 to-transparent" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text/20 to-transparent" />
<div className="relative">{children}</div>
</div>
)
@@ -108,13 +93,15 @@ const SecretFieldInput = <Key extends string>({
className="flex-1 bg-background/60"
/>
{component.revealable ? (
<button
<Button
type="button"
onClick={() => setRevealed((prev) => !prev)}
className="h-9 rounded-lg border border-accent/30 px-3 text-xs font-medium text-accent transition-all duration-200 hover:bg-accent/10"
variant="ghost"
size="sm"
className="border border-accent/30 text-accent hover:bg-accent/10"
>
{revealed ? '隐藏' : '显示'}
</button>
</Button>
) : null}
</div>
)
@@ -263,8 +250,14 @@ const renderGroup = <Key extends string>(
return (
<div
key={node.id}
className="rounded-2xl border border-accent/10 bg-accent/2 p-5 backdrop-blur-xl transition-all duration-200"
className="relative bg-accent/2 p-5 transition-all duration-200"
>
{/* Subtle gradient borders for nested groups */}
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-accent/15 to-transparent" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-accent/15 to-transparent" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-accent/15 to-transparent" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-accent/15 to-transparent" />
<div className="flex items-center gap-2">
<SchemaIcon name={node.icon} className="text-accent" />
<h3 className="text-sm font-semibold text-text">{node.title}</h3>
@@ -298,7 +291,7 @@ const renderField = <Key extends string>(
return (
<div
key={field.id}
className="rounded-xl border border-fill/30 bg-background/40 p-4"
className="rounded-lg border border-fill/30 bg-background/40 p-4"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -326,7 +319,7 @@ const renderField = <Key extends string>(
return (
<div
key={field.id}
className="space-y-2 rounded-xl border border-fill-tertiary/40 bg-background/30 p-4"
className="space-y-2 rounded-lg border border-fill-tertiary/40 bg-background/30 p-4"
>
<div className="flex items-start justify-between gap-3">
<div>

View File

@@ -1,7 +1,15 @@
/* eslint-disable react-hooks/refs */
import { clsxm, Spring } from '@afilmory/utils'
import { Button } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import type { SchemaFormRendererProps } from '../../schema-form/SchemaFormRenderer'
import {
@@ -16,6 +24,10 @@ import type {
SettingUiSchemaResponse,
SettingValueState,
} from '../types'
import {
MainPageLayout,
useMainPageLayout,
} from '~/components/layouts/MainPageLayout'
const providerGroupVisibility: Record<string, string> = {
'builder-storage-s3': 's3',
@@ -42,6 +54,8 @@ const buildInitialState = (
export const SettingsForm = () => {
const { data, isLoading, isError, error } = useSettingUiSchemaQuery()
const updateSettingsMutation = useUpdateSettingsMutation()
const { setHeaderActionState } = useMainPageLayout()
const formId = useId()
const [formState, setFormState] = useState<SettingValueState<string>>(
{} as SettingValueState<string>,
)
@@ -117,83 +131,120 @@ export const SettingsForm = () => {
: '未知错误'
: null
useEffect(() => {
setHeaderActionState((prev) => {
const nextState = {
disabled: isLoading || isError || changedEntries.length === 0,
loading: updateSettingsMutation.isPending,
}
return prev.disabled === nextState.disabled &&
prev.loading === nextState.loading
? prev
: nextState
})
return () => {
setHeaderActionState({ disabled: false, loading: false })
}
}, [
isLoading,
isError,
changedEntries.length,
setHeaderActionState,
updateSettingsMutation.isPending,
])
const headerActionPortal = (
<MainPageLayout.Actions>
<Button
type="submit"
form={formId}
disabled={changedEntries.length === 0}
isLoading={updateSettingsMutation.isPending}
loadingText="保存中…"
variant="primary"
size="sm"
>
</Button>
</MainPageLayout.Actions>
)
if (isLoading) {
return (
<GlassPanel className="p-6">
<div className="space-y-4">
<div className="h-5 w-1/2 animate-pulse rounded-full bg-fill/40" />
<div className="space-y-3">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map(
(key) => (
<div
key={key}
className="h-20 animate-pulse rounded-xl bg-fill/30"
/>
),
)}
<>
{headerActionPortal}
<GlassPanel className="p-6">
<div className="space-y-4">
<div className="h-5 w-1/2 animate-pulse rounded-lg bg-fill/40" />
<div className="space-y-3">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map(
(key) => (
<div
key={key}
className="h-20 animate-pulse rounded-lg bg-fill/30"
/>
),
)}
</div>
</div>
</div>
</GlassPanel>
</GlassPanel>
</>
)
}
if (isError) {
return (
<GlassPanel className="p-6">
<div className="flex items-center gap-3 text-sm text-red">
<i className="i-mingcute-close-circle-fill text-lg" />
<span>
{`无法加载设置:${error instanceof Error ? error.message : '未知错误'}`}
</span>
</div>
</GlassPanel>
<>
{headerActionPortal}
<GlassPanel className="p-6">
<div className="flex items-center gap-3 text-sm text-red">
<i className="i-mingcute-close-circle-fill text-lg" />
<span>
{`无法加载设置:${error instanceof Error ? error.message : '未知错误'}`}
</span>
</div>
</GlassPanel>
</>
)
}
if (!data) {
return null
return headerActionPortal
}
const { schema } = data
return (
<m.form
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
>
<SchemaFormRenderer
schema={schema}
values={formState}
onChange={handleChange}
shouldRenderNode={shouldRenderNode}
/>
<>
{headerActionPortal}
<m.form
id={formId}
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
>
<SchemaFormRenderer
schema={schema}
values={formState}
onChange={handleChange}
shouldRenderNode={shouldRenderNode}
/>
<div className="flex items-center justify-end gap-3">
<div className="text-xs text-text-tertiary">
{mutationErrorMessage
? `保存失败:${mutationErrorMessage}`
: updateSettingsMutation.isSuccess && changedEntries.length === 0
? '保存成功,设置已同步'
: changedEntries.length > 0
? `${changedEntries.length} 项设置待保存`
: '所有设置已同步'}
<div className="flex justify-end">
<div className="text-xs text-text-tertiary">
{mutationErrorMessage
? `保存失败:${mutationErrorMessage}`
: updateSettingsMutation.isSuccess && changedEntries.length === 0
? '保存成功,设置已同步'
: changedEntries.length > 0
? `${changedEntries.length} 项设置待保存`
: '所有设置已同步'}
</div>
</div>
<button
type="submit"
disabled={
changedEntries.length === 0 || updateSettingsMutation.isPending
}
className={clsxm(
'rounded-xl border border-accent/40 bg-accent px-4 py-2 text-sm font-semibold text-white transition-all duration-200',
'hover:bg-accent/90 disabled:cursor-not-allowed disabled:border-accent/20 disabled:bg-accent/30 disabled:text-white/60',
)}
>
{updateSettingsMutation.isPending ? '保存中…' : '保存修改'}
</button>
</div>
</m.form>
</m.form>
</>
)
}

View File

@@ -0,0 +1,52 @@
import { clsxm, Spring } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import { m } from 'motion/react'
import type { FC } from 'react'
type AddProviderCardProps = {
onClick: () => void
}
export const AddProviderCard: FC<AddProviderCardProps> = ({ onClick }) => {
return (
<m.button
type="button"
onClick={onClick}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={Spring.presets.smooth}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={clsxm(
'group relative flex flex-col items-center justify-center gap-3 overflow-hidden bg-background-tertiary p-5 transition-all duration-200',
'hover:shadow-lg',
'min-h-[180px]',
)}
>
{/* Linear gradient borders with accent color on hover */}
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent transition-opacity group-hover:via-accent/60" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text/20 to-transparent transition-opacity group-hover:via-accent/60" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent transition-opacity group-hover:via-accent/60" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text/20 to-transparent transition-opacity group-hover:via-accent/60" />
{/* Icon */}
<div className="relative">
<div className="flex h-12 w-12 items-center justify-center rounded-lg border-2 border-dashed border-accent/30 bg-accent/5 transition-all duration-200 group-hover:border-accent/60 group-hover:bg-accent/10">
<DynamicIcon
name="plus"
className="h-6 w-6 text-accent transition-transform duration-200 group-hover:scale-110"
/>
</div>
</div>
{/* Text */}
<div className="relative text-center">
<p className="text-sm font-semibold text-text">Add Provider</p>
<p className="text-xs text-text-tertiary">
Configure a new storage
</p>
</div>
</m.button>
)
}

View File

@@ -0,0 +1,138 @@
import { clsxm, Spring } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import { m } from 'motion/react'
import type { FC } from 'react'
import type { StorageProvider, StorageProviderType } from '../types'
const providerTypeConfig = {
s3: {
icon: 'database',
label: 'AWS S3',
color: 'text-orange-500',
bgColor: 'bg-orange-500/10',
},
github: {
icon: 'github',
label: 'GitHub',
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
},
local: {
icon: 'folder',
label: 'Local Storage',
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
},
minio: {
icon: 'server',
label: 'MinIO',
color: 'text-red-500',
bgColor: 'bg-red-500/10',
},
eagle: {
icon: 'image',
label: 'Eagle',
color: 'text-amber-500',
bgColor: 'bg-amber-500/10',
},
} as const
type ProviderCardProps = {
provider: StorageProvider
isActive: boolean
onClick: () => void
}
export const ProviderCard: FC<ProviderCardProps> = ({
provider,
isActive,
onClick,
}) => {
const config =
providerTypeConfig[provider.type as keyof typeof providerTypeConfig] ||
providerTypeConfig.s3
// Extract preview info based on provider type
const getPreviewInfo = () => {
const cfg = provider.config
switch (provider.type) {
case 's3':
return cfg.region || cfg.bucket || 'Not configured'
case 'github':
return cfg.repo || 'Not configured'
case 'local':
return cfg.path || 'Not configured'
case 'minio':
return cfg.endpoint || 'Not configured'
case 'eagle':
return cfg.libraryPath || 'Not configured'
default:
return 'Storage provider'
}
}
return (
<m.button
type="button"
onClick={onClick}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={Spring.presets.smooth}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={clsxm(
'group relative flex flex-col gap-3 overflow-hidden bg-background-tertiary p-5 text-left transition-all duration-200',
'hover:shadow-lg',
)}
>
{/* Linear gradient borders */}
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent transition-opacity group-hover:via-accent/40" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text/20 to-transparent transition-opacity group-hover:via-accent/40" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent transition-opacity group-hover:via-accent/40" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent via-text/20 to-transparent transition-opacity group-hover:via-accent/40" />
{/* Active Badge */}
{isActive && (
<div className="absolute right-3 top-3">
<span className="inline-flex items-center gap-1 rounded bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white">
<DynamicIcon name="check-circle" className="h-3 w-3" />
Active
</span>
</div>
)}
{/* Provider Icon */}
<div className="relative">
<div
className={clsxm(
'inline-flex h-12 w-12 items-center justify-center rounded-lg',
config.bgColor,
)}
>
<DynamicIcon name={config.icon as any} className={clsxm('h-6 w-6', config.color)} />
</div>
</div>
{/* Provider Info */}
<div className="relative flex-1 space-y-1">
<h3 className="text-sm font-semibold text-text">
{provider.name || '未命名存储'}
</h3>
<p className="text-xs font-medium text-text-tertiary">{config.label}</p>
<p className="truncate text-xs text-text-tertiary/70">
{getPreviewInfo()}
</p>
</div>
{/* Hover Edit Indicator */}
<div className="absolute inset-0 flex items-center justify-center bg-accent/0 opacity-0 transition-all duration-200 group-hover:bg-accent/5 group-hover:opacity-100">
<span className="flex items-center gap-1.5 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-lg">
<DynamicIcon name="pencil" className="h-3.5 w-3.5" />
Edit
</span>
</div>
</m.button>
)
}

View File

@@ -0,0 +1,358 @@
import type { ModalComponentProps } from '@afilmory/ui'
import {
Button,
DialogContent,
FormHelperText,
Input,
Label,
ScrollArea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@afilmory/ui'
import { clsxm, Spring } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import { m } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import {
STORAGE_PROVIDER_FIELD_DEFINITIONS,
STORAGE_PROVIDER_TYPE_OPTIONS,
} from '../constants'
import type { StorageProvider, StorageProviderType } from '../types'
type ProviderEditModalProps = ModalComponentProps & {
provider: StorageProvider | null
activeProviderId: string | null
onSave: (provider: StorageProvider) => void
onSetActive: (id: string) => void
onDelete: (id: string) => void
}
export const ProviderEditModal = ({
provider,
activeProviderId,
onSave,
onSetActive,
onDelete,
dismiss,
}: ProviderEditModalProps) => {
const [formData, setFormData] = useState<StorageProvider | null>(provider)
const [isDirty, setIsDirty] = useState(false)
// Reset form when provider changes (e.g., when modal opens with new provider)
const providerKey = provider?.id || 'new'
useEffect(() => {
setFormData(provider)
setIsDirty(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerKey])
const isActive = provider?.id === activeProviderId
const isNewProvider = !provider?.id
const selectedFields = useMemo(() => {
if (!formData) return []
return STORAGE_PROVIDER_FIELD_DEFINITIONS[formData.type] || []
}, [formData])
const handleNameChange = (value: string) => {
if (!formData) return
setFormData({ ...formData, name: value })
setIsDirty(true)
}
const handleTypeChange = (value: StorageProviderType) => {
if (!formData) return
setFormData({
...formData,
type: value,
config: {}, // Reset config when type changes
})
setIsDirty(true)
}
const handleConfigChange = (key: string, value: string) => {
if (!formData) return
setFormData({
...formData,
config: {
...formData.config,
[key]: value,
},
})
setIsDirty(true)
}
const handleSave = () => {
if (!formData) return
onSave(formData)
dismiss()
}
const handleDelete = () => {
if (!formData?.id) return
if (window.confirm('确定要删除这个存储提供商吗?此操作无法撤销。')) {
onDelete(formData.id)
dismiss()
}
}
const handleSetActive = () => {
if (!formData?.id) return
onSetActive(formData.id)
}
if (!formData) return null
return (
<div className="flex h-full max-h-[85vh] flex-col">
{/* Header */}
<div className="shrink-0 space-y-3 px-6 pt-6">
<div className="flex items-start gap-3">
<div
className={clsxm(
'flex size-10 shrink-0 items-center justify-center rounded',
isNewProvider ? 'bg-accent/10 text-accent' : 'bg-fill text-text',
)}
>
<DynamicIcon
name={isNewProvider ? 'plus-circle' : 'edit'}
className="size-5"
/>
</div>
<div className="flex-1 space-y-1">
<h2 className="text-xl font-semibold text-text">
{isNewProvider ? 'Add Storage Provider' : 'Edit Provider'}
</h2>
<p className="text-sm text-text-tertiary">
{isNewProvider
? 'Configure a new storage provider for your photos'
: 'Update provider configuration and credentials'}
</p>
</div>
</div>
{/* Horizontal divider */}
<div className="h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent" />
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-hidden">
<ScrollArea rootClassName="h-full" viewportClassName="h-full">
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6 px-6 py-4"
>
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-text">
Basic Information
</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="provider-name">Display Name</Label>
<Input
id="provider-name"
value={formData.name}
onInput={(e) => handleNameChange(e.currentTarget.value)}
placeholder="e.g., Production S3"
className="bg-background/60"
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider-type">Provider Type</Label>
<Select
value={formData.type}
onValueChange={(value) =>
handleTypeChange(value as StorageProviderType)
}
>
<SelectTrigger id="provider-type">
<SelectValue placeholder="Select provider type" />
</SelectTrigger>
<SelectContent>
{STORAGE_PROVIDER_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Configuration Fields */}
{selectedFields.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-text">
Connection Configuration
</h3>
<div className="space-y-4">
{selectedFields.map((field) => {
const value = formData.config[field.key] || ''
return (
<div
key={field.key}
className="space-y-2 rounded border border-fill-tertiary/40 bg-background/30 p-4"
>
<div className="space-y-1">
<Label
htmlFor={`field-${field.key}`}
className="font-semibold"
>
{field.label}
</Label>
{field.description && (
<p className="text-xs text-text-tertiary">
{field.description}
</p>
)}
</div>
{field.multiline ? (
<Textarea
id={`field-${field.key}`}
value={value}
onInput={(e) =>
handleConfigChange(
field.key,
e.currentTarget.value,
)
}
placeholder={field.placeholder}
rows={3}
className="bg-background/60"
/>
) : (
<Input
id={`field-${field.key}`}
type={field.sensitive ? 'password' : 'text'}
value={value}
onInput={(e) =>
handleConfigChange(
field.key,
e.currentTarget.value,
)
}
placeholder={field.placeholder}
className="bg-background/60"
autoComplete="off"
/>
)}
{field.helper && (
<FormHelperText>{field.helper}</FormHelperText>
)}
</div>
)
})}
</div>
</div>
)}
</m.div>
</ScrollArea>
</div>
{/* Footer */}
<div className="shrink-0 px-6 pb-6 pt-4">
{/* Horizontal divider */}
<div className="mb-4 h-[0.5px] bg-linear-to-r from-transparent via-text/20 to-transparent" />
{isNewProvider ? (
// Add mode: Simple cancel + create actions
<div className="flex items-center justify-end gap-2">
<Button
type="button"
onClick={dismiss}
variant="ghost"
size="sm"
className="text-text-secondary hover:text-text"
>
Cancel
</Button>
<Button
type="button"
onClick={handleSave}
variant="primary"
size="sm"
>
<DynamicIcon name="plus" className="h-3.5 w-3.5" />
<span>Create Provider</span>
</Button>
</div>
) : (
// Edit mode: Delete + cancel + set active + save
<div className="flex items-center justify-between gap-3">
<Button
type="button"
onClick={handleDelete}
variant="ghost"
size="sm"
className="text-red hover:bg-red/10"
>
<DynamicIcon name="trash-2" className="h-3.5 w-3.5" />
<span>Delete</span>
</Button>
<div className="flex gap-2">
<Button
type="button"
onClick={dismiss}
variant="ghost"
size="sm"
className="text-text-secondary hover:text-text"
>
Cancel
</Button>
{isActive ? (
<span className="inline-flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-xs font-semibold uppercase text-white">
<DynamicIcon name="check-circle" className="h-3.5 w-3.5" />
Active
</span>
) : (
<Button
type="button"
onClick={handleSetActive}
variant="ghost"
size="sm"
className="border border-accent/30 bg-accent/10 text-accent hover:bg-accent/20"
>
<DynamicIcon name="check" className="h-3.5 w-3.5" />
<span>Set Active</span>
</Button>
)}
<Button
type="button"
onClick={handleSave}
disabled={!isDirty}
variant="primary"
size="sm"
>
<DynamicIcon name="save" className="h-3.5 w-3.5" />
<span>Save Changes</span>
</Button>
</div>
</div>
)}
</div>
</div>
)
}
// Configure modal content
ProviderEditModal.contentClassName = 'max-w-2xl w-[95vw] max-h-[90vh] p-0'
ProviderEditModal.contentProps = {
style: {
maxHeight: '90vh',
},
}

View File

@@ -1,77 +1,30 @@
import { FormHelperText, Input, Label, Textarea } from '@afilmory/ui'
import { clsxm } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import { Button, Modal } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import type { ReactNode } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
STORAGE_PROVIDER_FIELD_DEFINITIONS,
STORAGE_PROVIDER_TYPE_OPTIONS,
} from '../constants'
import { useStorageProvidersQuery, useUpdateStorageProvidersMutation } from '../hooks'
import type { StorageProvider, StorageProviderType } from '../types'
MainPageLayout,
useMainPageLayout,
} from '~/components/layouts/MainPageLayout'
import {
createEmptyProvider,
getDefaultConfigForType,
reorderProvidersByActive,
} from '../utils'
const glassCardStyles = {
backgroundImage:
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
boxShadow:
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.08)',
} as const
const glassGlowStyles = {
background:
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))',
} as const
const typeLabelMap = new Map(
STORAGE_PROVIDER_TYPE_OPTIONS.map((option) => [option.value, option.label]),
)
const GlassPanel = ({
className,
children,
}: {
className?: string
children: ReactNode
}) => (
<div
className={clsxm(
'group relative overflow-hidden rounded-2xl border border-accent/20 backdrop-blur-2xl',
className,
)}
style={glassCardStyles}
>
<div
className="pointer-events-none absolute inset-0 rounded-2xl opacity-60"
style={glassGlowStyles}
/>
<div className="relative">{children}</div>
</div>
)
const ProviderBadge = ({ type }: { type: StorageProviderType }) => {
const label = typeLabelMap.get(type) ?? type
return (
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2 py-0.5 text-[11px] font-medium text-accent">
<DynamicIcon name="database" className="h-3.5 w-3.5" />
{label}
</span>
)
}
useStorageProvidersQuery,
useUpdateStorageProvidersMutation,
} from '../hooks'
import type { StorageProvider } from '../types'
import { createEmptyProvider, reorderProvidersByActive } from '../utils'
import { AddProviderCard } from './AddProviderCard'
import { ProviderCard } from './ProviderCard'
import { ProviderEditModal } from './ProviderEditModal'
export const StorageProvidersManager = () => {
const { data, isLoading, isError, error } = useStorageProvidersQuery()
const updateMutation = useUpdateStorageProvidersMutation()
const { setHeaderActionState } = useMainPageLayout()
const [providers, setProviders] = useState<StorageProvider[]>([])
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
@@ -85,116 +38,65 @@ export const StorageProvidersManager = () => {
(initialProviders.length > 0 ? initialProviders[0].id : null)
setProviders(initialProviders)
setActiveProviderId(activeId)
setSelectedProviderId(activeId ?? initialProviders[0]?.id ?? null)
setIsDirty(false)
}, [data])
// eslint-disable-next-line react-compiler/react-compiler
const orderedProviders = useMemo(
() => reorderProvidersByActive(providers, activeProviderId),
[providers, activeProviderId],
)
const selectedProvider = useMemo(
() => providers.find((provider) => provider.id === selectedProviderId) ?? null,
[providers, selectedProviderId],
)
const handleSelectProvider = (providerId: string) => {
setSelectedProviderId(providerId)
}
const markDirty = () => setIsDirty(true)
const handleEditProvider = (provider: StorageProvider | null) => {
Modal.present(ProviderEditModal, {
provider,
activeProviderId,
onSave: handleSaveProvider,
onSetActive: handleSetActive,
onDelete: handleDeleteProvider,
})
}
const handleAddProvider = () => {
const newProvider = createEmptyProvider('s3')
setProviders((prev) => [...prev, newProvider])
setActiveProviderId((prev) => prev ?? newProvider.id)
setSelectedProviderId(newProvider.id)
handleEditProvider(newProvider)
}
const handleSaveProvider = (updatedProvider: StorageProvider) => {
setProviders((prev) => {
const exists = prev.some((p) => p.id === updatedProvider.id)
if (exists) {
return prev.map((p) =>
p.id === updatedProvider.id ? updatedProvider : p,
)
}
// New provider
const result = [...prev, updatedProvider]
// Set as active if it's the first provider
if (!activeProviderId) {
setActiveProviderId(updatedProvider.id)
}
return result
})
markDirty()
}
const handleDeleteProvider = (providerId: string) => {
setProviders((prev) => {
const next = prev.filter((provider) => provider.id !== providerId)
const nextActive = next.some((provider) => provider.id === activeProviderId)
const nextActive = next.some(
(provider) => provider.id === activeProviderId,
)
? activeProviderId
: next[0]?.id ?? null
: (next[0]?.id ?? null)
setActiveProviderId(nextActive)
setSelectedProviderId((currentSelected) => {
if (currentSelected && next.some((provider) => provider.id === currentSelected)) {
return currentSelected
}
return nextActive
})
return next
})
markDirty()
}
const updateProvider = (
providerId: string,
updater: (provider: StorageProvider) => StorageProvider,
) => {
setProviders((prev) =>
prev.map((provider) =>
provider.id === providerId
? updater({
...provider,
updatedAt: new Date().toISOString(),
})
: provider,
),
)
markDirty()
}
const handleUpdateName = (providerId: string, name: string) => {
updateProvider(providerId, (provider) => ({
...provider,
name,
}))
}
const handleUpdateType = (providerId: string, nextType: StorageProviderType) => {
updateProvider(providerId, (provider) => {
if (provider.type === nextType) {
return provider
}
const nextConfigFields = STORAGE_PROVIDER_FIELD_DEFINITIONS[nextType]
const preserved = nextConfigFields.reduce<Record<string, string>>(
(acc, field) => {
acc[field.key] = provider.config[field.key] ?? ''
return acc
},
{},
)
return {
...provider,
type: nextType,
config: {
...getDefaultConfigForType(nextType),
...preserved,
},
}
})
}
const handleConfigChange = (
providerId: string,
key: string,
value: string,
) => {
updateProvider(providerId, (provider) => ({
...provider,
config: {
...provider.config,
[key]: value,
},
}))
}
const handleSetActive = (providerId: string) => {
setActiveProviderId(providerId)
markDirty()
@@ -202,9 +104,10 @@ export const StorageProvidersManager = () => {
const handleSave = () => {
const resolvedActiveId =
activeProviderId && providers.some((provider) => provider.id === activeProviderId)
activeProviderId &&
providers.some((provider) => provider.id === activeProviderId)
? activeProviderId
: providers[0]?.id ?? null
: (providers[0]?.id ?? null)
updateMutation.mutate(
{
@@ -219,277 +122,138 @@ export const StorageProvidersManager = () => {
)
}
const disableSave =
isLoading ||
isError ||
!isDirty ||
updateMutation.isPending ||
providers.length === 0
useEffect(() => {
setHeaderActionState((prev) => {
const nextState = {
disabled: disableSave,
loading: updateMutation.isPending,
}
return prev.disabled === nextState.disabled &&
prev.loading === nextState.loading
? prev
: nextState
})
return () => {
setHeaderActionState({ disabled: false, loading: false })
}
}, [disableSave, setHeaderActionState, updateMutation.isPending])
const headerActionPortal = (
<MainPageLayout.Actions>
<Button
type="button"
onClick={handleSave}
disabled={disableSave}
isLoading={updateMutation.isPending}
loadingText="保存中…"
variant="primary"
size="sm"
>
</Button>
</MainPageLayout.Actions>
)
if (isLoading && !data) {
return (
<GlassPanel className="p-6">
<div className="space-y-4">
<div className="h-5 w-1/3 animate-pulse rounded-full bg-fill/40" />
<div className="grid gap-3 lg:grid-cols-[280px_1fr]">
<div className="h-40 animate-pulse rounded-xl bg-fill/20" />
<div className="h-40 animate-pulse rounded-xl bg-fill/20" />
</div>
</div>
</GlassPanel>
<>
{headerActionPortal}
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-[180px] animate-pulse rounded bg-background-tertiary"
/>
))}
</m.div>
</>
)
}
if (isError) {
return (
<GlassPanel className="p-6">
<div className="flex items-center gap-3 text-sm text-red">
<DynamicIcon name="alert-triangle" className="h-5 w-5" />
<>
{headerActionPortal}
<div className="flex items-center justify-center gap-3 rounded bg-background-tertiary p-8 text-sm text-red">
<span>
{error instanceof Error ? error.message : '未知错误'}
<span>{error instanceof Error ? error.message : '未知错误'}</span>
</span>
</div>
</GlassPanel>
</>
)
}
const mutationErrorMessage =
updateMutation.isError && updateMutation.error
? updateMutation.error instanceof Error
? updateMutation.error.message
: '未知错误'
: null
const hasProviders = providers.length > 0
const selectedFields =
selectedProvider != null
? STORAGE_PROVIDER_FIELD_DEFINITIONS[selectedProvider.type]
: []
return (
<div className="grid gap-6 lg:grid-cols-[280px_1fr]">
<GlassPanel className="p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text"></h2>
<p className="text-xs text-text-tertiary">
</p>
</div>
<button
type="button"
onClick={handleAddProvider}
className="flex items-center gap-1 rounded-lg border border-accent/30 bg-accent/10 px-3 py-1.5 text-xs font-medium text-accent transition-all duration-200 hover:bg-accent/20"
<>
{headerActionPortal}
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{orderedProviders.map((provider, index) => (
<m.div
key={provider.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
>
<DynamicIcon name="plus" className="h-3.5 w-3.5" />
</button>
</div>
<ProviderCard
provider={provider}
isActive={provider.id === activeProviderId}
onClick={() => handleEditProvider(provider)}
/>
</m.div>
))}
<m.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
...Spring.presets.smooth,
delay: orderedProviders.length * 0.05,
}}
>
<AddProviderCard onClick={handleAddProvider} />
</m.div>
</m.div>
{hasProviders ? (
<div className="mt-4 space-y-2">
{orderedProviders.map((provider) => {
const isSelected = provider.id === selectedProviderId
const isActive = provider.id === activeProviderId
return (
<button
key={provider.id}
type="button"
onClick={() => handleSelectProvider(provider.id)}
className={clsxm(
'w-full rounded-xl border px-3 py-2 text-left transition-all duration-200',
'flex flex-col gap-1.5',
isSelected
? 'border-accent/40 bg-accent/15 text-accent'
: 'border-fill-tertiary bg-background/60 hover:border-accent/20 hover:bg-accent/10',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">
{provider.name || '未命名存储'}
</span>
{isActive ? (
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-[11px] font-medium text-white">
<DynamicIcon name="check-circle" className="h-3.5 w-3.5" />
Active
</span>
) : null}
</div>
<ProviderBadge type={provider.type} />
</button>
)
})}
</div>
) : (
<div className="mt-6 rounded-xl border border-dashed border-accent/30 bg-accent/5 p-4 text-center text-xs text-text-tertiary">
</div>
)}
</GlassPanel>
<GlassPanel className="p-6">
{selectedProvider ? (
<div className="space-y-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<DynamicIcon name="hard-drive" className="h-5 w-5 text-accent" />
<h2 className="text-base font-semibold text-text">
{selectedProvider.name || '未命名存储'}
</h2>
</div>
<p className="text-xs text-text-tertiary">
Builder 使
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleSetActive(selectedProvider.id)}
disabled={selectedProvider.id === activeProviderId}
className={clsxm(
'rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-200',
selectedProvider.id === activeProviderId
? 'border-accent/20 bg-accent/10 text-accent/60 cursor-not-allowed'
: 'border-accent/40 bg-accent/10 text-accent hover:bg-accent/20',
)}
>
Active
</button>
<button
type="button"
onClick={() => handleDeleteProvider(selectedProvider.id)}
className="rounded-lg border border-red/40 bg-red/10 px-3 py-1.5 text-xs font-medium text-red transition-all duration-200 hover:bg-red/15"
>
</button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-medium text-text"></Label>
<Input
value={selectedProvider.name}
onInput={(event) =>
handleUpdateName(selectedProvider.id, event.currentTarget.value)
}
placeholder="例如:生产环境 S3"
className="bg-background/60"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-text"></Label>
<select
value={selectedProvider.type}
onChange={(event) =>
handleUpdateType(
selectedProvider.id,
event.currentTarget.value as StorageProviderType,
)
}
className="h-10 w-full rounded-lg border border-fill-tertiary bg-background/70 px-3 text-sm text-text transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-accent/40"
>
{STORAGE_PROVIDER_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-text"></h3>
<div className="space-y-4">
{selectedFields.map((field) => {
const value = selectedProvider.config[field.key] ?? ''
const handler = (nextValue: string) =>
handleConfigChange(selectedProvider.id, field.key, nextValue)
return (
<div
key={field.key}
className="space-y-2 rounded-xl border border-fill-tertiary/40 bg-background/30 p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<Label className="text-xs font-semibold text-text">
{field.label}
</Label>
<p className="text-xs text-text-tertiary">
{field.description}
</p>
</div>
</div>
{field.multiline ? (
<Textarea
value={value}
onInput={(event) => handler(event.currentTarget.value)}
placeholder={field.placeholder}
rows={field.multiline ? 3 : 2}
className="bg-background/60"
/>
) : (
<Input
type={field.sensitive ? 'password' : 'text'}
value={value}
onInput={(event) => handler(event.currentTarget.value)}
placeholder={field.placeholder}
className="bg-background/60"
autoComplete="off"
/>
)}
<FormHelperText>{field.helper}</FormHelperText>
</div>
)
})}
</div>
</div>
<div className="flex flex-col gap-3 border-t border-accent/10 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-text-tertiary">
{mutationErrorMessage
? `保存失败:${mutationErrorMessage}`
: updateMutation.isSuccess && !isDirty
? '保存成功,配置已同步'
: isDirty
? '有未保存的更改'
: '暂无更改'}
</div>
<button
type="button"
onClick={handleSave}
disabled={
!isDirty || updateMutation.isPending || providers.length === 0
}
className={clsxm(
'rounded-xl border border-accent/40 bg-accent px-4 py-2 text-sm font-semibold text-white transition-all duration-200',
'hover:bg-accent/90 disabled:cursor-not-allowed disabled:border-accent/20 disabled:bg-accent/30 disabled:text-white/60',
)}
>
{updateMutation.isPending ? '保存中…' : '保存配置'}
</button>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<DynamicIcon name="hard-drive" className="h-8 w-8 text-accent/60" />
<div className="space-y-1">
<div className="text-sm font-medium text-text">
</div>
<p className="text-xs text-text-tertiary">
</p>
</div>
<button
type="button"
onClick={handleAddProvider}
className="rounded-lg border border-accent/30 bg-accent/10 px-4 py-2 text-sm font-medium text-accent transition-all duration-200 hover:bg-accent/20"
>
</button>
</div>
)}
</GlassPanel>
</div>
{/* Status Message */}
{hasProviders && (
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ ...Spring.presets.smooth, delay: 0.2 }}
className="mt-4 text-center"
>
<p className="text-xs text-text-tertiary">
<span>
{updateMutation.isError && updateMutation.error
? `保存失败:${updateMutation.error instanceof Error ? updateMutation.error.message : '未知错误'}`
: updateMutation.isSuccess && !isDirty
? '✓ 配置已保存并同步'
: isDirty
? `有未保存的更改 • ${providers.length} 个提供商`
: `${providers.length} 个存储提供商 • ${orderedProviders.find((p) => p.id === activeProviderId)?.name || 'N/A'} 当前激活`}
</span>
</p>
</m.div>
)}
</>
)
}

View File

@@ -5,7 +5,10 @@ import {
import type { StorageProvider, StorageProviderType } from './types'
const generateId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID()
}
return Math.random().toString(36).slice(2, 10)
@@ -21,19 +24,14 @@ const normaliseConfigForType = (
type: StorageProviderType,
config: Record<string, unknown>,
): Record<string, string> => {
return STORAGE_PROVIDER_FIELD_DEFINITIONS[type].reduce<Record<string, string>>(
(acc, field) => {
const raw = config[field.key]
acc[field.key] =
typeof raw === 'string'
? raw
: raw == null
? ''
: String(raw)
return acc
},
{},
)
return STORAGE_PROVIDER_FIELD_DEFINITIONS[type].reduce<
Record<string, string>
>((acc, field) => {
const raw = config[field.key]
acc[field.key] =
typeof raw === 'string' ? raw : raw == null ? '' : String(raw)
return acc
}, {})
}
const coerceProvider = (input: unknown): StorageProvider | null => {
@@ -44,7 +42,9 @@ const coerceProvider = (input: unknown): StorageProvider | null => {
const record = input as Record<string, unknown>
const type = isStorageProviderType(record.type) ? record.type : 'local'
const configInput =
record.config && typeof record.config === 'object' && !Array.isArray(record.config)
record.config &&
typeof record.config === 'object' &&
!Array.isArray(record.config)
? (record.config as Record<string, unknown>)
: {}
@@ -72,7 +72,9 @@ const coerceProvider = (input: unknown): StorageProvider | null => {
return provider
}
export const parseStorageProviders = (raw: string | null): StorageProvider[] => {
export const parseStorageProviders = (
raw: string | null,
): StorageProvider[] => {
if (!raw) {
return []
}
@@ -105,19 +107,20 @@ export const serializeStorageProviders = (
export const getDefaultConfigForType = (
type: StorageProviderType,
): Record<string, string> => {
return STORAGE_PROVIDER_FIELD_DEFINITIONS[type].reduce<Record<string, string>>(
(acc, field) => {
acc[field.key] = ''
return acc
},
{},
)
return STORAGE_PROVIDER_FIELD_DEFINITIONS[type].reduce<
Record<string, string>
>((acc, field) => {
acc[field.key] = ''
return acc
}, {})
}
export const createEmptyProvider = (type: StorageProviderType): StorageProvider => {
export const createEmptyProvider = (
type: StorageProviderType,
): StorageProvider => {
const timestamp = new Date().toISOString()
return {
id: generateId(),
id: '',
name: '未命名存储',
type,
config: getDefaultConfigForType(type),
@@ -134,7 +137,9 @@ export const ensureActiveProviderId = (
return null
}
return providers.some((provider) => provider.id === activeId) ? activeId : null
return providers.some((provider) => provider.id === activeId)
? activeId
: null
}
export const reorderProvidersByActive = (

View File

@@ -1,4 +1,5 @@
import { clsxm, Spring } from '@afilmory/utils'
import { Button } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import {
startTransition,
@@ -368,16 +369,16 @@ export const SuperAdminSettingsForm = () => {
<div className="flex items-center justify-end gap-3">
<span className="text-xs text-text-tertiary">{mutationMessage}</span>
<button
<Button
type="submit"
disabled={updateMutation.isPending || !hasChanges}
className={clsxm(
'rounded-xl border border-accent/40 bg-accent px-4 py-2 text-sm font-semibold text-white transition-all duration-200',
'hover:bg-accent/90 disabled:cursor-not-allowed disabled:border-accent/20 disabled:bg-accent/30 disabled:text-white/60',
)}
disabled={!hasChanges}
isLoading={updateMutation.isPending}
loadingText="保存中..."
variant="primary"
size="sm"
>
{updateMutation.isPending ? '保存中...' : '保存修改'}
</button>
</Button>
</div>
</m.form>
)

View File

@@ -1,26 +1,22 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
export const Component = () => {
return (
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
<MainPageLayout
title="Analytics"
description="Track your photo collection statistics and trends"
>
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-text">Analytics</h1>
<p className="mt-1 text-sm text-text-secondary">
Track your photo collection statistics and trends
</p>
</div>
{/* Charts Grid */}
<div className="grid gap-4 md:grid-cols-2">
{/* Upload Trends */}
<div className="rounded-lg border border-border/50 bg-background-tertiary p-5">
<div className="relative bg-background-tertiary p-5">
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<h2 className="mb-4 text-sm font-semibold text-text">
Upload Trends
</h2>
@@ -30,7 +26,12 @@ export const Component = () => {
</div>
{/* Storage Usage */}
<div className="rounded-lg border border-border/50 bg-background-tertiary p-5">
<div className="relative bg-background-tertiary p-5">
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<h2 className="mb-4 text-sm font-semibold text-text">
Storage Usage
</h2>
@@ -40,7 +41,12 @@ export const Component = () => {
</div>
{/* Popular Tags */}
<div className="rounded-lg border border-border/50 bg-background-tertiary p-5">
<div className="relative bg-background-tertiary p-5">
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<h2 className="mb-4 text-sm font-semibold text-text">Popular Tags</h2>
<div className="space-y-1.5">
{[
@@ -51,7 +57,7 @@ export const Component = () => {
].map((item) => (
<div
key={item.tag}
className="flex items-center justify-between rounded-md bg-fill/10 px-3 py-2 transition-colors hover:bg-fill/20"
className="flex items-center justify-between bg-fill/10 px-3 py-2 transition-colors hover:bg-fill/20"
>
<span className="text-[13px] text-text">{item.tag}</span>
<span className="text-[13px] font-medium text-accent">
@@ -63,7 +69,12 @@ export const Component = () => {
</div>
{/* Device Stats */}
<div className="rounded-lg border border-border/50 bg-background-tertiary p-5">
<div className="relative bg-background-tertiary p-5">
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<h2 className="mb-4 text-sm font-semibold text-text">Top Devices</h2>
<div className="space-y-1.5">
{[
@@ -74,7 +85,7 @@ export const Component = () => {
].map((item) => (
<div
key={item.device}
className="flex items-center justify-between rounded-md bg-fill/10 px-3 py-2 transition-colors hover:bg-fill/20"
className="flex items-center justify-between bg-fill/10 px-3 py-2 transition-colors hover:bg-fill/20"
>
<span className="text-[13px] text-text">{item.device}</span>
<span className="text-[13px] font-medium text-accent">
@@ -85,6 +96,6 @@ export const Component = () => {
</div>
</div>
</div>
</m.div>
</MainPageLayout>
)
}

View File

@@ -1,70 +1,76 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
export const Component = () => {
return (
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
<MainPageLayout
title="Dashboard"
description="Welcome to your photo management dashboard"
>
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-text">Dashboard</h1>
<p className="mt-1 text-sm text-text-secondary">
Welcome to your photo management dashboard
</p>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-3">
{[
{ label: 'Total Photos', value: '1,234', trend: '+12%' },
{ label: 'Storage Used', value: '45.2 GB', trend: '+8%' },
{ label: 'This Month', value: '156', trend: '+24%' },
].map((stat, index) => (
<m.div
key={stat.label}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.1 }}
className="rounded-lg border border-border/50 bg-background-tertiary p-4"
>
<div className="text-[11px] font-medium text-text-secondary">
{stat.label}
</div>
<div className="mt-2 text-2xl font-semibold text-text">
{stat.value}
</div>
<div className="mt-1 text-[11px] font-medium text-accent">
{stat.trend} from last month
</div>
</m.div>
))}
</div>
{/* Recent Activity */}
<div className="rounded-lg border border-border/50 bg-background-tertiary p-4">
<h2 className="text-sm font-semibold text-text">Recent Activity</h2>
<div className="mt-4 space-y-2">
<div className="space-y-6">
{/* Stats Cards - Sharp Edges */}
<div className="grid gap-4 md:grid-cols-3">
{[
{ action: 'Uploaded 23 photos', time: '2 hours ago' },
{ action: 'Created new album "Summer 2024"', time: '5 hours ago' },
{ action: 'Shared album with 3 people', time: '1 day ago' },
].map((activity) => (
<div
key={activity.action}
className="flex items-center justify-between rounded-md bg-fill/10 px-3 py-2 transition-colors hover:bg-fill/20"
{ label: 'Total Photos', value: '1,234', trend: '+12%' },
{ label: 'Storage Used', value: '45.2 GB', trend: '+8%' },
{ label: 'This Month', value: '156', trend: '+24%' },
].map((stat, index) => (
<m.div
key={stat.label}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.1 }}
className="relative bg-background-tertiary p-4"
>
<span className="text-[13px] text-text">{activity.action}</span>
<span className="text-[11px] text-text-tertiary">
{activity.time}
</span>
</div>
{/* Gradient borders */}
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<div className="text-[11px] font-medium text-text-secondary">
{stat.label}
</div>
<div className="mt-2 text-2xl font-semibold text-text">
{stat.value}
</div>
<div className="mt-1 text-[11px] font-medium text-accent">
{stat.trend} from last month
</div>
</m.div>
))}
</div>
{/* Recent Activity - Sharp Edges */}
<div className="relative bg-background-tertiary p-4">
{/* Gradient borders */}
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent" />
<h2 className="text-sm font-semibold text-text">Recent Activity</h2>
<div className="mt-4 space-y-2">
{[
{ action: 'Uploaded 23 photos', time: '2 hours ago' },
{ action: 'Created new album "Summer 2024"', time: '5 hours ago' },
{ action: 'Shared album with 3 people', time: '1 day ago' },
].map((activity) => (
<div
key={activity.action}
className="flex items-center justify-between bg-fill/10 px-3 py-2 transition-colors hover:bg-fill/20"
>
<span className="text-[13px] text-text">{activity.action}</span>
<span className="text-[11px] text-text-tertiary">
{activity.time}
</span>
</div>
))}
</div>
</div>
</div>
</m.div>
</MainPageLayout>
)
}

View File

@@ -1,4 +1,4 @@
import { ScrollArea } from '@afilmory/ui'
import { Button, ScrollArea } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useState } from 'react'
@@ -33,19 +33,22 @@ export const Component = () => {
return (
<div className="flex h-screen flex-col">
{/* Top Navigation - Vercel Style */}
<nav className="shrink-0 border-b border-border/50 bg-background-tertiary px-6 py-3">
{/* Top Navigation - Sharp Edges Design */}
<nav className="relative shrink-0 bg-background-tertiary px-6 py-3">
{/* Bottom border with gradient */}
<div className="absolute bottom-0 left-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent" />
<div className="flex items-center gap-6">
{/* Logo/Brand */}
<div className="text-base font-semibold text-text">Afilmory</div>
{/* Navigation Tabs - minimal pills */}
{/* Navigation Tabs - subtle rounded corners */}
<div className="flex flex-1 items-center gap-1">
{navigationTabs.map((tab) => (
<NavLink key={tab.path} to={tab.path} end={tab.path === '/'}>
{({ isActive }) => (
<m.div
className="relative overflow-hidden rounded-md px-3 py-1.5"
className="relative overflow-hidden rounded-lg px-3 py-1.5"
initial={false}
animate={{
backgroundColor: isActive
@@ -97,14 +100,17 @@ export const Component = () => {
</div>
)}
<button
<Button
type="button"
onClick={handleLogout}
disabled={isLoggingOut}
className="rounded-md bg-accent px-3 py-1.5 text-[13px] font-medium text-white transition-all duration-150 hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-60"
isLoading={isLoggingOut}
loadingText="Logging out..."
variant="primary"
size="sm"
>
{isLoggingOut ? 'Logging out...' : 'Logout'}
</button>
Logout
</Button>
</div>
</div>
</nav>

View File

@@ -1,31 +1,42 @@
import { Button } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import {
MainPageLayout,
useMainPageLayout,
} from '~/components/layouts/MainPageLayout'
const UploadPhotosAction = () => {
const { headerActionState } = useMainPageLayout()
const isBusy = headerActionState.loading
const isDisabled = headerActionState.disabled || isBusy
return (
<Button
type="button"
disabled={isDisabled}
isLoading={isBusy}
loadingText="Uploading…"
variant="primary"
size="sm"
aria-busy={isBusy}
>
Upload Photos
</Button>
)
}
export const Component = () => {
return (
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
<MainPageLayout
title="Photos"
description="Manage and organize your photo collection"
>
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text">Photos</h1>
<p className="mt-1 text-sm text-text-secondary">
Manage and organize your photo collection
</p>
</div>
<button
type="button"
className="rounded-md bg-accent px-3 py-1.5 text-[13px] font-medium text-white transition-all duration-150 hover:bg-accent/90"
>
Upload Photos
</button>
</div>
<MainPageLayout.Actions>
<UploadPhotosAction />
</MainPageLayout.Actions>
{/* Photo Grid */}
<div className="grid gap-4 md:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
<m.div
@@ -33,14 +44,20 @@ export const Component = () => {
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
className="group aspect-square overflow-hidden rounded-lg border border-border/50 bg-background-tertiary transition-all hover:border-border"
className="group relative aspect-square overflow-hidden bg-background-tertiary transition-all"
>
{/* Gradient borders */}
<div className="absolute left-0 top-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent transition-opacity group-hover:via-text/40" />
<div className="absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent transition-opacity group-hover:via-text/40" />
<div className="absolute left-0 bottom-0 right-0 h-[0.5px] bg-gradient-to-r from-transparent via-text/20 to-transparent transition-opacity group-hover:via-text/40" />
<div className="absolute top-0 left-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent via-text/20 to-transparent transition-opacity group-hover:via-text/40" />
<div className="flex h-full items-center justify-center text-[13px] text-text-tertiary">
Photo {index + 1}
</div>
</m.div>
))}
</div>
</m.div>
</MainPageLayout>
)
}

View File

@@ -1,28 +1,16 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { SettingsForm, SettingsNavigation } from '~/modules/settings'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
export const Component = () => {
return (
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
<MainPageLayout
title="系统设置"
description="管理后台与核心功能的通用配置,修改后会立即同步生效。"
>
<header className="space-y-3">
<div className="space-y-1.5">
<h1 className="text-2xl font-semibold text-text"></h1>
<p className="text-sm text-text-secondary">
</p>
</div>
<div className="space-y-6">
<SettingsNavigation active="general" />
</header>
<SettingsForm />
</m.div>
<SettingsForm />
</div>
</MainPageLayout>
)
}

View File

@@ -1,29 +1,17 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { SettingsNavigation } from '~/modules/settings'
import { StorageProvidersManager } from '~/modules/storage-providers'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
export const Component = () => {
return (
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
<MainPageLayout
title="素材存储与 Builder"
description="在此配置多个素材存储提供商,并选择一个作为 Builder 的活跃源。保存后请重新运行 Builder 以加载最新配置。"
>
<header className="space-y-3">
<div className="space-y-1.5">
<h1 className="text-2xl font-semibold text-text"> Builder</h1>
<p className="text-sm text-text-secondary">
Builder Builder
</p>
</div>
<div className="space-y-6">
<SettingsNavigation active="storage" />
</header>
<StorageProvidersManager />
</m.div>
<StorageProvidersManager />
</div>
</MainPageLayout>
)
}

View File

@@ -89,28 +89,36 @@ export const Component = () => {
{/* Submit Button */}
<Button
type="submit"
disabled={isLoading || !email.trim() || !password.trim()}
className="w-full rounded-lg px-6 py-2.5 text-sm font-medium"
variant="primary"
size="md"
className="w-full"
disabled={!email.trim() || !password.trim()}
isLoading={isLoading}
loadingText="Signing in..."
>
{isLoading ? 'Signing in...' : 'Sign In'}
Sign In
</Button>
{/* Additional Links */}
<div className="mt-6 flex items-center justify-between text-sm">
<button
<Button
type="button"
className="text-text-tertiary transition-colors duration-200 hover:text-accent"
variant="ghost"
size="sm"
className="text-text-tertiary hover:text-accent"
disabled={isLoading}
>
Forgot password?
</button>
<button
</Button>
<Button
type="button"
className="text-text-tertiary transition-colors duration-200 hover:text-accent"
variant="ghost"
size="sm"
className="text-text-tertiary hover:text-accent"
disabled={isLoading}
>
Create account
</button>
</Button>
</div>
</div>
</form>

View File

@@ -1,4 +1,4 @@
import { ScrollArea } from '@afilmory/ui'
import { Button, ScrollArea } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useState } from 'react'
@@ -103,14 +103,17 @@ export const Component = () => {
</div>
)}
<button
<Button
type="button"
onClick={handleLogout}
disabled={isLoggingOut}
className="rounded-md bg-accent px-3 py-1.5 text-[13px] font-medium text-white transition-all duration-150 hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-60"
isLoading={isLoggingOut}
loadingText="Logging out..."
variant="primary"
size="sm"
>
{isLoggingOut ? 'Logging out...' : 'Logout'}
</button>
Logout
</Button>
</div>
</div>
</nav>