mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat: unify dashboard design
This commit is contained in:
158
be/apps/dashboard/src/components/layouts/MainPageLayout.tsx
Normal file
158
be/apps/dashboard/src/components/layouts/MainPageLayout.tsx
Normal 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,
|
||||
})
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user