import { useEffect, useRef, useState } from 'react' import { detectFileType, formatBytes, readImageDimensions, getExtension, IMAGE_MIME, IMAGE_EXTENSIONS, PRIMARY_IMAGE_MAX_BYTES, PRIMARY_ARCHIVE_MAX_BYTES, SCREENSHOT_MAX_BYTES, } from '../../lib/uploadUtils' // ─── Primary file validation ────────────────────────────────────────────────── async function validatePrimaryFile(file) { const errors = [] const warnings = [] const type = detectFileType(file) if (!file) { return { type: 'unknown', errors: [], warnings, metadata: { resolution: '—', size: '—', type: '—' }, previewUrl: '', } } const metadata = { resolution: '—', size: formatBytes(file.size), type: file.type || getExtension(file.name) || 'unknown', } if (type === 'unsupported') { errors.push('Unsupported file type. Use image (jpg/jpeg/png/webp) or archive (zip/rar/7z/tar/gz).') } if (type === 'image') { if (file.size > PRIMARY_IMAGE_MAX_BYTES) { errors.push('Image exceeds 50MB maximum size.') } try { const dimensions = await readImageDimensions(file) metadata.resolution = `${dimensions.width} × ${dimensions.height}` if (dimensions.width < 800 || dimensions.height < 600) { errors.push('Image resolution must be at least 800×600.') } } catch { errors.push('Unable to read image resolution.') } } if (type === 'archive') { metadata.resolution = 'n/a' if (file.size > PRIMARY_ARCHIVE_MAX_BYTES) { errors.push('Archive exceeds 200MB maximum size.') } warnings.push('Archive upload requires at least one valid screenshot.') } const previewUrl = type === 'image' ? URL.createObjectURL(file) : '' return { type, errors, warnings, metadata, previewUrl } } // ─── Screenshot validation ──────────────────────────────────────────────────── async function validateScreenshots(files, isArchive) { if (!isArchive) return { errors: [], perFileErrors: [] } const errors = [] const perFileErrors = Array.from({ length: files.length }, () => '') if (files.length < 1) errors.push('At least one screenshot is required for archives.') if (files.length > 5) errors.push('Maximum 5 screenshots are allowed.') await Promise.all( files.map(async (file, index) => { const typeErrors = [] const extension = getExtension(file.name) const mime = String(file.type || '').toLowerCase() if (!IMAGE_MIME.has(mime) && !IMAGE_EXTENSIONS.has(extension)) { typeErrors.push('Must be JPG, PNG, or WEBP.') } if (file.size > SCREENSHOT_MAX_BYTES) { typeErrors.push('Must be 10MB or less.') } if (typeErrors.length === 0) { try { const dimensions = await readImageDimensions(file) if (dimensions.width < 1280 || dimensions.height < 720) { typeErrors.push('Minimum resolution is 1280×720.') } } catch { typeErrors.push('Could not read screenshot resolution.') } } if (typeErrors.length > 0) { perFileErrors[index] = typeErrors.join(' ') } }) ) if (perFileErrors.some(Boolean)) { errors.push('One or more screenshots are invalid.') } return { errors, perFileErrors } } // ─── Hook ───────────────────────────────────────────────────────────────────── /** * useFileValidation * * Runs async validation on the primary file and screenshots, * maintains preview URL lifecycle (revokes on change/unmount), * and exposes derived state for the upload UI. * * @param {File|null} primaryFile * @param {File[]} screenshots * @param {boolean} isArchive - derived from primaryType === 'archive' */ export default function useFileValidation(primaryFile, screenshots, isArchive) { const [primaryType, setPrimaryType] = useState('unknown') const [primaryErrors, setPrimaryErrors] = useState([]) const [primaryWarnings, setPrimaryWarnings] = useState([]) const [fileMetadata, setFileMetadata] = useState({ resolution: '—', size: '—', type: '—' }) const [primaryPreviewUrl, setPrimaryPreviewUrl] = useState('') const [screenshotErrors, setScreenshotErrors] = useState([]) const [screenshotPerFileErrors, setScreenshotPerFileErrors] = useState([]) const primaryRunRef = useRef(0) const screenshotRunRef = useRef(0) const effectiveIsArchive = typeof isArchive === 'boolean' ? isArchive : detectFileType(primaryFile) === 'archive' // Primary file validation useEffect(() => { primaryRunRef.current += 1 const runId = primaryRunRef.current let cancelled = false ;(async () => { const result = await validatePrimaryFile(primaryFile) if (cancelled || runId !== primaryRunRef.current) return setPrimaryType(result.type) setPrimaryWarnings(result.warnings) setPrimaryErrors(result.errors) setFileMetadata(result.metadata) setPrimaryPreviewUrl((current) => { if (current) URL.revokeObjectURL(current) return result.previewUrl }) })() return () => { cancelled = true } }, [primaryFile]) // Screenshot validation useEffect(() => { screenshotRunRef.current += 1 const runId = screenshotRunRef.current let cancelled = false ;(async () => { const result = await validateScreenshots(screenshots, effectiveIsArchive) if (cancelled || runId !== screenshotRunRef.current) return setScreenshotErrors(result.errors) setScreenshotPerFileErrors(result.perFileErrors) })() return () => { cancelled = true } }, [screenshots, effectiveIsArchive]) // Clear screenshots when file changes to a non-archive useEffect(() => { if (!effectiveIsArchive) { setScreenshotErrors([]) setScreenshotPerFileErrors([]) } }, [effectiveIsArchive]) // Revoke preview URL on unmount useEffect(() => { return () => { if (primaryPreviewUrl) URL.revokeObjectURL(primaryPreviewUrl) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return { primaryType, primaryErrors, primaryWarnings, fileMetadata, primaryPreviewUrl, screenshotErrors, screenshotPerFileErrors, } } /** Standalone for use outside the hook if needed */ export { validatePrimaryFile, validateScreenshots }