| import { useState, useRef, useCallback } from "react"; |
| import { Upload, X, File, AlertCircle, Link as LinkIcon } from "lucide-react"; |
| import { Input } from "@/components/ui/input"; |
| import { Button } from "@/components/ui/button"; |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| import { FileConstraints } from "@/server/types/plugin"; |
|
|
| interface FileUploadProps { |
| name: string; |
| description: string; |
| fileConstraints?: FileConstraints; |
| acceptUrl?: boolean; |
| onFileChange: (file: File | null) => void; |
| onUrlChange?: (url: string) => void; |
| disabled?: boolean; |
| } |
|
|
| export function FileUpload({ |
| name, |
| description, |
| fileConstraints, |
| acceptUrl = false, |
| onFileChange, |
| onUrlChange, |
| disabled = false, |
| }: FileUploadProps) { |
| const [selectedFile, setSelectedFile] = useState<File | null>(null); |
| const [fileUrl, setFileUrl] = useState<string>(""); |
| const [error, setError] = useState<string | null>(null); |
| const [dragActive, setDragActive] = useState(false); |
| const [mode, setMode] = useState<"upload" | "url">("upload"); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
| const formatFileSize = (bytes: number): string => { |
| if (bytes === 0) return "0 Bytes"; |
| const k = 1024; |
| const sizes = ["Bytes", "KB", "MB", "GB"]; |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]; |
| }; |
|
|
| const validateFile = (file: File): string | null => { |
| if (!fileConstraints) return null; |
| |
| if (fileConstraints.maxSize && file.size > fileConstraints.maxSize) { |
| return `File size (${formatFileSize(file.size)}) exceeds maximum allowed size (${formatFileSize(fileConstraints.maxSize)})`; |
| } |
|
|
| if (fileConstraints.acceptedTypes && fileConstraints.acceptedTypes.length > 0) { |
| if (!fileConstraints.acceptedTypes.includes(file.type)) { |
| return `File type ${file.type} is not accepted. Allowed types: ${fileConstraints.acceptedTypes.join(", ")}`; |
| } |
| } |
|
|
| if (fileConstraints.acceptedExtensions && fileConstraints.acceptedExtensions.length > 0) { |
| const extension = "." + file.name.split(".").pop()?.toLowerCase(); |
| if (!fileConstraints.acceptedExtensions.includes(extension)) { |
| return `File extension ${extension} is not accepted. Allowed extensions: ${fileConstraints.acceptedExtensions.join(", ")}`; |
| } |
| } |
|
|
| return null; |
| }; |
|
|
| const handleFileSelect = useCallback((file: File) => { |
| const validationError = validateFile(file); |
| |
| if (validationError) { |
| setError(validationError); |
| setSelectedFile(null); |
| onFileChange(null); |
| return; |
| } |
|
|
| setError(null); |
| setSelectedFile(file); |
| onFileChange(file); |
| }, [fileConstraints, onFileChange]); |
|
|
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| if (file) { |
| handleFileSelect(file); |
| } |
| }; |
|
|
| const handleDrag = (e: React.DragEvent) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| if (e.type === "dragenter" || e.type === "dragover") { |
| setDragActive(true); |
| } else if (e.type === "dragleave") { |
| setDragActive(false); |
| } |
| }; |
|
|
| const handleDrop = (e: React.DragEvent) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| setDragActive(false); |
|
|
| if (e.dataTransfer.files && e.dataTransfer.files[0]) { |
| handleFileSelect(e.dataTransfer.files[0]); |
| } |
| }; |
|
|
| const handleRemoveFile = () => { |
| setSelectedFile(null); |
| setError(null); |
| onFileChange(null); |
| if (fileInputRef.current) { |
| fileInputRef.current.value = ""; |
| } |
| }; |
|
|
| const handleUrlChange = (url: string) => { |
| setFileUrl(url); |
| if (onUrlChange) { |
| onUrlChange(url); |
| } |
| }; |
|
|
| const getAcceptAttribute = (): string | undefined => { |
| if (!fileConstraints) return undefined; |
| |
| const types = []; |
| if (fileConstraints.acceptedTypes) { |
| types.push(...fileConstraints.acceptedTypes); |
| } |
| if (fileConstraints.acceptedExtensions) { |
| types.push(...fileConstraints.acceptedExtensions); |
| } |
| |
| return types.length > 0 ? types.join(",") : undefined; |
| }; |
|
|
| const renderContent = () => { |
| if (!acceptUrl) { |
| return ( |
| <div className="space-y-3"> |
| {renderUploadArea()} |
| {renderConstraints()} |
| </div> |
| ); |
| } |
|
|
| return ( |
| <Tabs value={mode} onValueChange={(v) => setMode(v as "upload" | "url")} className="w-full"> |
| <TabsList className="grid w-full grid-cols-2 bg-black/30"> |
| <TabsTrigger value="upload" className="data-[state=active]:bg-purple-500/20"> |
| <Upload className="w-4 h-4 mr-2" /> |
| Upload File |
| </TabsTrigger> |
| <TabsTrigger value="url" className="data-[state=active]:bg-purple-500/20"> |
| <LinkIcon className="w-4 h-4 mr-2" /> |
| Use URL |
| </TabsTrigger> |
| </TabsList> |
| |
| <TabsContent value="upload" className="mt-3 space-y-3"> |
| {renderUploadArea()} |
| {renderConstraints()} |
| </TabsContent> |
| |
| <TabsContent value="url" className="mt-3 space-y-3"> |
| <Input |
| type="url" |
| placeholder="https://example.com/file.jpg" |
| value={fileUrl} |
| onChange={(e) => handleUrlChange(e.target.value)} |
| disabled={disabled} |
| className="bg-black/50 border-white/10 text-white focus:border-purple-500" |
| /> |
| <p className="text-xs text-gray-500"> |
| Enter the URL of the file you want to process |
| </p> |
| {renderConstraints()} |
| </TabsContent> |
| </Tabs> |
| ); |
| }; |
|
|
| const renderUploadArea = () => ( |
| <> |
| <div |
| className={`relative border-2 border-dashed rounded-lg p-8 transition-all ${ |
| dragActive |
| ? "border-purple-500 bg-purple-500/10" |
| : "border-white/20 bg-black/30" |
| } ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:border-purple-500/50"}`} |
| onDragEnter={handleDrag} |
| onDragLeave={handleDrag} |
| onDragOver={handleDrag} |
| onDrop={handleDrop} |
| onClick={() => !disabled && fileInputRef.current?.click()} |
| > |
| <input |
| ref={fileInputRef} |
| type="file" |
| onChange={handleFileChange} |
| accept={getAcceptAttribute()} |
| disabled={disabled} |
| className="hidden" |
| /> |
| |
| {selectedFile ? ( |
| <div className="flex items-center justify-between"> |
| <div className="flex items-center gap-3"> |
| <div className="p-2 bg-purple-500/20 rounded"> |
| <File className="w-6 h-6 text-purple-400" /> |
| </div> |
| <div> |
| <p className="text-sm font-medium text-white">{selectedFile.name}</p> |
| <p className="text-xs text-gray-400">{formatFileSize(selectedFile.size)}</p> |
| </div> |
| </div> |
| <Button |
| type="button" |
| variant="ghost" |
| size="sm" |
| onClick={(e) => { |
| e.stopPropagation(); |
| handleRemoveFile(); |
| }} |
| disabled={disabled} |
| className="text-red-400 hover:text-red-300 hover:bg-red-500/10" |
| > |
| <X className="w-4 h-4" /> |
| </Button> |
| </div> |
| ) : ( |
| <div className="text-center"> |
| <Upload className="w-12 h-12 text-gray-400 mx-auto mb-3" /> |
| <p className="text-sm text-gray-300 mb-1"> |
| {dragActive ? "Drop file here" : "Click to upload or drag and drop"} |
| </p> |
| <p className="text-xs text-gray-500">{description}</p> |
| </div> |
| )} |
| </div> |
| |
| {error && ( |
| <div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> |
| <AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" /> |
| <p className="text-sm text-red-300">{error}</p> |
| </div> |
| )} |
| </> |
| ); |
|
|
| const renderConstraints = () => { |
| if (!fileConstraints) return null; |
|
|
| return ( |
| <div className="space-y-2"> |
| {fileConstraints.maxSize && ( |
| <p className="text-xs text-gray-500"> |
| • Max file size: {formatFileSize(fileConstraints.maxSize)} |
| </p> |
| )} |
| {fileConstraints.acceptedTypes && fileConstraints.acceptedTypes.length > 0 && ( |
| <p className="text-xs text-gray-500"> |
| • Accepted types: {fileConstraints.acceptedTypes.join(", ")} |
| </p> |
| )} |
| {fileConstraints.acceptedExtensions && fileConstraints.acceptedExtensions.length > 0 && ( |
| <p className="text-xs text-gray-500"> |
| • Accepted extensions: {fileConstraints.acceptedExtensions.join(", ")} |
| </p> |
| )} |
| </div> |
| ); |
| }; |
|
|
| return ( |
| <div className="space-y-2"> |
| <label className="block text-sm text-gray-300"> |
| {name} |
| <span className="text-xs text-gray-500 ml-2">(file)</span> |
| </label> |
| {renderContent()} |
| </div> |
| ); |
| } |