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:
130
resources/js/lib/uploadUtils.js
Normal file
130
resources/js/lib/uploadUtils.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Shared utilities for the upload system.
|
||||
* These are pure functions – no React, no side effects.
|
||||
*/
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp'])
|
||||
export const IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
export const ARCHIVE_EXTENSIONS = new Set(['zip', 'rar', '7z', 'tar', 'gz'])
|
||||
export const ARCHIVE_MIME = new Set([
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-rar-compressed',
|
||||
'application/vnd.rar',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip',
|
||||
'application/x-gzip',
|
||||
'application/octet-stream',
|
||||
])
|
||||
|
||||
export const PRIMARY_IMAGE_MAX_BYTES = 50 * 1024 * 1024 // 50 MB
|
||||
export const PRIMARY_ARCHIVE_MAX_BYTES = 200 * 1024 * 1024 // 200 MB
|
||||
export const SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
// ─── Type detection ───────────────────────────────────────────────────────────
|
||||
export function getExtension(fileName = '') {
|
||||
const parts = String(fileName).toLowerCase().split('.')
|
||||
return parts.length > 1 ? parts.pop() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns {'image' | 'archive' | 'unsupported'}
|
||||
*/
|
||||
export function detectFileType(file) {
|
||||
if (!file) return 'unknown'
|
||||
const extension = getExtension(file.name)
|
||||
const mime = String(file.type || '').toLowerCase()
|
||||
if (IMAGE_MIME.has(mime) || IMAGE_EXTENSIONS.has(extension)) return 'image'
|
||||
if (ARCHIVE_MIME.has(mime) || ARCHIVE_EXTENSIONS.has(extension)) return 'archive'
|
||||
return 'unsupported'
|
||||
}
|
||||
|
||||
// ─── Formatting ───────────────────────────────────────────────────────────────
|
||||
export function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||
const mb = kb / 1024
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`
|
||||
return `${(mb / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
// ─── Image utils ──────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns {Promise<{width: number, height: number}>}
|
||||
*/
|
||||
export function readImageDimensions(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
img.onerror = () => {
|
||||
reject(new Error('image_read_failed'))
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
img.src = blobUrl
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Category tree ────────────────────────────────────────────────────────────
|
||||
export function buildCategoryTree(contentTypes = []) {
|
||||
const rootsById = new Map()
|
||||
contentTypes.forEach((type) => {
|
||||
const categories = Array.isArray(type?.categories) ? type.categories : []
|
||||
categories.forEach((category) => {
|
||||
if (!category?.id) return
|
||||
const key = String(category.id)
|
||||
if (!rootsById.has(key)) {
|
||||
rootsById.set(key, {
|
||||
id: key,
|
||||
name: category.name || `Category ${category.id}`,
|
||||
children: [],
|
||||
})
|
||||
}
|
||||
const root = rootsById.get(key)
|
||||
const children = Array.isArray(category?.children) ? category.children : []
|
||||
children.forEach((child) => {
|
||||
if (!child?.id) return
|
||||
const exists = root.children.some((item) => String(item.id) === String(child.id))
|
||||
if (!exists) {
|
||||
root.children.push({ id: String(child.id), name: child.name || `Subcategory ${child.id}` })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
return Array.from(rootsById.values())
|
||||
}
|
||||
|
||||
// ─── Content type helpers ─────────────────────────────────────────────────────
|
||||
export function getContentTypeValue(type) {
|
||||
if (!type) return ''
|
||||
return String(type.id ?? type.key ?? type.slug ?? type.name ?? '')
|
||||
}
|
||||
|
||||
export function getContentTypeVisualKey(type) {
|
||||
const raw = String(type?.slug || type?.name || type?.key || '').toLowerCase()
|
||||
const normalized = raw.replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
||||
if (normalized.includes('wallpaper')) return 'wallpapers'
|
||||
if (normalized.includes('skin')) return 'skins'
|
||||
if (normalized.includes('photo')) return 'photography'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// ─── Processing status helpers ────────────────────────────────────────────────
|
||||
export function getProcessingTransparencyLabel(processingStatus, machineState) {
|
||||
if (!['processing', 'finishing', 'publishing'].includes(machineState)) return ''
|
||||
const normalized = String(processingStatus || '').toLowerCase()
|
||||
if (normalized === 'generating_preview') return 'Generating preview'
|
||||
if (['processed', 'ready', 'published', 'queued', 'publish_ready'].includes(normalized)) {
|
||||
return 'Preparing for publish'
|
||||
}
|
||||
return 'Analyzing content'
|
||||
}
|
||||
Reference in New Issue
Block a user