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)
211 lines
6.4 KiB
JavaScript
211 lines
6.4 KiB
JavaScript
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 }
|