new-personal-site/components/lib/form/ImageUpload.tsx
Benjamin Toby a0a0ab8ee4 Updates
2025-07-20 10:35:54 +01:00

223 lines
8.2 KiB
TypeScript

import Button from "../layout/Button";
import Stack from "../layout/Stack";
import { ImagePlus, X } from "lucide-react";
import React, { DetailedHTMLProps } from "react";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Center from "../layout/Center";
import imageInputToBase64, {
ImageInputToBase64FunctionReturn,
} from "../utils/form/imageInputToBase64";
import { twMerge } from "tailwind-merge";
import Tag from "../elements/Tag";
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChangeHandler?: (
imgData: ImageInputToBase64FunctionReturn | undefined
) => any;
fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
placeHolderWrapper?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageWrapperProps?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageProps?: DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>;
label?: string;
disablePreview?: boolean;
multiple?: boolean;
existingImageUrl?: string;
externalSetImage?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn | undefined>
>;
externalSetImages?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
>;
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
externalImage?: ImageInputToBase64FunctionReturn;
restoreImageFn?: () => void;
};
/**
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
*/
export default function ImageUpload({
onChangeHandler,
fileInputProps,
placeHolderWrapper,
previewImageWrapperProps,
previewImageProps,
label,
disablePreview,
existingImageUrl,
externalSetImage,
externalSetImages,
externalImage,
multiple,
restoreImageFn,
setLoading,
...props
}: ImageUploadProps) {
const [imageObject, setImageObject] = React.useState<
ImageInputToBase64FunctionReturn | undefined
>(externalImage);
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (existingImageUrl) setSrc(existingImageUrl);
}, [existingImageUrl]);
return (
<Stack
{...props}
className={twMerge(
"w-full h-[300px] overflow-hidden",
props?.className
)}
>
<input
type="file"
className={twMerge("hidden", fileInputProps?.className)}
multiple={multiple}
accept="image/*"
{...fileInputProps}
onChange={(e) => {
setLoading?.(true);
if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
let imgArr: ImageInputToBase64FunctionReturn[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileObj = await imageInputToBase64({
file,
});
imgArr.push(fileObj);
}
externalSetImages?.(imgArr);
setLoading?.(false);
})();
} else {
imageInputToBase64({ imageInput: e.target }).then(
(res) => {
setSrc(res.imageBase64Full);
onChangeHandler?.(res);
setImageObject?.(res);
externalSetImage?.(res);
fileInputProps?.onChange?.(e);
setLoading?.(false);
}
);
}
}}
ref={inputRef as any}
/>
{src || imageObject?.imageBase64Full ? (
<Card
className="w-full relative h-full items-center justify-center"
{...previewImageWrapperProps}
>
{label && (
<label
className={twMerge(
"absolute top-0 left-0 text-xs z-50"
)}
>
<Tag color="gray">
<span className="opacity-70">{label}</span>
</Tag>
</label>
)}
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : (
<img
src={imageObject?.imageBase64Full || src}
className="w-full h-full object-contain"
{...previewImageProps}
/>
)}
<Button
variant="ghost"
className={twMerge(
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark"
)}
onClick={(e) => {
setSrc(undefined);
onChangeHandler?.(undefined);
setImageObject?.(undefined);
externalSetImage?.(undefined);
if (inputRef.current) {
inputRef.current.value == "";
}
}}
title="Cancel Image Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
</Card>
) : (
<Card
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className
)}
onClick={(e) => {
const targetEl = e.target as HTMLElement | undefined;
if (targetEl?.closest(".cancel-upload")) {
e.preventDefault();
return;
}
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
{...placeHolderWrapper}
>
<Center>
<Stack className="items-center gap-2">
<ImagePlus className="text-slate-400" />
<Span size="smaller" variant="faded">
{label || "Click to Upload Image"}
</Span>
{existingImageUrl && (
<Button
title="Restore Image Button"
size="smaller"
variant="ghost"
onClick={() => {
restoreImageFn?.() ||
setSrc(existingImageUrl);
}}
className="cancel-upload"
>
Restore Original Image
</Button>
)}
</Stack>
</Center>
</Card>
)}
</Stack>
);
}