Save workspace changes
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user