'use client' import * as React from 'react' import type { UploadFileResult, UploadProgress } from '@admin/types' import type { FileValidationConfig } from '@admin/utils/validation/file-validation-config' import type { UseMutationResult } from '@tanstack/react-query' import { uploadMediaFromUrl } from '@admin/actions/upload' import { parseAcceptTypes } from '@admin/utils/upload/parse-accept-types' import { validateFiles } from '@admin/utils/validation/validate-files' import { useMutation } from '@tanstack/react-query' export interface UseUploadOptions { validationConfig?: FileValidationConfig accept?: string maxSizeInMB?: number onProgress?: (progress: UploadProgress[]) => void onSuccess?: (result: UploadFileResult) => void onError?: (error: Error) => void prefix?: string isActive?: boolean } export interface UploadMutationVariables { files: File[] prefix?: string activityId?: number } export interface UploadUrlMutationVariables { url: string prefix?: string accept?: string activityId?: number } export interface UseUploadReturn { mutation: UseMutationResult urlMutation: UseMutationResult progress: UploadProgress[] isUploading: boolean upload: (files: File[], prefix?: string) => void uploadFromUrl: ( url: string, prefix?: string, acceptOverride?: string ) => Promise validate: (files: File[]) => { valid: boolean; errors: string[] } } export function useUpload(options: UseUploadOptions = {}): UseUploadReturn { const { validationConfig: providedConfig, accept, maxSizeInMB, onProgress, onSuccess, onError, prefix: defaultPrefix, isActive = true } = options const validationConfig = React.useMemo(() => { const config: FileValidationConfig = { ...providedConfig } if (accept) { const parsedTypes = parseAcceptTypes(accept) if (config.allowedTypes && config.allowedTypes.length > 0) { config.allowedTypes = [...config.allowedTypes, ...parsedTypes].filter( (v, i, a) => a.indexOf(v) === i ) } else { config.allowedTypes = parsedTypes } } if (maxSizeInMB !== undefined) { config.maxSizeInBytes = maxSizeInMB * 1024 * 1024 } return config }, [providedConfig, accept, maxSizeInMB]) const [progress, setProgress] = React.useState([]) const [_isPending, startTransition] = React.useTransition() const mountedRef = React.useRef(true) const activeRef = React.useRef(isActive) const activeActivityIdRef = React.useRef(0) const previousIsActiveRef = React.useRef(isActive) const uploadAbortControllerRef = React.useRef(null) const progressIntervalRef = React.useRef | null>(null) const progressResetTimeoutRef = React.useRef | null>(null) if (previousIsActiveRef.current !== isActive) { previousIsActiveRef.current = isActive activeActivityIdRef.current += 1 } activeRef.current = isActive const clearProgressResetTimeout = React.useCallback(() => { if (progressResetTimeoutRef.current) { clearTimeout(progressResetTimeoutRef.current) progressResetTimeoutRef.current = null } }, []) const clearProgressInterval = React.useCallback(() => { if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current) progressIntervalRef.current = null } }, []) const abortActiveUpload = React.useCallback(() => { if (uploadAbortControllerRef.current) { uploadAbortControllerRef.current.abort() uploadAbortControllerRef.current = null } }, []) const cleanupActiveUpload = React.useCallback(() => { clearProgressResetTimeout() clearProgressInterval() abortActiveUpload() }, [abortActiveUpload, clearProgressInterval, clearProgressResetTimeout]) const isCurrentActiveMutation = React.useCallback((activityId?: number) => { if (!mountedRef.current || !activeRef.current) return false return activityId === undefined || activityId === activeActivityIdRef.current }, []) React.useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false cleanupActiveUpload() } }, [cleanupActiveUpload]) const mutation = useMutation({ mutationFn: async ({ files, prefix = defaultPrefix }) => { cleanupActiveUpload() const abortController = new AbortController() uploadAbortControllerRef.current = abortController const initialProgress: UploadProgress[] = files.map((file) => ({ filename: file.name, progress: 0, loaded: 0, total: file.size })) setProgress(initialProgress) onProgress?.(initialProgress) const formData = new FormData() if (prefix) { formData.append('prefix', prefix) } files.forEach((file, index) => { formData.append(`file${index}`, file) }) // Simulate progress since server doesn't support streaming const progressInterval = setInterval(() => { if (!mountedRef.current) return setProgress((prev) => prev.map((p) => { const newProgress = Math.min(p.progress + 10, 90) return { ...p, progress: newProgress, loaded: Math.floor((p.total * newProgress) / 100) } }) ) }, 200) progressIntervalRef.current = progressInterval try { if (validationConfig?.maxSizeInBytes) { formData.append('maxSizeInBytes', validationConfig.maxSizeInBytes.toString()) } if (validationConfig?.maxFiles) { formData.append('maxFiles', validationConfig.maxFiles.toString()) } if (validationConfig?.allowedTypes?.length) { formData.append('allowedTypes', validationConfig.allowedTypes.join(',')) } const response = await fetch('/api/admin/upload', { method: 'POST', body: formData, signal: abortController.signal }) const result = (await response.json()) as UploadFileResult if (result.success && mountedRef.current) { const completeProgress: UploadProgress[] = files.map((file) => ({ filename: file.name, progress: 100, loaded: file.size, total: file.size })) setProgress(completeProgress) onProgress?.(completeProgress) } return result } finally { clearInterval(progressInterval) if (progressIntervalRef.current === progressInterval) { progressIntervalRef.current = null } if (uploadAbortControllerRef.current === abortController) { uploadAbortControllerRef.current = null } } }, onSuccess: (result, variables) => { if (!mountedRef.current) return if (!isCurrentActiveMutation(variables.activityId)) { clearProgressResetTimeout() startTransition(() => { setProgress([]) }) return } onSuccess?.(result) clearProgressResetTimeout() progressResetTimeoutRef.current = setTimeout(() => { progressResetTimeoutRef.current = null startTransition(() => { setProgress([]) }) }, 2000) }, onError: (error, variables) => { if (!mountedRef.current || error.name === 'AbortError') return clearProgressResetTimeout() setProgress([]) if (!isCurrentActiveMutation(variables.activityId)) return onError?.(error) } }) const urlMutation = useMutation({ mutationFn: async ({ url, prefix = defaultPrefix, accept: explicitAccept = accept }) => uploadMediaFromUrl(url, { prefix, accept: explicitAccept, maxSizeInBytes: validationConfig.maxSizeInBytes }), onSuccess: (result, variables) => { if (!isCurrentActiveMutation(variables.activityId)) return onSuccess?.(result) }, onError: (error, variables) => { if (!isCurrentActiveMutation(variables.activityId)) return onError?.(error) } }) const upload = React.useCallback( (files: File[], prefix?: string) => { mutation.mutate({ files, prefix, activityId: activeActivityIdRef.current }) }, [mutation] ) const uploadFromUrl = React.useCallback( (url: string, prefix?: string, acceptOverride?: string) => urlMutation.mutateAsync({ url, prefix, accept: acceptOverride, activityId: activeActivityIdRef.current }), [urlMutation] ) const validate = React.useCallback( (files: File[]) => { const result = validateFiles(files, validationConfig) return { valid: result.valid, errors: result.errors.map((e) => (e.filename ? `${e.filename}: ${e.error}` : e.error)) } }, [validationConfig] ) const isUploading = progress.some((p) => p.progress < 100) return { mutation, urlMutation, progress, isUploading, upload, uploadFromUrl, validate } }