181 lines
6.8 KiB
TypeScript
181 lines
6.8 KiB
TypeScript
import { ImagePlus, RotateCcw, XCircle } from "lucide-react";
|
|
import React, { useCallback } from "react";
|
|
import type { ControllerRenderProps, FieldError } from "react-hook-form";
|
|
import { Button } from "./button";
|
|
|
|
// Props for our custom field component
|
|
interface IconUploadFieldProps {
|
|
field: ControllerRenderProps<any, string>; // field.value can be File | string (URL) | null | undefined
|
|
error?: FieldError;
|
|
accept?: string;
|
|
previewContainerClassName?: string;
|
|
defaultPreview?: string; // Visual fallback URL if field.value is initially undefined
|
|
formDefaultValue?: string | null | undefined; // The actual RHF default value for this field
|
|
}
|
|
|
|
export function IconUploadField({
|
|
field,
|
|
error,
|
|
accept = "image/*",
|
|
previewContainerClassName = "w-24 h-24 rounded-full",
|
|
defaultPreview,
|
|
formDefaultValue, // New prop
|
|
}: IconUploadFieldProps) {
|
|
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
let objectUrlToRevoke: string | null = null;
|
|
|
|
if (field.value instanceof File) {
|
|
objectUrlToRevoke = URL.createObjectURL(field.value);
|
|
setPreviewUrl(objectUrlToRevoke);
|
|
} else if (typeof field.value === "string" && field.value) {
|
|
setPreviewUrl(field.value);
|
|
} else if (field.value === null) {
|
|
setPreviewUrl(null);
|
|
} else if (field.value === undefined && defaultPreview) {
|
|
// Show visual default prop if field value is undefined
|
|
setPreviewUrl(defaultPreview);
|
|
} else {
|
|
setPreviewUrl(null);
|
|
}
|
|
|
|
return () => {
|
|
if (objectUrlToRevoke) {
|
|
URL.revokeObjectURL(objectUrlToRevoke);
|
|
}
|
|
};
|
|
}, [field.value, defaultPreview]);
|
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
field.onChange(file || undefined);
|
|
if (event.target) {
|
|
event.target.value = "";
|
|
}
|
|
};
|
|
|
|
const handleRemoveImage = useCallback(
|
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault();
|
|
field.onChange(null); // Set RHF value to null
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ""; // Clear the native file input
|
|
}
|
|
},
|
|
[field],
|
|
);
|
|
|
|
const handleResetToDefault = useCallback(
|
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault();
|
|
// The button's visibility logic ensures formDefaultValue is relevant.
|
|
// This will set RHF value to string (URL), null, or undefined, as per formDefaultValue.
|
|
field.onChange(undefined);
|
|
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ""; // Clear the native file input
|
|
}
|
|
},
|
|
[field, formDefaultValue],
|
|
);
|
|
|
|
const triggerFileInput = useCallback(() => {
|
|
fileInputRef.current?.click();
|
|
}, []);
|
|
|
|
// Visibility for the "Remove" button: show if there's a File or a URL string in field.value
|
|
const showRemoveButton =
|
|
(!!field.value &&
|
|
(field.value instanceof File || typeof field.value === "string")) ||
|
|
(formDefaultValue && field.value === undefined);
|
|
|
|
// Visibility for the "Reset to Default" button
|
|
const canReset = typeof formDefaultValue !== "undefined"; // Was a formDefaultValue prop provided?
|
|
// Is current value different from the formDefaultValue?
|
|
// (A File object is always different from a URL string/null/undefined default)
|
|
const isDifferentFromDefault =
|
|
(field.value instanceof File ||
|
|
(field.value !== formDefaultValue && field.value !== undefined)) &&
|
|
formDefaultValue;
|
|
const showResetButton = canReset && isDifferentFromDefault;
|
|
|
|
return (
|
|
<div className="flex items-center space-x-4">
|
|
<div
|
|
ref={field.ref}
|
|
className={`relative ${previewContainerClassName} border-2 ${
|
|
error
|
|
? "border-destructive"
|
|
: "border-dashed border-muted-foreground"
|
|
} flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden`}
|
|
onClick={triggerFileInput}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
triggerFileInput();
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={previewUrl ? "Change icon" : "Upload icon"}
|
|
aria-invalid={!!error}
|
|
aria-describedby={error ? `${field.name}-error` : undefined}
|
|
>
|
|
{previewUrl ? (
|
|
<img
|
|
src={previewUrl}
|
|
alt="Icon preview"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<ImagePlus
|
|
className={`w-10 h-10 ${error ? "text-destructive" : "text-muted-foreground"}`}
|
|
/>
|
|
)}
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
className="hidden"
|
|
accept={accept}
|
|
onChange={handleFileChange}
|
|
onBlur={field.onBlur}
|
|
name={field.name}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col space-y-2">
|
|
{/* Optional: A separate button to trigger file input, if needed. */}
|
|
{/* <Button type="button" variant="outline" size="sm" onClick={triggerFileInput}>
|
|
{previewUrl ? "Change Icon" : "Upload Icon"}
|
|
</Button> */}
|
|
|
|
{showRemoveButton && (
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={handleRemoveImage}
|
|
>
|
|
<XCircle className="mr-2 h-4 w-4" />
|
|
Remove
|
|
</Button>
|
|
)}
|
|
|
|
{showResetButton && (
|
|
<Button
|
|
type="button"
|
|
variant="outline" // Choose a suitable variant
|
|
size="sm"
|
|
onClick={handleResetToDefault}
|
|
>
|
|
<RotateCcw className="mr-2 h-4 w-4" />
|
|
Reset to Default
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|