Files
SkinbaseNova/resources/js/hooks/upload/useFileValidation.js
Gregor Klevze 1266f81d35 feat: upload wizard refactor + vision AI tags + artwork versioning
Upload wizard:
- Refactored UploadWizard into modular steps (Step1FileUpload, Step2Details, Step3Publish)
- Extracted reusable hooks: useUploadMachine, useFileValidation, useVisionTags
- Extracted reusable components: CategorySelector, ContentTypeSelector
- Added TagPicker component (studio-style list picker with AI badge + new-tag insertion)
- Fixed TagInput auto-open bug (hasFocusedRef guard)
- Replaced TagInput with TagPicker in UploadSidebar

Vision AI tag suggestions:
- Add UploadVisionSuggestController: sync POST /api/uploads/{id}/vision-suggest
- Calls vision.klevze.net/analyze/all on upload completion (before step 2 opens)
- Two-phase useVisionTags: immediate gateway call + background DB polling
- Trigger fires on uploadReady (not step change) so tags arrive before user sees step 2
- Added vision.gateway config block with VISION_GATEWAY_URL env

Artwork versioning system:
- ArtworkVersion / ArtworkVersionEvent models
- ArtworkVersioningService: createNewVersion, restoreVersion, rate limiting, ranking decay
- Migrations: artwork_versions, artwork_version_events, versioning columns on artworks
- Studio API routes: GET versions, POST restore/{version_id}
- Feature tests: ArtworkVersioningTest (13 cases)
2026-03-01 14:56:46 +01:00

211 lines
6.4 KiB
JavaScript
Raw 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)
// 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, isArchive)
if (cancelled || runId !== screenshotRunRef.current) return
setScreenshotErrors(result.errors)
setScreenshotPerFileErrors(result.perFileErrors)
})()
return () => {
cancelled = true
}
}, [screenshots, isArchive])
// Clear screenshots when file changes to a non-archive
useEffect(() => {
if (!isArchive) {
setScreenshotErrors([])
setScreenshotPerFileErrors([])
}
}, [isArchive])
// 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 }