updates
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState, useEffect } from "react"
|
||||
import { ImagePlus, X } from "lucide-react"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
export type ImageInputFieldProps = BaseFieldControlProps<File | null> & {
|
||||
accept?: string
|
||||
}
|
||||
|
||||
export function ImageInputField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
accept = "image/*",
|
||||
}: ImageInputFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setPreview(null)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(value)
|
||||
setPreview(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [value])
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File | null) => {
|
||||
if (file && !file.type.startsWith("image/")) return
|
||||
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],
|
||||
)
|
||||
|
||||
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)}
|
||||
/>
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="max-h-30 max-w-full rounded-md object-contain"
|
||||
/>
|
||||
{!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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ImagePlus className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Click or drag & drop an image
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { DocumentInputField, type DocumentInputFieldProps } from "../controls/document-input-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfDocumentFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<DocumentInputFieldProps, keyof BaseFieldControlProps<File | null>>
|
||||
|
||||
export function RhfDocumentField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfDocumentFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={DocumentInputField} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { ImageInputField, type ImageInputFieldProps } from "../controls/image-input-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfImageFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<ImageInputFieldProps, keyof BaseFieldControlProps<File | null>>
|
||||
|
||||
export function RhfImageField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfImageFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={ImageInputField} />
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export {
|
||||
AsyncMultiSelectField, type AsyncMultiSelectFieldProps,
|
||||
} from "./controls/async-select-field"
|
||||
export { FileInputField, type FileInputFieldProps } from "./controls/file-input-field"
|
||||
export { ImageInputField, type ImageInputFieldProps } from "./controls/image-input-field"
|
||||
export { DocumentInputField, type DocumentInputFieldProps } from "./controls/document-input-field"
|
||||
|
||||
// ── RHF Field Wrappers ──
|
||||
export { RhfTextField } from "./fields/rhf-text-field"
|
||||
@@ -29,5 +31,7 @@ export { RhfTextareaField } from "./fields/rhf-textarea-field"
|
||||
export { RhfCheckboxField } from "./fields/rhf-checkbox-field"
|
||||
export { RhfSelectField } from "./fields/rhf-select-field"
|
||||
export { RhfFileField } from "./fields/rhf-file-field"
|
||||
export { RhfImageField } from "./fields/rhf-image-field"
|
||||
export { RhfDocumentField } from "./fields/rhf-document-field"
|
||||
export { RhfAsyncSelectField, RhfAsyncMultiSelectField, type InlineCreateFormProps, type InlineCreateConfig } from "./fields/rhf-async-select-field"
|
||||
export { SimpleTitleForm, type SimpleTitleFormProps } from "./fields/simple-title-form"
|
||||
|
||||
Reference in New Issue
Block a user