"use client"; import { Button } from "@/components/ui/button"; import { Field, FieldContent, FieldError, FieldLabel, } from "@/components/ui/field"; import { type FileWithPreview, useFileUpload } from "@/hooks/use-file-upload"; import { api } from "@/lib/axios/http"; import { cn } from "@/lib/utils"; import { closestCenter, DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { arrayMove, rectSortingStrategy, SortableContext, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { AlertCircleIcon, GripVerticalIcon, ImageIcon, UploadIcon, XIcon, } from "lucide-react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { Controller, useFormContext } from "react-hook-form"; type SelectableMedia = { id: string; url: string; alt: string | null; sort: number; }; type MediaKind = "image" | "video" | "pdf"; type Props = { name: string; label: string; multiple?: boolean; className?: string; viewClass?: string; selectableMedia?: SelectableMedia[]; keyPrefix?: string; acceptTypes?: MediaKind[]; }; export type MediaUploaderFieldRef = { uploadPendingFiles: () => Promise<{ urls: string[]; uploadedKeys: string[] }>; revertUncommittedUploads: () => void; getCurrentUrls: () => string[]; }; type InitialFileMeta = { name: string; size: number; type: string; url: string; id: string; }; async function uploadToCloudinaryViaPresign( file: File, keyPrefix = "products", ): Promise<{ key: string; publicUrl: string; secureUrl: string }> { const resourceType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : file.type === "application/pdf" ? "raw" : "auto"; const res = await api.post<{ data: Record }>( "/v1/images/upload/presign", { folder: keyPrefix, resourceType, }, ); const payload = (res.data?.data as Record) || {}; const form = new FormData(); if (payload.unsigned && payload.upload_preset) { form.append("upload_preset", payload.upload_preset as string); if (payload.folder) form.append("folder", payload.folder as string); form.append("file", file); const { data: upJson } = await api.post>( payload.uploadUrl as string, form, ); return { key: upJson.public_id as string, publicUrl: upJson.secure_url as string, secureUrl: upJson.secure_url as string, }; } if (!payload.unsigned) { if (payload.api_key) form.append("api_key", payload.api_key as string); if (payload.timestamp) form.append("timestamp", String(payload.timestamp)); if (payload.signature) form.append("signature", payload.signature as string); if (payload.publicId) form.append("public_id", payload.publicId as string); if (payload.folder) form.append("folder", payload.folder as string); if (payload.resourceType) form.append("resource_type", payload.resourceType as string); form.append("file", file); const { data: upJson } = await api.post>( payload.uploadUrl as string, form, ); return { key: upJson.public_id as string, publicUrl: upJson.url as string, secureUrl: upJson.secure_url as string, }; } throw new Error("Invalid presign response"); } function SortableMediaItem({ file, onDelete, isBusy, }: { file: FileWithPreview; onDelete: () => void; isBusy: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: file.id, disabled: isBusy, }); const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, boxShadow: isDragging ? "0 10px 25px rgba(0,0,0,0.12)" : undefined, }; const src = file.preview || ""; const isVideo = file.file instanceof File && file.file.type.startsWith("video/"); const isPdf = file.file instanceof File && file.file.type === "application/pdf"; return (
{src ? ( isVideo ? (
); } const MediaUploaderField = forwardRef( function MediaUploaderField( { name, label, multiple = false, className, viewClass, selectableMedia = [], keyPrefix, acceptTypes = ["image"], }, ref, ) { const acceptString = useMemo(() => { const parts: string[] = []; if (acceptTypes.includes("image")) parts.push("image/*"); if (acceptTypes.includes("video")) parts.push("video/*"); if (acceptTypes.includes("pdf")) parts.push("application/pdf"); return parts.join(","); }, [acceptTypes]); const sortedSelectableMedia = useMemo(() => { return [...selectableMedia].sort((a, b) => a.sort - b.sort); }, [selectableMedia]); const { setValue, getValues } = useFormContext(); const getList = useCallback((): string[] => { const v = getValues(name); if (Array.isArray(v)) return (v as string[]).filter(Boolean); if (typeof v === "string" && v.trim()) return [v.trim()]; return []; }, [getValues, name]); const setList = useCallback( (urls: string[]) => { if (multiple) { setValue(name, urls, { shouldDirty: true, shouldValidate: true }); } else { setValue(name, urls[0] ?? "", { shouldDirty: true, shouldValidate: true, }); } }, [multiple, name, setValue], ); const initialFiles: InitialFileMeta[] = useMemo(() => { const urls = getList(); return urls.map((url, i) => ({ name: `media-${i + 1}`, size: 0, type: "image/jpeg", url, id: `init-${i}-${url}`, })); }, [getList]); const maxSizeMB = 50; const maxSize = maxSizeMB * 1024 * 1024; const maxFiles = multiple ? 20 : 1; const _fileUpload = useFileUpload({ accept: acceptString, maxSize, multiple, maxFiles, initialFiles, }); const files = _fileUpload[0].files; const isDragging = _fileUpload[0].isDragging; const errors = _fileUpload[0].errors; const handleDragEnter = _fileUpload[1].handleDragEnter; const handleDragLeave = _fileUpload[1].handleDragLeave; const handleDragOver = _fileUpload[1].handleDragOver; const handleDrop = _fileUpload[1].handleDrop; const openFileDialog = _fileUpload[1].openFileDialog; const removeFile = _fileUpload[1].removeFile; const getInputProps = _fileUpload[1].getInputProps; const clearErrors = _fileUpload[1].clearErrors; const idToUrl = useRef>(Object.fromEntries(initialFiles.map((f) => [f.id, f.url]))); const initialPreviewById = useRef>(Object.fromEntries(initialFiles.map((f) => [f.id, f.url]))); const uploadedKeyById = useRef>({}); const [busyIds, setBusyIds] = useState>({}); const [topError, setTopError] = useState(null); const [showSelectableMedia, setShowSelectableMedia] = useState(false); const [fileOrder, setFileOrder] = useState([]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), ); useEffect(() => { const currentFileIds = files.map((f) => f.id as string); setFileOrder((prevOrder) => { if (prevOrder.length === 0) { return currentFileIds; } const prevSet = new Set(prevOrder); const currentSet = new Set(currentFileIds); if ( prevSet.size !== currentSet.size || ![...prevSet].every((id) => currentSet.has(id)) ) { const existingIds = prevOrder.filter((id) => currentSet.has(id)); const newIds = currentFileIds.filter((id) => !prevSet.has(id)); return [...existingIds, ...newIds]; } return prevOrder; }); }, [files]); const orderedFiles = useMemo(() => { if (fileOrder.length === 0) return files; const fileMap = new Map(files.map((file) => [file.id as string, file])); return fileOrder .map((id) => fileMap.get(id)) .filter(Boolean) as FileWithPreview[]; }, [files, fileOrder]); useEffect(() => { const current = getList(); if (current.length === 0 && initialFiles.length > 0) { setList(initialFiles.map((f) => f.url)); } }, [getList, initialFiles, setList]); useEffect(() => { setTopError(null); clearErrors?.(); for (const f of files) { const id = f.id as string; if (idToUrl.current[id]) continue; const fallbackValue = f.preview || (f.file instanceof File ? "" : ((f.file as InitialFileMeta).url ?? "")); if (!fallbackValue) continue; idToUrl.current[id] = fallbackValue; initialPreviewById.current[id] = fallbackValue; const currentUrls = getList(); const fileIndex = files.findIndex((file) => file.id === id); if (fileIndex !== -1) { const newUrls = [...currentUrls]; newUrls[fileIndex] = fallbackValue; setList(newUrls.filter(Boolean)); } } }, [files, clearErrors, getList, setList]); useImperativeHandle( ref, () => ({ uploadPendingFiles: async () => { const filesToProcess = multiple ? orderedFiles : files; const uploadedKeys: string[] = []; const resolvedUrls: string[] = []; setTopError(null); for (const fileEntry of filesToProcess) { const id = fileEntry.id as string; if (fileEntry.file instanceof File) { const hasRemoteUrl = !!idToUrl.current[id] && /^(https?:\/\/)/.test(idToUrl.current[id]); if (!hasRemoteUrl) { try { setBusyIds((p) => ({ ...p, [id]: true })); const { key, secureUrl } = await uploadToCloudinaryViaPresign( fileEntry.file, keyPrefix ?? "uploads", ); idToUrl.current[id] = secureUrl; uploadedKeyById.current[id] = key; } catch (e: unknown) { const errorMessage = typeof e === "object" && e !== null && "message" in e ? (e as { message?: string }).message : undefined; setTopError(errorMessage ?? "Failed to upload media."); throw e; } finally { setBusyIds((p) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [id]: _omit, ...rest } = p; return rest; }); } } if (uploadedKeyById.current[id]) { uploadedKeys.push(uploadedKeyById.current[id]); } } const resolvedUrl = idToUrl.current[id] || (fileEntry.file instanceof File ? fileEntry.preview || "" : ((fileEntry.file as InitialFileMeta).url ?? "")); if (resolvedUrl) { resolvedUrls.push(resolvedUrl); } } setList(resolvedUrls); return { urls: resolvedUrls, uploadedKeys: Array.from(new Set(uploadedKeys)), }; }, revertUncommittedUploads: () => { const filesToProcess = multiple ? orderedFiles : files; const revertedUrls = filesToProcess .map((fileEntry) => { const id = fileEntry.id as string; if (fileEntry.file instanceof File) { const fallback = initialPreviewById.current[id] || fileEntry.preview || ""; if (fallback) { idToUrl.current[id] = fallback; } delete uploadedKeyById.current[id]; return fallback; } return idToUrl.current[id] || (fileEntry.file as InitialFileMeta).url || ""; }) .filter(Boolean); setList(revertedUrls); }, getCurrentUrls: () => getList(), }), [files, getList, keyPrefix, multiple, orderedFiles, setList], ); const handleRemove = useCallback( (id: string) => { const url = idToUrl.current[id]; if (url) { setList(getList().filter((u) => u !== url)); delete idToUrl.current[id]; delete initialPreviewById.current[id]; delete uploadedKeyById.current[id]; } removeFile(id); }, [getList, removeFile, setList], ); const handleSelectMedia = useCallback( (media: SelectableMedia) => { const currentUrls = getList(); if (multiple) { if (currentUrls.includes(media.url)) { const newUrls = currentUrls.filter((url) => url !== media.url); setList(newUrls); Object.keys(idToUrl.current).forEach((id) => { if (idToUrl.current[id] === media.url) { delete idToUrl.current[id]; } }); } else { const newUrls = [...currentUrls, media.url]; setList(newUrls); const selectedId = `selected-${media.id}-${Date.now()}`; idToUrl.current[selectedId] = media.url; } } else { setList([media.url]); setShowSelectableMedia(false); idToUrl.current = {}; const selectedId = `selected-${media.id}-${Date.now()}`; idToUrl.current[selectedId] = media.url; } }, [getList, setList, multiple], ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; const activeIndex = orderedFiles.findIndex((f) => f.id === active.id); const overIndex = orderedFiles.findIndex((f) => f.id === over?.id); const currentUrls = getList(); if (currentUrls.length !== files.length) { return; } const idToUrlMap: Record = {}; files.forEach((file, index) => { const fileId = file.id as string; const url = currentUrls[index]; if (url) { idToUrlMap[fileId] = url; idToUrl.current[fileId] = url; } }); const newFileOrder = arrayMove([...fileOrder], activeIndex, overIndex); setFileOrder(newFileOrder); const reorderedUrls = newFileOrder.map((fileId) => idToUrlMap[fileId]).filter(Boolean); setList(reorderedUrls); }, [orderedFiles, getList, setList, fileOrder, files], ); return ( ( {label}
0 || undefined} className={cn( "border-input data-[dragging=true]:bg-accent/50 has-[input:focus]:border-ring has-[input:focus]:ring-ring/50 relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed p-4 transition-colors not-data-files:justify-center has-[input:focus]:ring-[3px]", )} > {files.length > 0 ? (

Uploaded Files ({files.length})

{sortedSelectableMedia.length > 0 && ( )}
{showSelectableMedia && sortedSelectableMedia.length > 0 && (

Select from available media:

{sortedSelectableMedia.map((m) => { const isSelected = getList().includes(m.url); return ( ); })}
)} {multiple ? ( f.id as string)} strategy={rectSortingStrategy}>
{orderedFiles.map((file) => { const id = file.id as string; const isBusy = !!busyIds[id]; return handleRemove(id)} isBusy={isBusy} />; })}
) : (
{files.map((file) => { const id = file.id as string; const isBusy = !!busyIds[id]; const src = file.preview || ""; return (
{src ? ( file.file instanceof File && file.file.type.startsWith("video/") ? (
); })}
)}
) : (

Drop your media here

Accept: {acceptTypes.join(", ")}

{sortedSelectableMedia.length > 0 && ( )}
)}
{files.length === 0 && showSelectableMedia && sortedSelectableMedia.length > 0 && (

Select from available media:

{sortedSelectableMedia.map((m) => { const isSelected = getList().includes(m.url); return ( ); })}
)} {(errors.length > 0 || topError) && (
{topError ?? errors[0]}
)}
{fieldState.invalid && }
)} /> ); }, ); export default MediaUploaderField;