Files
SkinbaseNova/resources/js/hooks/upload/useFileValidation.js
Gregor Klevze 979e011257 Refactor dashboard and upload flows
Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
2026-03-21 11:02:22 +01:00

214 lines
6.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }