MOPC-App/src/components/settings/storage-settings-form.tsx

367 lines
14 KiB
TypeScript
Raw Normal View History

'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { HardDrive, Loader2, Cloud, FolderOpen } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
// Note: Storage provider cache is cleared server-side when settings are updated
const COMMON_IMAGE_TYPES = [
{ value: 'image/png', label: 'PNG (.png)' },
{ value: 'image/jpeg', label: 'JPEG (.jpg, .jpeg)' },
{ value: 'image/webp', label: 'WebP (.webp)' },
{ value: 'image/gif', label: 'GIF (.gif)' },
{ value: 'image/svg+xml', label: 'SVG (.svg)' },
]
const COMMON_FILE_TYPES = [
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' },
{ value: 'video/quicktime', label: 'QuickTime Video (.mov)' },
{ value: 'image/png', label: 'PNG Images (.png)' },
{ value: 'image/jpeg', label: 'JPEG Images (.jpg, .jpeg)' },
{ value: 'image/gif', label: 'GIF Images (.gif)' },
{ value: 'image/webp', label: 'WebP Images (.webp)' },
{ value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', label: 'Word Documents (.docx)' },
{ value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', label: 'Excel Spreadsheets (.xlsx)' },
{ value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', label: 'PowerPoint (.pptx)' },
]
const formSchema = z.object({
storage_provider: z.enum(['s3', 'local']),
local_storage_path: z.string().optional(),
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'),
allowed_image_types: z.array(z.string()).min(1, 'Select at least one image type'),
})
type FormValues = z.infer<typeof formSchema>
interface StorageSettingsFormProps {
settings: {
storage_provider?: string
local_storage_path?: string
max_file_size_mb?: string
avatar_max_size_mb?: string
allowed_file_types?: string
allowed_image_types?: string
}
}
export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
const utils = trpc.useUtils()
// Parse allowed file types from JSON string
let allowedTypes: string[] = []
try {
allowedTypes = settings.allowed_file_types
? JSON.parse(settings.allowed_file_types)
: ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
} catch {
allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
}
// Parse allowed image types from JSON string
let allowedImageTypes: string[] = []
try {
allowedImageTypes = settings.allowed_image_types
? JSON.parse(settings.allowed_image_types)
: ['image/png', 'image/jpeg', 'image/webp']
} catch {
allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp']
}
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
storage_provider: (settings.storage_provider as 's3' | 'local') || 's3',
local_storage_path: settings.local_storage_path || './uploads',
max_file_size_mb: settings.max_file_size_mb || '500',
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
allowed_file_types: allowedTypes,
allowed_image_types: allowedImageTypes,
},
})
const storageProvider = form.watch('storage_provider')
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('Storage settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'STORAGE' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
updateSettings.mutate({
settings: [
{ key: 'storage_provider', value: data.storage_provider },
{ key: 'local_storage_path', value: data.local_storage_path || './uploads' },
{ key: 'max_file_size_mb', value: data.max_file_size_mb },
{ key: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
{ key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) },
{ key: 'allowed_image_types', value: JSON.stringify(data.allowed_image_types) },
],
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Storage Provider Selection */}
<FormField
control={form.control}
name="storage_provider"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Storage Provider</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid gap-4 md:grid-cols-2"
>
<div className="flex items-start space-x-3 rounded-lg border p-4">
<RadioGroupItem value="s3" id="s3" className="mt-1" />
<Label htmlFor="s3" className="flex flex-col cursor-pointer">
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4" />
<span className="font-medium">S3 / MinIO</span>
</div>
<span className="text-sm text-muted-foreground">
Store files in MinIO or S3-compatible storage. Recommended for production.
</span>
</Label>
</div>
<div className="flex items-start space-x-3 rounded-lg border p-4">
<RadioGroupItem value="local" id="local" className="mt-1" />
<Label htmlFor="local" className="flex flex-col cursor-pointer">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" />
<span className="font-medium">Local Filesystem</span>
</div>
<span className="text-sm text-muted-foreground">
Store files on the local server. Good for development or single-server deployments.
</span>
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Local Storage Path (only shown when local is selected) */}
{storageProvider === 'local' && (
<FormField
control={form.control}
name="local_storage_path"
render={({ field }) => (
<FormItem>
<FormLabel>Local Storage Path</FormLabel>
<FormControl>
<Input placeholder="./uploads" {...field} />
</FormControl>
<FormDescription>
Directory path where files will be stored. Relative paths are from the app root.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="max_file_size_mb"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum File Size (MB)</FormLabel>
<FormControl>
<Input type="number" min="1" max="2000" placeholder="500" {...field} />
</FormControl>
<FormDescription>
Maximum allowed file upload size in megabytes. Recommended: 500 MB for video uploads.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatar_max_size_mb"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum Avatar/Logo Size (MB)</FormLabel>
<FormControl>
<Input type="number" min="1" max="50" placeholder="5" {...field} />
</FormControl>
<FormDescription>
Maximum size for profile pictures and project logos.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="allowed_file_types"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel>Allowed File Types</FormLabel>
<FormDescription>
Select which file types can be uploaded to the platform
</FormDescription>
</div>
<div className="grid gap-3 md:grid-cols-2">
{COMMON_FILE_TYPES.map((type) => (
<FormField
key={type.value}
control={form.control}
name="allowed_file_types"
render={({ field }) => {
return (
<FormItem
key={type.value}
className="flex items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(type.value)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, type.value])
: field.onChange(
field.value?.filter(
(value) => value !== type.value
)
)
}}
/>
</FormControl>
<FormLabel className="cursor-pointer text-sm font-normal">
{type.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="allowed_image_types"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel>Allowed Image Types (Avatars/Logos)</FormLabel>
<FormDescription>
Select which image formats can be used for profile pictures and project logos
</FormDescription>
</div>
<div className="grid gap-3 md:grid-cols-2">
{COMMON_IMAGE_TYPES.map((type) => (
<FormField
key={type.value}
control={form.control}
name="allowed_image_types"
render={({ field }) => {
return (
<FormItem
key={type.value}
className="flex items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(type.value)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, type.value])
: field.onChange(
field.value?.filter(
(value) => value !== type.value
)
)
}}
/>
</FormControl>
<FormLabel className="cursor-pointer text-sm font-normal">
{type.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{storageProvider === 's3' && (
<div className="rounded-lg border border-muted bg-muted/50 p-4">
<p className="text-sm text-muted-foreground">
<strong>Note:</strong> MinIO connection settings (endpoint, bucket, credentials) are
configured via environment variables for security. Set <code>MINIO_ENDPOINT</code> and{' '}
<code>MINIO_PUBLIC_ENDPOINT</code> for external MinIO servers.
</p>
</div>
)}
{storageProvider === 'local' && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950">
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Warning:</strong> Local storage is not recommended for production deployments
with multiple servers, as files will only be accessible from the server that uploaded them.
</p>
</div>
)}
<Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<HardDrive className="mr-2 h-4 w-4" />
Save Storage Settings
</>
)}
</Button>
</form>
</Form>
)
}