diff --git a/src/components/admin/storage-form-dialog.tsx b/src/components/admin/storage-form-dialog.tsx deleted file mode 100644 index 37366364..00000000 --- a/src/components/admin/storage-form-dialog.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import type { Storage } from '@shared/types' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { Eye, EyeOff } from 'lucide-react' -import type { ReactNode } from 'react' -import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' -import { z } from 'zod' -import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { createStorage, updateStorage } from '@/lib/api' -import { formatSize } from '@/lib/format' - -const UNITS = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const -type Unit = keyof typeof UNITS - -function bytesToDisplay(bytes: number): { value: number; unit: Unit } { - if (bytes === 0) return { value: 0, unit: 'GB' } - if (bytes >= UNITS.TB && bytes % UNITS.TB === 0) return { value: bytes / UNITS.TB, unit: 'TB' } - if (bytes >= UNITS.GB && bytes % UNITS.GB === 0) return { value: bytes / UNITS.GB, unit: 'GB' } - return { value: bytes / UNITS.MB, unit: 'MB' } -} - -const storageFormSchema = z.object({ - title: z.string().min(1), - mode: z.enum(['private', 'public']), - bucket: z.string().min(1), - endpoint: z.string().url(), - region: z.string().min(1), - accessKey: z.string().min(1), - secretKey: z.string().min(1), - filePath: z.string().min(1), - customHost: z.string().optional(), - capacityValue: z.coerce.number().min(0), - capacityUnit: z.enum(['MB', 'GB', 'TB']), -}) - -type StorageFormValues = z.infer - -const DEFAULT_VALUES: StorageFormValues = { - title: '', - mode: 'private', - bucket: '', - endpoint: '', - region: 'auto', - accessKey: '', - secretKey: '', - filePath: '$UID/$RAW_NAME', - customHost: '', - capacityValue: 0, - capacityUnit: 'GB', -} - -interface StorageFormDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - storage: Storage | null -} - -export function StorageFormDialog({ open, onOpenChange, storage }: StorageFormDialogProps) { - const { t } = useTranslation() - const queryClient = useQueryClient() - const [showSecret, setShowSecret] = useState(false) - const isEditing = storage !== null - - const form = useForm({ - resolver: zodResolver(storageFormSchema), - defaultValues: DEFAULT_VALUES, - }) - - useEffect(() => { - if (!open) return - if (storage) { - const { value, unit } = bytesToDisplay(storage.capacity ?? 0) - form.reset({ - title: storage.title, - mode: storage.mode, - bucket: storage.bucket, - endpoint: storage.endpoint, - region: storage.region, - accessKey: storage.accessKey, - secretKey: storage.secretKey, - filePath: storage.filePath, - customHost: storage.customHost || '', - capacityValue: value, - capacityUnit: unit, - }) - } else { - form.reset(DEFAULT_VALUES) - } - setShowSecret(false) - }, [open, storage, form]) - - const mutation = useMutation({ - mutationFn: ({ capacityValue, capacityUnit, ...rest }: StorageFormValues) => { - const capacity = capacityValue * UNITS[capacityUnit] - return isEditing ? updateStorage(storage.id, { ...rest, capacity }) : createStorage({ ...rest, capacity }) - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] }) - onOpenChange(false) - toast.success(isEditing ? t('admin.storages.updated') : t('admin.storages.created')) - }, - onError: (err) => { - toast.error(err.message) - }, - }) - - function onSubmit(values: StorageFormValues) { - mutation.mutate(values) - } - - return ( - - - - {isEditing ? t('admin.storages.editTitle') : t('admin.storages.addTitle')} - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - - - - - - - - -
- - - - {form.watch('capacityValue') > 0 - ? `= ${formatSize(form.watch('capacityValue') * UNITS[form.watch('capacityUnit')])}` - : t('admin.storages.capacityUnlimited')} - -
-

{t('admin.storages.capacityHint')}

-
-
- - - - - -
-
-
- ) -} - -function FormField({ label, error, children }: { label: string; error?: string; children: ReactNode }) { - return ( -
- - {children} - {error &&

{error}

} -
- ) -} diff --git a/src/components/admin/storage-form-drawer.tsx b/src/components/admin/storage-form-drawer.tsx new file mode 100644 index 00000000..500af6f6 --- /dev/null +++ b/src/components/admin/storage-form-drawer.tsx @@ -0,0 +1,229 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import type { Storage } from '@shared/types' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Eye, EyeOff } from 'lucide-react' +import type { ReactNode } from 'react' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { z } from 'zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { createStorage, updateStorage } from '@/lib/api' +import { formatSize } from '@/lib/format' + +const UNITS = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const +type Unit = keyof typeof UNITS + +function bytesToDisplay(bytes: number): { value: number; unit: Unit } { + if (bytes === 0) return { value: 0, unit: 'GB' } + if (bytes >= UNITS.TB && bytes % UNITS.TB === 0) return { value: bytes / UNITS.TB, unit: 'TB' } + if (bytes >= UNITS.GB && bytes % UNITS.GB === 0) return { value: bytes / UNITS.GB, unit: 'GB' } + return { value: bytes / UNITS.MB, unit: 'MB' } +} + +const storageFormSchema = z.object({ + title: z.string().min(1), + mode: z.enum(['private', 'public']), + bucket: z.string().min(1), + endpoint: z.string().url(), + region: z.string().min(1), + accessKey: z.string().min(1), + secretKey: z.string().min(1), + filePath: z.string().min(1), + customHost: z.string().optional(), + capacityValue: z.coerce.number().min(0), + capacityUnit: z.enum(['MB', 'GB', 'TB']), +}) + +type StorageFormValues = z.infer + +const DEFAULT_VALUES: StorageFormValues = { + title: '', + mode: 'private', + bucket: '', + endpoint: '', + region: 'auto', + accessKey: '', + secretKey: '', + filePath: '$UID/$RAW_NAME', + customHost: '', + capacityValue: 0, + capacityUnit: 'GB', +} + +interface StorageFormDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + storage: Storage | null +} + +export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDrawerProps) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [showSecret, setShowSecret] = useState(false) + const isEditing = storage !== null + + const form = useForm({ + resolver: zodResolver(storageFormSchema), + defaultValues: DEFAULT_VALUES, + }) + + useEffect(() => { + if (!open) return + if (storage) { + const { value, unit } = bytesToDisplay(storage.capacity ?? 0) + form.reset({ + title: storage.title, + mode: storage.mode, + bucket: storage.bucket, + endpoint: storage.endpoint, + region: storage.region, + accessKey: storage.accessKey, + secretKey: storage.secretKey, + filePath: storage.filePath, + customHost: storage.customHost || '', + capacityValue: value, + capacityUnit: unit, + }) + } else { + form.reset(DEFAULT_VALUES) + } + setShowSecret(false) + }, [open, storage, form]) + + const mutation = useMutation({ + mutationFn: ({ capacityValue, capacityUnit, ...rest }: StorageFormValues) => { + const capacity = capacityValue * UNITS[capacityUnit] + return isEditing ? updateStorage(storage.id, { ...rest, capacity }) : createStorage({ ...rest, capacity }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] }) + onOpenChange(false) + toast.success(isEditing ? t('admin.storages.updated') : t('admin.storages.created')) + }, + onError: (err) => { + toast.error(err.message) + }, + }) + + function onSubmit(values: StorageFormValues) { + mutation.mutate(values) + } + + return ( + + + + {isEditing ? t('admin.storages.editTitle') : t('admin.storages.addTitle')} + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + + + + + +
+ + + + {form.watch('capacityValue') > 0 + ? `= ${formatSize(form.watch('capacityValue') * UNITS[form.watch('capacityUnit')])}` + : t('admin.storages.capacityUnlimited')} + +
+

{t('admin.storages.capacityHint')}

+
+
+
+ + + + + +
+
+
+ ) +} + +function FormField({ label, error, children }: { label: string; error?: string; children: ReactNode }) { + return ( +
+ + {children} + {error &&

{error}

} +
+ ) +} diff --git a/src/i18n/admin-storages-locale.test.ts b/src/i18n/admin-storages-locale.test.ts index 0fe88042..6c5c3d17 100644 --- a/src/i18n/admin-storages-locale.test.ts +++ b/src/i18n/admin-storages-locale.test.ts @@ -38,6 +38,9 @@ const ADMIN_STORAGES_KEYS = [ 'admin.storages.fieldFilePath', 'admin.storages.fieldCustomHost', 'admin.storages.customHostPlaceholder', + 'admin.storages.fieldCapacity', + 'admin.storages.capacityUnlimited', + 'admin.storages.capacityHint', ] const ADMIN_NAV_KEYS = ['admin.nav.management', 'admin.nav.storages', 'admin.nav.users'] @@ -173,6 +176,18 @@ describe('admin.storages locale keys — English values contract', () => { it('admin.nav.users is "Users"', () => { expect(enLocale['admin.nav.users']).toBe('Users') }) + + it('admin.storages.fieldCapacity is "Capacity"', () => { + expect(enLocale['admin.storages.fieldCapacity']).toBe('Capacity') + }) + + it('admin.storages.capacityUnlimited is "Unlimited"', () => { + expect(enLocale['admin.storages.capacityUnlimited']).toBe('Unlimited') + }) + + it('admin.storages.capacityHint is "Maximum storage space. 0 means unlimited."', () => { + expect(enLocale['admin.storages.capacityHint']).toBe('Maximum storage space. 0 means unlimited.') + }) }) describe('admin.storages locale keys — i18n runtime translation', () => { @@ -328,4 +343,40 @@ describe('admin.storages locale keys — i18n runtime translation', () => { await i18n.changeLanguage('zh') expect(i18n.t('admin.nav.management')).toBe('管理') }) + + it('translates admin.storages.fieldCapacity to English', async () => { + const { default: i18n } = await import('./index') + await i18n.changeLanguage('en') + expect(i18n.t('admin.storages.fieldCapacity')).toBe('Capacity') + }) + + it('translates admin.storages.fieldCapacity to Chinese', async () => { + const { default: i18n } = await import('./index') + await i18n.changeLanguage('zh') + expect(i18n.t('admin.storages.fieldCapacity')).toBe('可用空间') + }) + + it('translates admin.storages.capacityUnlimited to English', async () => { + const { default: i18n } = await import('./index') + await i18n.changeLanguage('en') + expect(i18n.t('admin.storages.capacityUnlimited')).toBe('Unlimited') + }) + + it('translates admin.storages.capacityUnlimited to Chinese', async () => { + const { default: i18n } = await import('./index') + await i18n.changeLanguage('zh') + expect(i18n.t('admin.storages.capacityUnlimited')).toBe('不限制') + }) + + it('translates admin.storages.capacityHint to English', async () => { + const { default: i18n } = await import('./index') + await i18n.changeLanguage('en') + expect(i18n.t('admin.storages.capacityHint')).toBe('Maximum storage space. 0 means unlimited.') + }) + + it('translates admin.storages.capacityHint to Chinese', async () => { + const { default: i18n } = await import('./index') + await i18n.changeLanguage('zh') + expect(i18n.t('admin.storages.capacityHint')).toBe('最大存储空间,0 表示不限制。') + }) }) diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 00000000..ac7b9968 --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { formatDate, formatSize } from './format' + +describe('formatSize', () => { + it('returns "0 B" for 0 bytes', () => { + expect(formatSize(0)).toBe('0 B') + }) + + it('returns bytes with no decimal for values under 1 KB', () => { + expect(formatSize(512)).toBe('512 B') + }) + + it('returns KB with one decimal for kilobyte values', () => { + expect(formatSize(1024)).toBe('1.0 KB') + }) + + it('returns MB with one decimal for megabyte values', () => { + expect(formatSize(1024 * 1024)).toBe('1.0 MB') + }) + + it('returns GB with one decimal for gigabyte values', () => { + expect(formatSize(1024 * 1024 * 1024)).toBe('1.0 GB') + }) + + it('returns TB with one decimal for terabyte values', () => { + expect(formatSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TB') + }) + + it('returns fractional KB for values between 1 KB and 1 MB', () => { + expect(formatSize(1536)).toBe('1.5 KB') + }) + + it('returns fractional GB for non-round gigabyte values', () => { + expect(formatSize(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB') + }) + + it('returns single byte without decimal', () => { + expect(formatSize(1)).toBe('1 B') + }) + + it('returns 10 GB correctly', () => { + expect(formatSize(10 * 1024 * 1024 * 1024)).toBe('10.0 GB') + }) + + it('returns 100 MB correctly', () => { + expect(formatSize(100 * 1024 * 1024)).toBe('100.0 MB') + }) +}) + +describe('formatDate', () => { + it('returns a locale date string for a valid ISO timestamp', () => { + const result = formatDate('2024-01-15T00:00:00.000Z') + expect(result).toBeTruthy() + expect(result).not.toBe('—') + }) + + it('returns "—" for an empty string', () => { + expect(formatDate('')).toBe('—') + }) + + it('returns "—" for a non-date string', () => { + expect(formatDate('not-a-date')).toBe('—') + }) + + it('returns "—" for a random invalid string', () => { + expect(formatDate('foobar')).toBe('—') + }) + + it('returns a non-empty string for a valid Unix epoch string', () => { + const result = formatDate('2000-06-15') + expect(result).toBeTruthy() + expect(result).not.toBe('—') + }) +}) diff --git a/src/routes/_authenticated/admin/storages/index.tsx b/src/routes/_authenticated/admin/storages/index.tsx index 1605d948..00880aca 100644 --- a/src/routes/_authenticated/admin/storages/index.tsx +++ b/src/routes/_authenticated/admin/storages/index.tsx @@ -1,3 +1,4 @@ +import { StorageStatus } from '@shared/constants' import type { Storage } from '@shared/types' import { useQuery } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' @@ -5,7 +6,7 @@ import { Database, Pencil, Plus, Trash2 } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { DeleteStorageDialog } from '@/components/admin/delete-storage-dialog' -import { StorageFormDialog } from '@/components/admin/storage-form-dialog' +import { StorageFormDrawer } from '@/components/admin/storage-form-drawer' import { Button } from '@/components/ui/button' import { listStorages } from '@/lib/api' @@ -13,8 +14,6 @@ export const Route = createFileRoute('/_authenticated/admin/storages/')({ component: StoragesPage, }) -import { StorageStatus } from '@shared/constants' - function StoragesPage() { const { t } = useTranslation() const [formOpen, setFormOpen] = useState(false) @@ -96,7 +95,7 @@ function StoragesPage() { - +