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)
This commit is contained in:
210
resources/js/hooks/upload/useFileValidation.js
Normal file
210
resources/js/hooks/upload/useFileValidation.js
Normal file
@@ -0,0 +1,210 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user