171 lines
5.7 KiB
TypeScript
171 lines
5.7 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useRef, useState, useEffect } from "react"
|
|
import { FileUp, X, FileText, FileImage, FileSpreadsheet, File } from "lucide-react"
|
|
import type { BaseFieldControlProps } from "../types"
|
|
import { cn } from "@/shared/lib/utils"
|
|
|
|
export type DocumentInputFieldProps = BaseFieldControlProps<File | null> & {
|
|
accept?: string
|
|
}
|
|
|
|
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"]
|
|
|
|
function getFileIcon(file: File) {
|
|
if (IMAGE_TYPES.includes(file.type)) return FileImage
|
|
if (file.type === "application/pdf") return FileText
|
|
if (
|
|
file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
|
file.type === "application/vnd.ms-excel" ||
|
|
file.type === "text/csv"
|
|
) return FileSpreadsheet
|
|
return File
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
export function DocumentInputField({
|
|
value,
|
|
onChange,
|
|
onBlur,
|
|
name,
|
|
disabled,
|
|
invalid,
|
|
accept = "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt",
|
|
}: DocumentInputFieldProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const [preview, setPreview] = useState<string | null>(null)
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
|
|
const isImage = value && IMAGE_TYPES.includes(value.type)
|
|
|
|
useEffect(() => {
|
|
if (!value || !isImage) {
|
|
setPreview(null)
|
|
return
|
|
}
|
|
const url = URL.createObjectURL(value)
|
|
setPreview(url)
|
|
return () => URL.revokeObjectURL(url)
|
|
}, [value, isImage])
|
|
|
|
const handleFile = useCallback(
|
|
(file: File | null) => {
|
|
onChange(file)
|
|
},
|
|
[onChange],
|
|
)
|
|
|
|
const handleDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragging(false)
|
|
if (disabled) return
|
|
const file = e.dataTransfer.files?.[0] ?? null
|
|
handleFile(file)
|
|
},
|
|
[disabled, handleFile],
|
|
)
|
|
|
|
const handleDragOver = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
if (!disabled) setIsDragging(true)
|
|
},
|
|
[disabled],
|
|
)
|
|
|
|
const handleDragLeave = useCallback(() => setIsDragging(false), [])
|
|
|
|
const handleClear = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
onChange(null)
|
|
if (inputRef.current) inputRef.current.value = ""
|
|
},
|
|
[onChange],
|
|
)
|
|
|
|
const FileIcon = value ? getFileIcon(value) : FileUp
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => !disabled && inputRef.current?.click()}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault()
|
|
inputRef.current?.click()
|
|
}
|
|
}}
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onBlur={onBlur}
|
|
aria-invalid={invalid || undefined}
|
|
className={cn(
|
|
"relative flex min-h-35 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-background p-4 text-sm transition-colors",
|
|
isDragging && "border-primary bg-primary/5",
|
|
invalid && "border-destructive",
|
|
disabled && "pointer-events-none opacity-50",
|
|
)}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept={accept}
|
|
name={name}
|
|
disabled={disabled}
|
|
className="hidden"
|
|
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
|
/>
|
|
|
|
{value ? (
|
|
<>
|
|
{isImage && preview ? (
|
|
<img
|
|
src={preview}
|
|
alt="Preview"
|
|
className="max-h-30 max-w-full rounded-md object-contain"
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-1.5">
|
|
<FileIcon className="h-8 w-8 text-muted-foreground" />
|
|
<span className="max-w-48 truncate font-medium text-foreground">
|
|
{value.name}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatFileSize(value.size)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{!disabled && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="absolute right-2 top-2 rounded-full bg-destructive p-1 text-destructive-foreground shadow-sm hover:bg-destructive/90"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<FileUp className="h-8 w-8 text-muted-foreground" />
|
|
<span className="text-muted-foreground">
|
|
Click or drag & drop a file
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
Images, PDF, Word, Excel, CSV, or text files
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|