fix: remove unnecessary global flag from regex patterns

Co-authored-by: Innei <41265413+Innei@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-31 13:37:18 +00:00
parent b32f1db6a6
commit 154f809588
63 changed files with 253 additions and 239 deletions

View File

@@ -442,7 +442,7 @@ export class PhotoAssetService {
return null
}
const normalized = trimmed.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')
const normalized = trimmed.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/, '')
return normalized.length > 0 ? normalized : null
}
@@ -452,7 +452,7 @@ export class PhotoAssetService {
if (!segment) {
continue
}
filtered.push(segment.replaceAll(/^\/+|\/+$/g, ''))
filtered.push(segment.replaceAll(/^\/+|\/+$/, ''))
}
if (filtered.length === 0) {

View File

@@ -1,4 +1,4 @@
const sortObjectKeys = (obj) => {
function sortObjectKeys(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj
}

View File

@@ -13,14 +13,14 @@ export const App: FC = () => {
)
}
const AppLayer = () => {
function AppLayer() {
usePageRedirect()
const appIsReady = true
return appIsReady ? <Outlet /> : <AppSkeleton />
}
const AppSkeleton = () => {
function AppSkeleton() {
return null
}
export default App

View File

@@ -9,22 +9,22 @@ export const [authUserAtom, useAuthUser, useAuthUserValue, useSetAuthUser, getAu
createAtomHooks(baseAuthUserAtom)
// Selectors
export const useIsAuthenticated = () => {
export function useIsAuthenticated() {
const user = useAuthUserValue()
return !!user
}
export const useUserRole = () => {
export function useUserRole() {
const user = useAuthUserValue()
return user?.role ?? null
}
export const useIsAdmin = () => {
export function useIsAdmin() {
const user = useAuthUserValue()
return user?.role === 'admin' || user?.role === 'superadmin'
}
export const useIsSuperAdmin = () => {
export function useIsSuperAdmin() {
const user = useAuthUserValue()
return user?.role === 'superadmin'
}

View File

@@ -20,11 +20,11 @@ export const [contextMenuAtom, useContextMenuState, useContextMenuValue, useSetC
atom<ContextMenuState>({ open: false }),
)
const useShowWebContextMenu = () => {
function useShowWebContextMenu() {
const setContextMenu = useSetContextMenu()
const showWebContextMenu = useCallback(
async (menuItems: Array<FollowMenuItem>, e: MouseEvent | React.MouseEvent) => {
async (menuItems: FollowMenuItem[], e: MouseEvent | React.MouseEvent) => {
const abortController = new AbortController()
const resolvers = Promise.withResolvers<void>()
setContextMenu({
@@ -75,11 +75,11 @@ export enum MenuItemType {
Action,
}
export const useShowContextMenu = () => {
export function useShowContextMenu() {
const showWebContextMenu = useShowWebContextMenu()
const showContextMenu = useCallback(
async (inputMenu: Array<MenuItemInput>, e: MouseEvent | React.MouseEvent) => {
async (inputMenu: MenuItemInput[], e: MouseEvent | React.MouseEvent) => {
const menuItems = filterNullableMenuItems(inputMenu)
e.preventDefault()
e.stopPropagation()

View File

@@ -25,7 +25,7 @@ export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks(
}),
)
export const useReadonlyRouteSelector = <T>(selector: (route: RouteAtom) => T): T => {
export function useReadonlyRouteSelector<T>(selector: (route: RouteAtom) => T): T {
const memoizedAtom = useMemo(() => selectAtom(routeAtom, (route) => selector(route)), [selector])
return useAtomValue(memoizedAtom)

View File

@@ -1,14 +1,16 @@
import { clsxm } from '@afilmory/utils'
import type { ReactNode } from 'react'
export const LinearBorderPanel = ({ className, children }: { className?: string; children: ReactNode }) => (
<div className={clsxm('group relative overflow-hidden', className)}>
{/* Linear gradient borders - sharp edges */}
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
export function LinearBorderPanel({ className, children }: { className?: string; children: ReactNode }) {
return (
<div className={clsxm('group relative overflow-hidden', className)}>
{/* Linear gradient borders - sharp edges */}
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
<div className="relative">{children}</div>
</div>
)
<div className="relative">{children}</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { Button } from '@afilmory/ui'
import { useLocation, useNavigate } from 'react-router'
export const NotFound = () => {
export function NotFound() {
const location = useLocation()
const navigate = useNavigate()

View File

@@ -23,7 +23,7 @@ type MainPageLayoutContextValue = {
const MainPageLayoutContext = createContext<MainPageLayoutContextValue | null>(null)
export const useMainPageLayout = () => {
export function useMainPageLayout() {
const context = use(MainPageLayoutContext)
if (!context) {
@@ -41,7 +41,7 @@ type MainPageLayoutProps = {
children: ReactNode
}
const MainPageLayoutBase = ({ title, description, actions, footer, children }: MainPageLayoutProps) => {
function 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)
@@ -109,7 +109,7 @@ type MainPageLayoutActionsProps = {
children: ReactNode
}
const MainPageLayoutActions = ({ children }: MainPageLayoutActionsProps) => {
function MainPageLayoutActions({ children }: MainPageLayoutActionsProps) {
const { headerActionsContainer, registerPortalPresence } = useMainPageLayout()
useEffect(() => {

View File

@@ -18,7 +18,7 @@ export interface PageTabsProps {
className?: string
}
export const PageTabs = ({ items, activeId, onSelect, className }: PageTabsProps) => {
export function PageTabs({ items, activeId, onSelect, className }: PageTabsProps) {
const renderTabContent = (selected: boolean, label: ReactNode) => (
<span
className={clsxm(

View File

@@ -18,7 +18,7 @@ const DEFAULT_CONFIRM_TEXT = '继续离开'
const DEFAULT_CANCEL_TEXT = '留在此页'
const DEFAULT_BEFORE_UNLOAD_MESSAGE = '您有未保存的更改,确定要离开吗?'
export const useBlock = ({
export function useBlock({
when,
title = DEFAULT_TITLE,
description = DEFAULT_DESCRIPTION,
@@ -26,7 +26,7 @@ export const useBlock = ({
cancelText = DEFAULT_CANCEL_TEXT,
variant = 'danger',
beforeUnloadMessage = DEFAULT_BEFORE_UNLOAD_MESSAGE,
}: UseBlockOptions) => {
}: UseBlockOptions) {
const promptIdRef = useRef<string | null>(null)
const isPromptOpenRef = useRef(false)

View File

@@ -21,7 +21,7 @@ const AUTH_FAILURE_STATUSES = new Set([401, 403, 419])
const PUBLIC_PATHS = new Set([DEFAULT_LOGIN_PATH, DEFAULT_ONBOARDING_PATH])
export const usePageRedirect = () => {
export function usePageRedirect() {
const location = useLocation()
const navigate = useNavigate()
const queryClient = useQueryClient()

View File

@@ -1,6 +1,6 @@
const CAMELIZE_PATTERN = /[_.-](\w|$)/g
const camelCase = (value: string): string => {
function camelCase(value: string): string {
if (!value || (!value.includes('_') && !value.includes('-') && !value.includes('.'))) {
return value
}
@@ -8,7 +8,7 @@ const camelCase = (value: string): string => {
return value.replaceAll(CAMELIZE_PATTERN, (_match, group: string) => group.toUpperCase())
}
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false
}
@@ -17,7 +17,7 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return prototype === Object.prototype || prototype === null
}
export const camelCaseKeys = <T>(input: unknown): T => {
export function camelCaseKeys<T>(input: unknown): T {
if (Array.isArray(input)) {
return input.map((item) => camelCaseKeys(item)) as unknown as T
}

View File

@@ -1,5 +1,5 @@
declare const APP_DEV_CWD: string
export const attachOpenInEditor = (stack: string) => {
export function attachOpenInEditor(stack: string) {
const lines = stack.split('\n')
return lines.map((line) => {
// A line like this: at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
@@ -47,6 +47,6 @@ export const attachOpenInEditor = (stack: string) => {
})
}
// http://localhost:5173/src/App.tsx?t=1720527056591:41:9
const openInEditor = (file: string) => {
function openInEditor(file: string) {
fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}`)}`)
}

View File

@@ -4,7 +4,7 @@ export const stopPropagation: ReactEventHandler<any> = (e) => e.stopPropagation(
export const preventDefault: ReactEventHandler<any> = (e) => e.preventDefault()
export const nextFrame = (fn: (...args: any[]) => any) => {
export function nextFrame(fn: (...args: any[]) => any) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fn()
@@ -12,7 +12,7 @@ export const nextFrame = (fn: (...args: any[]) => any) => {
})
}
export const getElementTop = (element: HTMLElement) => {
export function getElementTop(element: HTMLElement) {
let actualTop = element.offsetTop
let current = element.offsetParent as HTMLElement
while (current !== null) {

View File

@@ -5,24 +5,26 @@ import { useMemo } from 'react'
export const jotaiStore = createStore()
export const createAtomAccessor = <T>(atom: PrimitiveAtom<T>) =>
[() => jotaiStore.get(atom), (value: T) => jotaiStore.set(atom, value)] as const
export function createAtomAccessor<T>(atom: PrimitiveAtom<T>) {
return [() => jotaiStore.get(atom), (value: T) => jotaiStore.set(atom, value)] as const
}
const options = { store: jotaiStore }
/**
* @param atom - jotai
* @returns - [atom, useAtom, useAtomValue, useSetAtom, jotaiStore.get, jotaiStore.set]
*/
export const createAtomHooks = <T>(atom: PrimitiveAtom<T>) =>
[
export function createAtomHooks<T>(atom: PrimitiveAtom<T>) {
return [
atom,
() => useAtom(atom, options),
() => useAtomValue(atom, options),
() => useSetAtom(atom, options),
...createAtomAccessor(atom),
] as const
}
export const createAtomSelector = <T>(atom: Atom<T>) => {
export function createAtomSelector<T>(atom: Atom<T>) {
const useHook = <R>(selector: (a: T) => R) => {
const memoizedAtom = useMemo(() => selectAtom(atom, (value) => selector(value)), [selector])

View File

@@ -1,7 +1,7 @@
const ns = 'app'
export const getStorageNS = (key: string) => `${ns}:${key}`
export const clearStorage = () => {
export function clearStorage() {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(ns)) {

View File

@@ -16,7 +16,7 @@ export interface LoginResponse extends SessionResponse {
* @param data - Login credentials with email and password
* @returns Session response with user and session data
*/
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
export async function login(data: LoginRequest): Promise<LoginResponse> {
// better-auth returns Response object, we need to parse it
const response = await coreApi<Response>('/auth/sign-in/email', {
method: 'POST',

View File

@@ -9,7 +9,8 @@ export type SessionResponse = {
export const AUTH_SESSION_QUERY_KEY = ['auth', 'session'] as const
export const fetchSession = async () =>
await coreApi<SessionResponse>('/auth/session', {
export async function fetchSession() {
return await coreApi<SessionResponse>('/auth/session', {
method: 'GET',
})
}

View File

@@ -14,7 +14,7 @@ export interface LoginRequest {
rememberMe?: boolean
}
export const useLogin = () => {
export function useLogin() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const setAuthUser = useSetAuthUser()

View File

@@ -5,9 +5,10 @@ import type { DashboardOverviewResponse } from './types'
const DASHBOARD_OVERVIEW_ENDPOINT = '/dashboard/overview'
export const fetchDashboardOverview = async () =>
camelCaseKeys<DashboardOverviewResponse>(
export async function fetchDashboardOverview() {
return camelCaseKeys<DashboardOverviewResponse>(
await coreApi<DashboardOverviewResponse>(DASHBOARD_OVERVIEW_ENDPOINT, {
method: 'GET',
}),
)
}

View File

@@ -125,30 +125,34 @@ const EMPTY_STATS = {
},
} as const
const ActivitySkeleton = () => (
<div className="bg-fill/10 animate-pulse rounded-lg border border-fill-tertiary px-3.5 py-3">
<div className="flex items-start gap-3">
<div className="bg-fill/20 h-11 w-11 shrink-0 rounded-lg" />
<div className="flex-1 space-y-2">
<div className="bg-fill/20 h-3.5 w-32 rounded-full" />
<div className="bg-fill/15 h-3 w-48 rounded-full" />
<div className="bg-fill/15 h-3 w-40 rounded-full" />
function ActivitySkeleton() {
return (
<div className="bg-fill/10 border-fill-tertiary animate-pulse rounded-lg border px-3.5 py-3">
<div className="flex items-start gap-3">
<div className="bg-fill/20 h-11 w-11 shrink-0 rounded-lg" />
<div className="flex-1 space-y-2">
<div className="bg-fill/20 h-3.5 w-32 rounded-full" />
<div className="bg-fill/15 h-3 w-48 rounded-full" />
<div className="bg-fill/15 h-3 w-40 rounded-full" />
</div>
</div>
</div>
</div>
)
)
}
const StatSkeleton = () => (
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden p-5">
<div className="space-y-2.5">
<div className="bg-fill/20 h-3 w-20 rounded-full" />
<div className="bg-fill/30 h-7 w-24 rounded-md" />
<div className="bg-fill/20 h-3 w-32 rounded-full" />
</div>
</LinearBorderPanel>
)
function StatSkeleton() {
return (
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden p-5">
<div className="space-y-2.5">
<div className="bg-fill/20 h-3 w-20 rounded-full" />
<div className="bg-fill/30 h-7 w-24 rounded-md" />
<div className="bg-fill/20 h-3 w-32 rounded-full" />
</div>
</LinearBorderPanel>
)
}
const ActivityList = ({ items }: { items: DashboardRecentActivityItem[] }) => {
function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
if (items.length === 0) {
return <div className="text-text-tertiary mt-5 text-sm"></div>
}
@@ -165,7 +169,7 @@ const ActivityList = ({ items }: { items: DashboardRecentActivityItem[] }) => {
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
className="bg-fill/5 hover:bg-fill/10 group rounded-lg border border-fill-tertiary px-3.5 py-3 transition-colors duration-200"
className="bg-fill/5 hover:bg-fill/10 group border-fill-tertiary rounded-lg border px-3.5 py-3 transition-colors duration-200"
>
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
@@ -221,7 +225,7 @@ const ActivityList = ({ items }: { items: DashboardRecentActivityItem[] }) => {
)
}
export const DashboardOverview = () => {
export function DashboardOverview() {
const { data, isLoading, isError } = useDashboardOverviewQuery()
const stats = data?.stats ?? EMPTY_STATS
@@ -317,7 +321,7 @@ export const DashboardOverview = () => {
))}
</div>
) : isError ? (
<div className="text-red-400 mt-5 text-sm"></div>
<div className="mt-5 text-sm text-red-400"></div>
) : (
<ActivityList items={data?.recentActivity ?? []} />
)}
@@ -357,7 +361,7 @@ export const DashboardOverview = () => {
})}
</div>
<div className="text-text-secondary mt-5 rounded-lg border border-fill-tertiary bg-fill/5 px-3.5 py-2.5 text-xs leading-relaxed">
<div className="text-text-secondary border-fill-tertiary bg-fill/5 mt-5 rounded-lg border px-3.5 py-2.5 text-xs leading-relaxed">
{statusTotal === 0
? '暂无同步任务,添加照片后即可查看同步健康度。'
: syncCompletion !== null && syncCompletion >= 0.85

View File

@@ -5,9 +5,10 @@ import type { DashboardOverviewResponse } from './types'
export const DASHBOARD_OVERVIEW_QUERY_KEY = ['dashboard', 'overview'] as const
export const useDashboardOverviewQuery = () =>
useQuery<DashboardOverviewResponse>({
export function useDashboardOverviewQuery() {
return useQuery<DashboardOverviewResponse>({
queryKey: DASHBOARD_OVERVIEW_QUERY_KEY,
queryFn: fetchDashboardOverview,
staleTime: 60 * 1000,
})
}

View File

@@ -30,13 +30,15 @@ export type OnboardingInitResponse = {
superAdminUserId: string
}
export const getOnboardingStatus = async () =>
await coreApi<OnboardingStatusResponse>('/onboarding/status', {
export async function getOnboardingStatus() {
return await coreApi<OnboardingStatusResponse>('/onboarding/status', {
method: 'GET',
})
}
export const postOnboardingInit = async (payload: OnboardingInitPayload) =>
await coreApi<OnboardingInitResponse>('/onboarding/init', {
export async function postOnboardingInit(payload: OnboardingInitPayload) {
return await coreApi<OnboardingInitResponse>('/onboarding/init', {
method: 'POST',
body: payload,
})
}

View File

@@ -12,7 +12,7 @@ import { createInitialSettingsState, getFieldByKey, isLikelyEmail, maskSecret, s
const INITIAL_STEP_INDEX = 0
export const useOnboardingWizard = () => {
export function useOnboardingWizard() {
const [currentStepIndex, setCurrentStepIndex] = useState(INITIAL_STEP_INDEX)
const [tenant, setTenant] = useState<TenantFormState>({
name: '',

View File

@@ -2,7 +2,7 @@ import type { OnboardingSettingKey, SettingFieldDefinition } from './constants'
import { ONBOARDING_SETTING_SECTIONS, ONBOARDING_STEPS } from './constants'
import type { SettingFormState } from './types'
export const createInitialSettingsState = (): SettingFormState => {
export function createInitialSettingsState(): SettingFormState {
const state = {} as SettingFormState
for (const section of ONBOARDING_SETTING_SECTIONS) {
for (const field of section.fields) {
@@ -14,15 +14,16 @@ export const createInitialSettingsState = (): SettingFormState => {
export const maskSecret = (value: string) => (value ? '•'.repeat(Math.min(10, value.length)) : '')
export const slugify = (value: string) =>
value
export function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replaceAll(/[^a-z0-9-]+/g, '-')
.replaceAll(/-{2,}/g, '-')
.replaceAll(/^-+|-+$/g, '')
}
export const isLikelyEmail = (value: string) => {
export function isLikelyEmail(value: string) {
const trimmed = value.trim()
if (!trimmed.includes('@')) {
return false
@@ -36,7 +37,7 @@ export const isLikelyEmail = (value: string) => {
export const stepProgress = (index: number) => Math.round((index / (ONBOARDING_STEPS.length - 1 || 1)) * 100)
export const getFieldByKey = (key: OnboardingSettingKey): SettingFieldDefinition => {
export function getFieldByKey(key: OnboardingSettingKey): SettingFieldDefinition {
for (const section of ONBOARDING_SETTING_SECTIONS) {
for (const field of section.fields) {
if (field.key === key) {

View File

@@ -19,10 +19,10 @@ type RunPhotoSyncOptions = {
onEvent?: (event: PhotoSyncProgressEvent) => void
}
export const runPhotoSync = async (
export async function runPhotoSync(
payload: RunPhotoSyncPayload,
options?: RunPhotoSyncOptions,
): Promise<PhotoSyncResult> => {
): Promise<PhotoSyncResult> {
const response = await fetch(`${coreApiBaseURL}/data-sync/run`, {
method: 'POST',
headers: {
@@ -134,15 +134,15 @@ export const runPhotoSync = async (
return camelCaseKeys<PhotoSyncResult>(finalResult)
}
export const listPhotoSyncConflicts = async (): Promise<PhotoSyncConflict[]> => {
export async function listPhotoSyncConflicts(): Promise<PhotoSyncConflict[]> {
const conflicts = await coreApi<PhotoSyncConflict[]>('/data-sync/conflicts')
return camelCaseKeys<PhotoSyncConflict[]>(conflicts)
}
export const resolvePhotoSyncConflict = async (
export async function resolvePhotoSyncConflict(
id: string,
payload: { strategy: PhotoSyncResolution; dryRun?: boolean },
): Promise<PhotoSyncAction> => {
): Promise<PhotoSyncAction> {
const result = await coreApi<PhotoSyncAction>(`/data-sync/conflicts/${id}/resolve`, {
method: 'POST',
body: payload,
@@ -151,29 +151,29 @@ export const resolvePhotoSyncConflict = async (
return camelCaseKeys<PhotoSyncAction>(result)
}
export const listPhotoAssets = async (): Promise<PhotoAssetListItem[]> => {
export async function listPhotoAssets(): Promise<PhotoAssetListItem[]> {
const assets = await coreApi<PhotoAssetListItem[]>('/photos/assets')
return camelCaseKeys<PhotoAssetListItem[]>(assets)
}
export const getPhotoAssetSummary = async (): Promise<PhotoAssetSummary> => {
export async function getPhotoAssetSummary(): Promise<PhotoAssetSummary> {
const summary = await coreApi<PhotoAssetSummary>('/photos/assets/summary')
return camelCaseKeys<PhotoAssetSummary>(summary)
}
export const deletePhotoAssets = async (ids: string[]): Promise<void> => {
export async function deletePhotoAssets(ids: string[]): Promise<void> {
await coreApi('/photos/assets', {
method: 'DELETE',
body: { ids },
})
}
export const uploadPhotoAssets = async (
export async function uploadPhotoAssets(
files: File[],
options?: { directory?: string },
): Promise<PhotoAssetListItem[]> => {
): Promise<PhotoAssetListItem[]> {
const formData = new FormData()
if (options?.directory) {
@@ -194,7 +194,7 @@ export const uploadPhotoAssets = async (
return data.assets
}
export const getPhotoStorageUrl = async (storageKey: string): Promise<string> => {
export async function getPhotoStorageUrl(storageKey: string): Promise<string> {
const result = await coreApi<{ url: string }>('/photos/storage-url', {
method: 'GET',
query: { key: storageKey },

View File

@@ -40,8 +40,8 @@ const STAGE_ORDER: PhotoSyncProgressStage[] = [
'status-reconciliation',
]
const createInitialStages = (totals: PhotoSyncProgressState['totals']): PhotoSyncProgressState['stages'] =>
STAGE_ORDER.reduce<PhotoSyncProgressState['stages']>(
function createInitialStages(totals: PhotoSyncProgressState['totals']): PhotoSyncProgressState['stages'] {
return STAGE_ORDER.reduce<PhotoSyncProgressState['stages']>(
(acc, stage) => {
const total = totals[stage]
acc[stage] = {
@@ -53,8 +53,9 @@ const createInitialStages = (totals: PhotoSyncProgressState['totals']): PhotoSyn
},
{} as PhotoSyncProgressState['stages'],
)
}
export const PhotoPage = () => {
export function PhotoPage() {
const [activeTab, setActiveTab] = useState<PhotoPageTab>('sync')
const [result, setResult] = useState<PhotoSyncResult | null>(null)
const [lastWasDryRun, setLastWasDryRun] = useState<boolean | null>(null)

View File

@@ -26,7 +26,7 @@ export interface MasonryRef {
*
* @param props
*/
export const Masonry = <Item,>(props: MasonryProps<Item> & { ref?: React.Ref<MasonryRef> }) => {
export function Masonry<Item>(props: MasonryProps<Item> & { ref?: React.Ref<MasonryRef> }) {
const [scrollTop, setScrollTop] = React.useState(0)
const [isScrolling, setIsScrolling] = React.useState(false)
const scrollElement = useScrollViewElement()

View File

@@ -13,14 +13,14 @@ type PhotoLibraryActionBarProps = {
onClearSelection: () => void
}
export const PhotoLibraryActionBar = ({
export function PhotoLibraryActionBar({
selectionCount,
isUploading,
isDeleting,
onUpload,
onDeleteSelected,
onClearSelection,
}: PhotoLibraryActionBarProps) => {
}: PhotoLibraryActionBarProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null)
const handleUploadClick = () => {

View File

@@ -15,7 +15,7 @@ type PhotoLibraryGridProps = {
isDeleting?: boolean
}
const PhotoGridItem = ({
function PhotoGridItem({
asset,
isSelected,
onToggleSelect,
@@ -29,7 +29,7 @@ const PhotoGridItem = ({
onOpenAsset: (asset: PhotoAssetListItem) => void
onDeleteAsset: (asset: PhotoAssetListItem) => Promise<void> | void
isDeleting?: boolean
}) => {
}) {
const manifest = asset.manifest?.data
const previewUrl = manifest?.thumbnailUrl ?? manifest?.originalUrl ?? asset.publicUrl
const deviceLabel = manifest?.exif?.Model || manifest?.exif?.Make || '未知设备'
@@ -142,7 +142,7 @@ const PhotoGridItem = ({
)
}
export const PhotoLibraryGrid = ({
export function PhotoLibraryGrid({
assets,
isLoading,
selectedIds,
@@ -150,7 +150,7 @@ export const PhotoLibraryGrid = ({
onOpenAsset,
onDeleteAsset,
isDeleting,
}: PhotoLibraryGridProps) => {
}: PhotoLibraryGridProps) {
if (isLoading) {
const skeletonKeys = [
'photo-skeleton-1',

View File

@@ -14,7 +14,7 @@ type PhotoSyncActionsProps = {
onError?: (error: Error) => void
}
export const PhotoSyncActions = ({ onCompleted, onProgress, onError }: PhotoSyncActionsProps) => {
export function PhotoSyncActions({ onCompleted, onProgress, onError }: PhotoSyncActionsProps) {
const { setHeaderActionState } = useMainPageLayout()
const [pendingMode, setPendingMode] = useState<'dry-run' | 'apply' | null>(null)
const abortRef = useRef<AbortController | null>(null)

View File

@@ -18,7 +18,7 @@ type PhotoSyncConflictsPanelProps = {
onRequestStorageUrl?: (storageKey: string) => Promise<string>
}
const formatDate = (value: string) => {
function formatDate(value: string) {
try {
return new Date(value).toLocaleString()
} catch {
@@ -26,7 +26,7 @@ const formatDate = (value: string) => {
}
}
export const PhotoSyncConflictsPanel = ({
export function PhotoSyncConflictsPanel({
conflicts,
isLoading,
resolvingId,
@@ -34,7 +34,7 @@ export const PhotoSyncConflictsPanel = ({
onResolve,
onResolveBatch,
onRequestStorageUrl,
}: PhotoSyncConflictsPanelProps) => {
}: PhotoSyncConflictsPanelProps) {
const sortedConflicts = useMemo(() => {
if (!conflicts) return []
return conflicts.toSorted((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
@@ -453,7 +453,7 @@ export const PhotoSyncConflictsPanel = ({
)
}
const ConflictManifestPreview = ({
function ConflictManifestPreview({
manifest,
disabled,
onOpenOriginal,
@@ -461,7 +461,7 @@ const ConflictManifestPreview = ({
manifest: PhotoSyncConflict['manifest']['data'] | null | undefined
disabled?: boolean
onOpenOriginal?: () => void
}) => {
}) {
if (!manifest) {
return (
<div className="border-border/20 bg-background-secondary/60 text-text-tertiary rounded-md border p-3 text-xs">
@@ -511,7 +511,7 @@ const ConflictManifestPreview = ({
)
}
const ConflictStoragePreview = ({
function ConflictStoragePreview({
storageKey,
snapshot,
disabled,
@@ -521,20 +521,22 @@ const ConflictStoragePreview = ({
snapshot: PhotoSyncSnapshot | null | undefined
disabled?: boolean
onOpenStorage?: () => void
}) => (
<div className="border-border/20 bg-background-secondary/60 text-text-tertiary rounded-md border p-3 text-xs">
<div className="flex items-center justify-between">
<p className="text-text text-sm font-semibold"></p>
{onOpenStorage ? (
<Button type="button" variant="ghost" size="xs" disabled={disabled} onClick={onOpenStorage}>
</Button>
) : null}
}) {
return (
<div className="border-border/20 bg-background-secondary/60 text-text-tertiary rounded-md border p-3 text-xs">
<div className="flex items-center justify-between">
<p className="text-text text-sm font-semibold"></p>
{onOpenStorage ? (
<Button type="button" variant="ghost" size="xs" disabled={disabled} onClick={onOpenStorage}>
</Button>
) : null}
</div>
<p className="mt-1 break-all">
Key
<span className="text-text font-mono text-[11px]">{storageKey}</span>
</p>
<MetadataSnapshot snapshot={snapshot ?? null} />
</div>
<p className="mt-1 break-all">
Key
<span className="text-text font-mono text-[11px]">{storageKey}</span>
</p>
<MetadataSnapshot snapshot={snapshot ?? null} />
</div>
)
)
}

View File

@@ -50,7 +50,7 @@ type PhotoSyncProgressPanelProps = {
progress: PhotoSyncProgressState
}
const formatActionLabel = (action: PhotoSyncAction) => {
function formatActionLabel(action: PhotoSyncAction) {
const config = PHOTO_ACTION_TYPE_CONFIG[action.type]
if (action.type === 'conflict' && action.conflictPayload) {
const conflictLabel = getConflictTypeLabel(action.conflictPayload.type)
@@ -60,7 +60,7 @@ const formatActionLabel = (action: PhotoSyncAction) => {
return config?.label ?? action.type
}
export const PhotoSyncProgressPanel = ({ progress }: PhotoSyncProgressPanelProps) => {
export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps) {
const isErrored = Boolean(progress.error)
const heading = isErrored ? '同步失败' : progress.dryRun ? '同步预览进行中' : '同步进行中'
const subtitle = isErrored

View File

@@ -6,14 +6,16 @@ import { useMemo, useState } from 'react'
import { getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants'
import type { PhotoAssetSummary, PhotoSyncAction, PhotoSyncResult, PhotoSyncSnapshot } from '../../types'
export const BorderOverlay = () => (
<>
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
</>
)
export function BorderOverlay() {
return (
<>
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
</>
)
}
type SummaryCardProps = {
label: string
@@ -21,7 +23,7 @@ type SummaryCardProps = {
tone?: 'accent' | 'warning' | 'muted'
}
const SummaryCard = ({ label, value, tone }: SummaryCardProps) => {
function SummaryCard({ label, value, tone }: SummaryCardProps) {
const toneClass =
tone === 'accent'
? 'text-accent'
@@ -52,13 +54,13 @@ const actionTypeConfig = PHOTO_ACTION_TYPE_CONFIG
const SUMMARY_SKELETON_KEYS = ['summary-skeleton-1', 'summary-skeleton-2', 'summary-skeleton-3', 'summary-skeleton-4']
export const PhotoSyncResultPanel = ({
export function PhotoSyncResultPanel({
result,
lastWasDryRun,
baselineSummary,
isSummaryLoading,
onRequestStorageUrl,
}: PhotoSyncResultPanelProps) => {
}: PhotoSyncResultPanelProps) {
const summaryItems = useMemo(() => {
if (result) {
return [
@@ -439,7 +441,7 @@ export const PhotoSyncResultPanel = ({
)
}
const ManifestPreview = ({
function ManifestPreview({
title,
manifest,
onOpenOriginal,
@@ -447,7 +449,7 @@ const ManifestPreview = ({
title: string
manifest: PhotoSyncAction['manifestAfter'] | PhotoSyncAction['manifestBefore']
onOpenOriginal?: () => void
}) => {
}) {
if (!manifest) {
return (
<div className="border-border/20 bg-background-secondary/60 text-text-tertiary rounded-md border p-3 text-xs">
@@ -501,7 +503,7 @@ type MetadataSnapshotProps = {
snapshot: PhotoSyncSnapshot | null | undefined
}
export const MetadataSnapshot = ({ snapshot }: MetadataSnapshotProps) => {
export function MetadataSnapshot({ snapshot }: MetadataSnapshotProps) {
if (!snapshot) return null
return (
<dl className="mt-2 space-y-1">

View File

@@ -15,7 +15,7 @@ export const PHOTO_CONFLICT_TYPE_CONFIG: Record<PhotoSyncConflictType, { label:
},
}
export const getConflictTypeLabel = (type: PhotoSyncConflictType | null | undefined): string => {
export function getConflictTypeLabel(type: PhotoSyncConflictType | null | undefined): string {
if (!type) {
return '冲突'
}

View File

@@ -14,14 +14,14 @@ export const PHOTO_ASSET_SUMMARY_QUERY_KEY = ['photo-assets', 'summary'] as cons
export const PHOTO_ASSET_LIST_QUERY_KEY = ['photo-assets', 'list'] as const
export const PHOTO_SYNC_CONFLICTS_QUERY_KEY = ['photo-sync', 'conflicts'] as const
export const usePhotoAssetSummaryQuery = () => {
export function usePhotoAssetSummaryQuery() {
return useQuery({
queryKey: PHOTO_ASSET_SUMMARY_QUERY_KEY,
queryFn: getPhotoAssetSummary,
})
}
export const usePhotoAssetListQuery = (options?: { enabled?: boolean }) => {
export function usePhotoAssetListQuery(options?: { enabled?: boolean }) {
return useQuery({
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
queryFn: listPhotoAssets,
@@ -29,7 +29,7 @@ export const usePhotoAssetListQuery = (options?: { enabled?: boolean }) => {
})
}
export const usePhotoSyncConflictsQuery = (options?: { enabled?: boolean }) => {
export function usePhotoSyncConflictsQuery(options?: { enabled?: boolean }) {
return useQuery({
queryKey: PHOTO_SYNC_CONFLICTS_QUERY_KEY,
queryFn: listPhotoSyncConflicts,
@@ -37,7 +37,7 @@ export const usePhotoSyncConflictsQuery = (options?: { enabled?: boolean }) => {
})
}
export const useDeletePhotoAssetsMutation = () => {
export function useDeletePhotoAssetsMutation() {
const queryClient = useQueryClient()
return useMutation({
@@ -61,7 +61,7 @@ export const useDeletePhotoAssetsMutation = () => {
})
}
export const useUploadPhotoAssetsMutation = () => {
export function useUploadPhotoAssetsMutation() {
const queryClient = useQueryClient()
return useMutation({
@@ -79,7 +79,7 @@ export const useUploadPhotoAssetsMutation = () => {
})
}
export const useResolvePhotoSyncConflictMutation = () => {
export function useResolvePhotoSyncConflictMutation() {
const queryClient = useQueryClient()
return useMutation({

View File

@@ -28,7 +28,7 @@ import type {
UiSlotComponent,
} from './types'
const FieldDescription = ({ description }: { description?: string | null }) => {
function FieldDescription({ description }: { description?: string | null }) {
if (!description) {
return null
}
@@ -36,7 +36,7 @@ const FieldDescription = ({ description }: { description?: string | null }) => {
return <p className="text-text-tertiary mt-1 text-xs">{description}</p>
}
const SchemaIcon = ({ name, className }: { name?: string | null; className?: string }) => {
function SchemaIcon({ name, className }: { name?: string | null; className?: string }) {
if (!name) {
return null
}
@@ -44,7 +44,7 @@ const SchemaIcon = ({ name, className }: { name?: string | null; className?: str
return <DynamicIcon name={name as any} className={clsxm('h-4 w-4', className)} />
}
const SecretFieldInput = <Key extends string>({
function SecretFieldInput<Key extends string>({
component,
fieldKey,
value,
@@ -54,7 +54,7 @@ const SecretFieldInput = <Key extends string>({
fieldKey: Key
value: string
onChange: (key: Key, value: SchemaFormValue) => void
}) => {
}) {
const [revealed, setRevealed] = useState(false)
return (
@@ -96,13 +96,7 @@ type FieldRendererProps<Key extends string> = {
context: SchemaRendererContext<Key>
}
const FieldRenderer = <Key extends string>({
field,
value,
onChange,
renderSlot,
context,
}: FieldRendererProps<Key>) => {
function FieldRenderer<Key extends string>({ field, value, onChange, renderSlot, context }: FieldRendererProps<Key>) {
const { component } = field
if (component.type === 'slot') {
@@ -173,14 +167,14 @@ const FieldRenderer = <Key extends string>({
)
}
const renderGroup = <Key extends string>(
function renderGroup<Key extends string>(
node: UiGroupNode<Key>,
context: SchemaRendererContext<Key>,
formState: SchemaFormState<Key>,
handleChange: (key: Key, value: SchemaFormValue) => void,
shouldRenderNode?: SchemaFormRendererProps<Key>['shouldRenderNode'],
renderSlot?: SlotRenderer<Key>,
) => {
) {
const renderedChildren = node.children
.map((child) => renderNode(child, context, formState, handleChange, shouldRenderNode, renderSlot))
.filter(Boolean)
@@ -208,13 +202,13 @@ const renderGroup = <Key extends string>(
)
}
const renderField = <Key extends string>(
function renderField<Key extends string>(
field: UiFieldNode<Key>,
formState: SchemaFormState<Key>,
handleChange: (key: Key, value: SchemaFormValue) => void,
renderSlot: SlotRenderer<Key> | undefined,
context: SchemaRendererContext<Key>,
) => {
) {
if (field.hidden) {
return null
}
@@ -266,14 +260,14 @@ const renderField = <Key extends string>(
)
}
const renderNode = <Key extends string>(
function renderNode<Key extends string>(
node: UiNode<Key>,
context: SchemaRendererContext<Key>,
formState: SchemaFormState<Key>,
handleChange: (key: Key, value: SchemaFormValue) => void,
shouldRenderNode?: SchemaFormRendererProps<Key>['shouldRenderNode'],
renderSlot?: SlotRenderer<Key>,
): ReactNode => {
): ReactNode {
if (shouldRenderNode && !shouldRenderNode(node, context)) {
return null
}
@@ -318,13 +312,13 @@ export interface SchemaFormRendererProps<Key extends string> {
renderSlot?: SlotRenderer<Key>
}
export const SchemaFormRenderer = <Key extends string>({
export function SchemaFormRenderer<Key extends string>({
schema,
values,
onChange,
shouldRenderNode,
renderSlot,
}: SchemaFormRendererProps<Key>) => {
}: SchemaFormRendererProps<Key>) {
const context: SchemaRendererContext<Key> = { values }
return (

View File

@@ -24,7 +24,7 @@ export interface UiTextareaComponent extends UiFieldComponentBase<'textarea'> {
export interface UiSelectComponent extends UiFieldComponentBase<'select'> {
readonly placeholder?: string
readonly options?: ReadonlyArray<string>
readonly options?: readonly string[]
readonly allowCustom?: boolean
}

View File

@@ -1,7 +1,7 @@
import type { UiFieldNode, UiNode } from './types'
export const collectFieldNodes = <Key extends string>(nodes: ReadonlyArray<UiNode<Key>>): UiFieldNode<Key>[] => {
const fields: UiFieldNode<Key>[] = []
export function collectFieldNodes<Key extends string>(nodes: ReadonlyArray<UiNode<Key>>): Array<UiFieldNode<Key>> {
const fields: Array<UiFieldNode<Key>> = []
for (const node of nodes) {
if (node.type === 'field') {

View File

@@ -2,11 +2,11 @@ import { coreApi } from '~/lib/api-client'
import type { SettingEntryInput, SettingUiSchemaResponse } from './types'
export const getSettingUiSchema = async () => {
export async function getSettingUiSchema() {
return await coreApi<SettingUiSchemaResponse>('/settings/ui-schema')
}
export const getSettings = async (keys: ReadonlyArray<string>) => {
export async function getSettings(keys: readonly string[]) {
return await coreApi<{
keys: string[]
values: Record<string, string | null>
@@ -16,8 +16,8 @@ export const getSettings = async (keys: ReadonlyArray<string>) => {
})
}
export const updateSettings = async (entries: ReadonlyArray<SettingEntryInput>) => {
return await coreApi<{ updated: ReadonlyArray<SettingEntryInput> }>('/settings', {
export async function updateSettings(entries: readonly SettingEntryInput[]) {
return await coreApi<{ updated: readonly SettingEntryInput[] }>('/settings', {
method: 'POST',
body: { entries },
})

View File

@@ -20,10 +20,10 @@ const providerGroupVisibility: Record<string, string> = {
'builder-storage-eagle': 'eagle',
}
const buildInitialState = (
function buildInitialState(
schema: SettingUiSchemaResponse['schema'],
values: SettingUiSchemaResponse['values'],
): SettingValueState<string> => {
): SettingValueState<string> {
const state: SettingValueState<string> = {} as SettingValueState<string>
const fields = collectFieldNodes(schema.sections)
@@ -35,7 +35,7 @@ const buildInitialState = (
return state
}
export const SettingsForm = () => {
export function SettingsForm() {
const { data, isLoading, isError, error } = useSettingUiSchemaQuery()
const updateSettingsMutation = useUpdateSettingsMutation()
const { setHeaderActionState } = useMainPageLayout()

View File

@@ -19,7 +19,7 @@ type SettingsNavigationProps = {
active: (typeof SETTINGS_TABS)[number]['id']
}
export const SettingsNavigation = ({ active }: SettingsNavigationProps) => {
export function SettingsNavigation({ active }: SettingsNavigationProps) {
return (
<PageTabs
activeId={active}

View File

@@ -5,18 +5,18 @@ import type { SettingEntryInput } from './types'
export const SETTING_UI_SCHEMA_QUERY_KEY = ['settings', 'ui-schema'] as const
export const useSettingUiSchemaQuery = () => {
export function useSettingUiSchemaQuery() {
return useQuery({
queryKey: SETTING_UI_SCHEMA_QUERY_KEY,
queryFn: getSettingUiSchema,
})
}
export const useUpdateSettingsMutation = () => {
export function useUpdateSettingsMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (entries: ReadonlyArray<SettingEntryInput>) => {
mutationFn: async (entries: readonly SettingEntryInput[]) => {
await updateSettings(entries)
},
onSuccess: () => {

View File

@@ -29,13 +29,13 @@ type ProviderEditModalProps = ModalComponentProps & {
onSetActive: (id: string) => void
}
export const ProviderEditModal = ({
export function ProviderEditModal({
provider,
onSave,
dismiss,
}: ProviderEditModalProps) => {
}: ProviderEditModalProps) {
const [formData, setFormData] = useState<StorageProvider | null>(provider)
const [isDirty, setIsDirty] = useState(false)

View File

@@ -12,7 +12,7 @@ import { createEmptyProvider, reorderProvidersByActive } from '../utils'
import { ProviderCard } from './ProviderCard'
import { ProviderEditModal } from './ProviderEditModal'
export const StorageProvidersManager = () => {
export function StorageProvidersManager() {
const { data, isLoading, isError, error } = useStorageProvidersQuery()
const updateMutation = useUpdateStorageProvidersMutation()
const { setHeaderActionState } = useMainPageLayout()

View File

@@ -19,7 +19,7 @@ export const STORAGE_PROVIDER_TYPE_OPTIONS: ReadonlyArray<{
export const STORAGE_PROVIDER_FIELD_DEFINITIONS: Record<
StorageProviderType,
ReadonlyArray<StorageProviderFieldDefinition>
readonly StorageProviderFieldDefinition[]
> = {
s3: [
{

View File

@@ -13,7 +13,7 @@ import {
export const STORAGE_PROVIDERS_QUERY_KEY = ['settings', 'storage-providers'] as const
export const useStorageProvidersQuery = () => {
export function useStorageProvidersQuery() {
return useQuery({
queryKey: STORAGE_PROVIDERS_QUERY_KEY,
queryFn: async () => {
@@ -33,7 +33,7 @@ export const useStorageProvidersQuery = () => {
})
}
export const useUpdateStorageProvidersMutation = () => {
export function useUpdateStorageProvidersMutation() {
const queryClient = useQueryClient()
return useMutation({
@@ -65,10 +65,10 @@ export const useUpdateStorageProvidersMutation = () => {
})
}
const restoreProviderSecrets = (
function restoreProviderSecrets(
nextProviders: StorageProvider[],
previousProviders: StorageProvider[],
): StorageProvider[] => {
): StorageProvider[] {
const previousMap = new Map(previousProviders.map((provider) => [provider.id, provider]))
return nextProviders.map((provider) => {

View File

@@ -1,18 +1,18 @@
import { STORAGE_PROVIDER_FIELD_DEFINITIONS, STORAGE_PROVIDER_TYPES } from './constants'
import type { StorageProvider, StorageProviderType } from './types'
const generateId = () => {
function generateId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return Math.random().toString(36).slice(2, 10)
}
export const isStorageProviderType = (value: unknown): value is StorageProviderType => {
export function isStorageProviderType(value: unknown): value is StorageProviderType {
return STORAGE_PROVIDER_TYPES.includes(value as StorageProviderType)
}
const normaliseConfigForType = (type: StorageProviderType, config: Record<string, unknown>): Record<string, string> => {
function 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)
@@ -20,7 +20,7 @@ const normaliseConfigForType = (type: StorageProviderType, config: Record<string
}, {})
}
const coerceProvider = (input: unknown): StorageProvider | null => {
function coerceProvider(input: unknown): StorageProvider | null {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return null
}
@@ -50,7 +50,7 @@ const coerceProvider = (input: unknown): StorageProvider | null => {
return provider
}
export const parseStorageProviders = (raw: string | null): StorageProvider[] => {
export function parseStorageProviders(raw: string | null): StorageProvider[] {
if (!raw) {
return []
}
@@ -67,7 +67,7 @@ export const parseStorageProviders = (raw: string | null): StorageProvider[] =>
}
}
export const serializeStorageProviders = (providers: ReadonlyArray<StorageProvider>): string => {
export function serializeStorageProviders(providers: readonly StorageProvider[]): string {
return JSON.stringify(
providers.map((provider) => ({
...provider,
@@ -76,21 +76,21 @@ export const serializeStorageProviders = (providers: ReadonlyArray<StorageProvid
)
}
export const normalizeStorageProviderConfig = (provider: StorageProvider): StorageProvider => {
export function normalizeStorageProviderConfig(provider: StorageProvider): StorageProvider {
return {
...provider,
config: normaliseConfigForType(provider.type, provider.config),
}
}
export const getDefaultConfigForType = (type: StorageProviderType): Record<string, string> => {
export function getDefaultConfigForType(type: StorageProviderType): Record<string, string> {
return STORAGE_PROVIDER_FIELD_DEFINITIONS[type].reduce<Record<string, string>>((acc, field) => {
acc[field.key] = ''
return acc
}, {})
}
export const createEmptyProvider = (type: StorageProviderType): StorageProvider => {
export function createEmptyProvider(type: StorageProviderType): StorageProvider {
const timestamp = new Date().toISOString()
return {
id: '',
@@ -102,10 +102,7 @@ export const createEmptyProvider = (type: StorageProviderType): StorageProvider
}
}
export const ensureActiveProviderId = (
providers: ReadonlyArray<StorageProvider>,
activeId: string | null,
): string | null => {
export function ensureActiveProviderId(providers: readonly StorageProvider[], activeId: string | null): string | null {
if (!activeId) {
return null
}
@@ -113,10 +110,10 @@ export const ensureActiveProviderId = (
return providers.some((provider) => provider.id === activeId) ? activeId : null
}
export const reorderProvidersByActive = (
providers: ReadonlyArray<StorageProvider>,
export function reorderProvidersByActive(
providers: readonly StorageProvider[],
activeId: string | null,
): StorageProvider[] => {
): StorageProvider[] {
if (!activeId) {
return [...providers]
}

View File

@@ -4,12 +4,13 @@ import type { SuperAdminSettingsResponse, UpdateSuperAdminSettingsPayload } from
const SUPER_ADMIN_SETTINGS_ENDPOINT = '/super-admin/settings'
export const fetchSuperAdminSettings = async () =>
await coreApi<SuperAdminSettingsResponse>(`${SUPER_ADMIN_SETTINGS_ENDPOINT}`, {
export async function fetchSuperAdminSettings() {
return await coreApi<SuperAdminSettingsResponse>(`${SUPER_ADMIN_SETTINGS_ENDPOINT}`, {
method: 'GET',
})
}
export const updateSuperAdminSettings = async (payload: UpdateSuperAdminSettingsPayload) => {
export async function updateSuperAdminSettings(payload: UpdateSuperAdminSettingsPayload) {
const sanitizedEntries = Object.entries(payload).filter(([, value]) => value !== undefined)
const body = Object.fromEntries(sanitizedEntries)

View File

@@ -19,13 +19,15 @@ type FormState = Record<SuperAdminSettingField, SchemaFormValue>
const BOOLEAN_FIELDS = new Set<SuperAdminSettingField>(['allowRegistration', 'localProviderEnabled'])
const toFormState = (settings: SuperAdminSettings): FormState => ({
allowRegistration: settings.allowRegistration,
localProviderEnabled: settings.localProviderEnabled,
maxRegistrableUsers: settings.maxRegistrableUsers === null ? '' : String(settings.maxRegistrableUsers),
})
function toFormState(settings: SuperAdminSettings): FormState {
return {
allowRegistration: settings.allowRegistration,
localProviderEnabled: settings.localProviderEnabled,
maxRegistrableUsers: settings.maxRegistrableUsers === null ? '' : String(settings.maxRegistrableUsers),
}
}
const areFormStatesEqual = (left: FormState | null, right: FormState | null): boolean => {
function areFormStatesEqual(left: FormState | null, right: FormState | null): boolean {
if (left === right) {
return true
}
@@ -41,7 +43,7 @@ const areFormStatesEqual = (left: FormState | null, right: FormState | null): bo
)
}
const normalizeMaxUsers = (value: SchemaFormValue): string => {
function normalizeMaxUsers(value: SchemaFormValue): string {
if (typeof value === 'string') {
return value
}
@@ -61,7 +63,7 @@ type PossiblySnakeCaseSettings = Partial<
}
>
const coerceMaxUsers = (value: unknown): number | null => {
function coerceMaxUsers(value: unknown): number | null {
if (value === undefined || value === null) {
return null
}
@@ -74,7 +76,7 @@ const coerceMaxUsers = (value: unknown): number | null => {
return Number.isFinite(parsed) ? parsed : null
}
const normalizeServerSettings = (input: PossiblySnakeCaseSettings | null): SuperAdminSettings | null => {
function normalizeServerSettings(input: PossiblySnakeCaseSettings | null): SuperAdminSettings | null {
if (!input) {
return null
}
@@ -98,7 +100,7 @@ const normalizeServerSettings = (input: PossiblySnakeCaseSettings | null): Super
return null
}
const extractServerValues = (payload: SuperAdminSettingsResponse): SuperAdminSettings | null => {
function extractServerValues(payload: SuperAdminSettingsResponse): SuperAdminSettings | null {
if ('values' in payload) {
return normalizeServerSettings(payload.values ?? null)
}
@@ -110,7 +112,7 @@ const extractServerValues = (payload: SuperAdminSettingsResponse): SuperAdminSet
return null
}
export const SuperAdminSettingsForm = () => {
export function SuperAdminSettingsForm() {
const { data, isLoading, isError, error } = useSuperAdminSettingsQuery()
const [formState, setFormState] = useState<FormState | null>(null)
const [initialState, setInitialState] = useState<FormState | null>(null)

View File

@@ -5,18 +5,19 @@ import type { SuperAdminSettingsResponse, UpdateSuperAdminSettingsPayload } from
export const SUPER_ADMIN_SETTINGS_QUERY_KEY = ['super-admin', 'settings'] as const
export const useSuperAdminSettingsQuery = () =>
useQuery<SuperAdminSettingsResponse>({
export function useSuperAdminSettingsQuery() {
return useQuery<SuperAdminSettingsResponse>({
queryKey: SUPER_ADMIN_SETTINGS_QUERY_KEY,
queryFn: fetchSuperAdminSettings,
staleTime: 60 * 1000,
})
}
type SuperAdminSettingsMutationOptions = {
onSuccess?: (data: SuperAdminSettingsResponse) => void
}
export const useUpdateSuperAdminSettingsMutation = (options?: SuperAdminSettingsMutationOptions) => {
export function useUpdateSuperAdminSettingsMutation(options?: SuperAdminSettingsMutationOptions) {
const queryClient = useQueryClient()
return useMutation({

View File

@@ -1,6 +1,6 @@
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
export const Component = () => {
export function Component() {
return (
<MainPageLayout title="Analytics" description="Track your photo collection statistics and trends">
<div className="grid gap-4 md:grid-cols-2">

View File

@@ -13,7 +13,7 @@ const navigationTabs = [
{ label: 'Analytics', path: '/analytics' },
] as const
export const Component = () => {
export function Component() {
const { logout } = usePageRedirect()
const user = useAuthUserValue()
const [isLoggingOut, setIsLoggingOut] = useState(false)

View File

@@ -1,5 +1,5 @@
import { PhotoPage } from '~/modules/photos'
export const Component = () => {
export function Component() {
return <PhotoPage />
}

View File

@@ -1,7 +1,7 @@
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { SettingsForm, SettingsNavigation } from '~/modules/settings'
export const Component = () => {
export function Component() {
return (
<MainPageLayout title="系统设置" description="管理后台与核心功能的通用配置,修改后会立即同步生效。">
<div className="space-y-6">

View File

@@ -2,7 +2,7 @@ import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { SettingsNavigation } from '~/modules/settings'
import { StorageProvidersManager } from '~/modules/storage-providers'
export const Component = () => {
export function Component() {
return (
<MainPageLayout
title="素材存储与 Builder"

View File

@@ -6,7 +6,7 @@ import { useState } from 'react'
import { useLogin } from '~/modules/auth/hooks/useLogin'
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
export const Component = () => {
export function Component() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const { login, isLoading, error, clearError } = useLogin()

View File

@@ -1,5 +1,5 @@
import { Navigate } from 'react-router'
export const Component = () => {
export function Component() {
return <Navigate to="/superadmin/settings" replace />
}

View File

@@ -9,7 +9,7 @@ import { usePageRedirect } from '~/hooks/usePageRedirect'
const navigationTabs = [{ label: '系统设置', path: '/superadmin/settings' }] as const
export const Component = () => {
export function Component() {
const { logout } = usePageRedirect()
const user = useAuthUserValue()
const isSuperAdmin = useIsSuperAdmin()

View File

@@ -3,7 +3,7 @@ import { m } from 'motion/react'
import { SuperAdminSettingsForm } from '~/modules/super-admin'
export const Component = () => {
export function Component() {
return (
<m.div
initial={{ opacity: 0, y: 8 }}

View File

@@ -24,7 +24,7 @@ export const ContextMenuProvider: Component = ({ children }) => (
</>
)
const Handler = () => {
function Handler() {
const ref = useRef<HTMLSpanElement>(null)
const [contextMenuState, setContextMenuState] = useContextMenuState()

View File

@@ -23,7 +23,7 @@ window.router = {
* And use our router hooks will not re-render the component when the router has any changes.
* Also it can access values outside of the component and provide a value selector
*/
export const StableRouterProvider = () => {
export function StableRouterProvider() {
const [searchParams] = useSearchParams()
const params = useParams()
const nav = useNavigate()