294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
|
|
'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_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'),
|
||
|
|
})
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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']
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
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) },
|
||
|
|
],
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{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>
|
||
|
|
)
|
||
|
|
}
|