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 }
|
||||
433
resources/js/hooks/upload/useUploadMachine.js
Normal file
433
resources/js/hooks/upload/useUploadMachine.js
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useCallback, useReducer, useRef } from 'react'
|
||||
import { emitUploadEvent } from '../../lib/uploadAnalytics'
|
||||
import * as uploadEndpoints from '../../lib/uploadEndpoints'
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
const DEFAULT_CHUNK_SIZE_BYTES = 5 * 1024 * 1024
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
// ─── State machine ───────────────────────────────────────────────────────────
|
||||
export const machineStates = {
|
||||
idle: 'idle',
|
||||
initializing: 'initializing',
|
||||
uploading: 'uploading',
|
||||
finishing: 'finishing',
|
||||
processing: 'processing',
|
||||
ready_to_publish: 'ready_to_publish',
|
||||
publishing: 'publishing',
|
||||
complete: 'complete',
|
||||
error: 'error',
|
||||
cancelled: 'cancelled',
|
||||
}
|
||||
|
||||
const initialMachineState = {
|
||||
state: machineStates.idle,
|
||||
progress: 0,
|
||||
sessionId: null,
|
||||
uploadToken: null,
|
||||
processingStatus: null,
|
||||
isCancelling: false,
|
||||
error: '',
|
||||
lastAction: null,
|
||||
}
|
||||
|
||||
function machineReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT_START':
|
||||
return { ...state, state: machineStates.initializing, progress: 0, error: '', isCancelling: false, lastAction: 'start' }
|
||||
case 'INIT_SUCCESS':
|
||||
return { ...state, sessionId: action.sessionId, uploadToken: action.uploadToken, error: '' }
|
||||
case 'UPLOAD_START':
|
||||
return { ...state, state: machineStates.uploading, progress: 1, error: '' }
|
||||
case 'UPLOAD_PROGRESS':
|
||||
return { ...state, progress: Math.max(1, Math.min(95, action.progress)), error: '' }
|
||||
case 'FINISH_START':
|
||||
return { ...state, state: machineStates.finishing, progress: Math.max(state.progress, 96), error: '' }
|
||||
case 'FINISH_SUCCESS':
|
||||
return { ...state, state: machineStates.processing, progress: 100, processingStatus: action.processingStatus ?? 'processing', error: '' }
|
||||
case 'PROCESSING_STATUS':
|
||||
return { ...state, processingStatus: action.processingStatus ?? state.processingStatus, error: '' }
|
||||
case 'READY_TO_PUBLISH':
|
||||
return { ...state, state: machineStates.ready_to_publish, processingStatus: 'ready', error: '' }
|
||||
case 'PUBLISH_START':
|
||||
return { ...state, state: machineStates.publishing, error: '', lastAction: 'publish' }
|
||||
case 'PUBLISH_SUCCESS':
|
||||
return { ...state, state: machineStates.complete, error: '' }
|
||||
case 'CANCEL_START':
|
||||
return { ...state, isCancelling: true, error: '', lastAction: 'cancel' }
|
||||
case 'CANCELLED':
|
||||
return { ...state, state: machineStates.cancelled, isCancelling: false, error: '' }
|
||||
case 'ERROR':
|
||||
return { ...state, state: machineStates.error, isCancelling: false, error: action.error || 'Upload failed.' }
|
||||
case 'RESET_MACHINE':
|
||||
return { ...initialMachineState }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function toPercent(loaded, total) {
|
||||
if (!Number.isFinite(total) || total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((loaded / total) * 100)))
|
||||
}
|
||||
|
||||
function getProcessingValue(payload) {
|
||||
const direct = String(payload?.processing_state || payload?.status || '').toLowerCase()
|
||||
return direct || 'processing'
|
||||
}
|
||||
|
||||
export function isReadyToPublishStatus(status) {
|
||||
const normalized = String(status || '').toLowerCase()
|
||||
return ['ready', 'processed', 'publish_ready', 'published', 'complete'].includes(normalized)
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* useUploadMachine
|
||||
*
|
||||
* Manages the full upload state machine lifecycle:
|
||||
* init → chunk → finish → poll → publish
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {File|null} opts.primaryFile
|
||||
* @param {boolean} opts.canStartUpload
|
||||
* @param {string} opts.primaryType 'image' | 'archive' | 'unknown'
|
||||
* @param {boolean} opts.isArchive
|
||||
* @param {number|null} opts.initialDraftId
|
||||
* @param {object} opts.metadata { title, description, tags, rightsAccepted, ... }
|
||||
* @param {number} [opts.chunkSize]
|
||||
* @param {function} [opts.onArtworkCreated] called with artworkId after draft creation
|
||||
*/
|
||||
export default function useUploadMachine({
|
||||
primaryFile,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
initialDraftId = null,
|
||||
metadata,
|
||||
chunkSize = DEFAULT_CHUNK_SIZE_BYTES,
|
||||
onArtworkCreated,
|
||||
}) {
|
||||
const [machine, dispatchMachine] = useReducer(machineReducer, initialMachineState)
|
||||
|
||||
const pollingTimerRef = useRef(null)
|
||||
const requestControllersRef = useRef(new Set())
|
||||
const publishLockRef = useRef(false)
|
||||
|
||||
// Resolved artwork id (draft) created at the start of the upload
|
||||
const resolvedArtworkIdRef = useRef(
|
||||
(() => {
|
||||
const parsed = Number(initialDraftId)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||||
})()
|
||||
)
|
||||
|
||||
const effectiveChunkSize = (() => {
|
||||
const parsed = Number(chunkSize)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_CHUNK_SIZE_BYTES
|
||||
})()
|
||||
|
||||
// ── Controller registry ────────────────────────────────────────────────────
|
||||
const registerController = useCallback(() => {
|
||||
const controller = new AbortController()
|
||||
requestControllersRef.current.add(controller)
|
||||
return controller
|
||||
}, [])
|
||||
|
||||
const unregisterController = useCallback((controller) => {
|
||||
if (!controller) return
|
||||
requestControllersRef.current.delete(controller)
|
||||
}, [])
|
||||
|
||||
const abortAllRequests = useCallback(() => {
|
||||
requestControllersRef.current.forEach((c) => c.abort())
|
||||
requestControllersRef.current.clear()
|
||||
}, [])
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────────────
|
||||
const clearPolling = useCallback(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
window.clearInterval(pollingTimerRef.current)
|
||||
pollingTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchProcessingStatus = useCallback(async (sessionId, uploadToken, signal) => {
|
||||
const response = await window.axios.get(uploadEndpoints.status(sessionId), {
|
||||
signal,
|
||||
headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined,
|
||||
params: uploadToken ? { upload_token: uploadToken } : undefined,
|
||||
})
|
||||
return response.data || {}
|
||||
}, [])
|
||||
|
||||
const pollProcessing = useCallback(async (sessionId, uploadToken) => {
|
||||
if (!sessionId) return
|
||||
try {
|
||||
const statusController = registerController()
|
||||
const payload = await fetchProcessingStatus(sessionId, uploadToken, statusController.signal)
|
||||
unregisterController(statusController)
|
||||
|
||||
const processingValue = getProcessingValue(payload)
|
||||
dispatchMachine({ type: 'PROCESSING_STATUS', processingStatus: processingValue })
|
||||
|
||||
if (isReadyToPublishStatus(processingValue)) {
|
||||
dispatchMachine({ type: 'READY_TO_PUBLISH' })
|
||||
clearPolling()
|
||||
} else if (processingValue === 'rejected' || processingValue === 'error' || payload?.failure_reason) {
|
||||
const failureMessage = payload?.failure_reason || payload?.message || `Processing ended with status: ${processingValue}`
|
||||
dispatchMachine({ type: 'ERROR', error: failureMessage })
|
||||
clearPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
|
||||
const message = error?.response?.data?.message || 'Processing status check failed.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
emitUploadEvent('upload_error', { stage: 'processing_poll', message })
|
||||
clearPolling()
|
||||
}
|
||||
}, [fetchProcessingStatus, registerController, unregisterController, clearPolling])
|
||||
|
||||
const startPolling = useCallback((sessionId, uploadToken) => {
|
||||
clearPolling()
|
||||
pollProcessing(sessionId, uploadToken)
|
||||
pollingTimerRef.current = window.setInterval(() => {
|
||||
pollProcessing(sessionId, uploadToken)
|
||||
}, POLL_INTERVAL_MS)
|
||||
}, [clearPolling, pollProcessing])
|
||||
|
||||
// ── Core upload flow ───────────────────────────────────────────────────────
|
||||
const runUploadFlow = useCallback(async () => {
|
||||
if (!primaryFile || !canStartUpload) return
|
||||
|
||||
clearPolling()
|
||||
dispatchMachine({ type: 'INIT_START' })
|
||||
emitUploadEvent('upload_start', {
|
||||
file_name: primaryFile.name,
|
||||
file_size: primaryFile.size,
|
||||
file_type: primaryType,
|
||||
is_archive: isArchive,
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. Create or reuse the artwork draft
|
||||
let artworkIdForUpload = resolvedArtworkIdRef.current
|
||||
if (!artworkIdForUpload) {
|
||||
const derivedTitle =
|
||||
String(metadata.title || '').trim() ||
|
||||
String(primaryFile.name || '').replace(/\.[^.]+$/, '') ||
|
||||
'Untitled upload'
|
||||
|
||||
const draftResponse = await window.axios.post('/api/artworks', {
|
||||
title: derivedTitle,
|
||||
description: String(metadata.description || '').trim() || null,
|
||||
category: metadata.subCategoryId || metadata.rootCategoryId || null,
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '',
|
||||
license: Boolean(metadata.rightsAccepted),
|
||||
})
|
||||
|
||||
const draftIdCandidate = Number(draftResponse?.data?.artwork_id ?? draftResponse?.data?.id)
|
||||
if (!Number.isFinite(draftIdCandidate) || draftIdCandidate <= 0) {
|
||||
throw new Error('Unable to create upload draft before finishing upload.')
|
||||
}
|
||||
|
||||
artworkIdForUpload = Math.floor(draftIdCandidate)
|
||||
resolvedArtworkIdRef.current = artworkIdForUpload
|
||||
onArtworkCreated?.(artworkIdForUpload)
|
||||
}
|
||||
|
||||
// 2. Init upload session
|
||||
const initController = registerController()
|
||||
const initResponse = await window.axios.post(
|
||||
uploadEndpoints.init(),
|
||||
{ client: 'web' },
|
||||
{ signal: initController.signal }
|
||||
)
|
||||
unregisterController(initController)
|
||||
|
||||
const sessionId = initResponse?.data?.session_id
|
||||
const uploadToken = initResponse?.data?.upload_token
|
||||
if (!sessionId || !uploadToken) {
|
||||
throw new Error('Upload session initialization returned an invalid payload.')
|
||||
}
|
||||
|
||||
dispatchMachine({ type: 'INIT_SUCCESS', sessionId, uploadToken })
|
||||
dispatchMachine({ type: 'UPLOAD_START' })
|
||||
|
||||
// 3. Chunked upload
|
||||
let uploaded = 0
|
||||
const totalSize = primaryFile.size
|
||||
|
||||
while (uploaded < totalSize) {
|
||||
const nextOffset = Math.min(uploaded + effectiveChunkSize, totalSize)
|
||||
const blob = primaryFile.slice(uploaded, nextOffset)
|
||||
|
||||
const payload = new FormData()
|
||||
payload.append('session_id', sessionId)
|
||||
payload.append('offset', String(uploaded))
|
||||
payload.append('chunk_size', String(blob.size))
|
||||
payload.append('total_size', String(totalSize))
|
||||
payload.append('upload_token', uploadToken)
|
||||
payload.append('chunk', blob)
|
||||
|
||||
const chunkController = registerController()
|
||||
const chunkResponse = await window.axios.post(uploadEndpoints.chunk(), payload, {
|
||||
signal: chunkController.signal,
|
||||
headers: { 'X-Upload-Token': uploadToken },
|
||||
})
|
||||
unregisterController(chunkController)
|
||||
|
||||
const receivedBytes = Number(chunkResponse?.data?.received_bytes ?? nextOffset)
|
||||
uploaded = Math.max(nextOffset, Number.isFinite(receivedBytes) ? receivedBytes : nextOffset)
|
||||
const progress = chunkResponse?.data?.progress ?? toPercent(uploaded, totalSize)
|
||||
dispatchMachine({ type: 'UPLOAD_PROGRESS', progress })
|
||||
}
|
||||
|
||||
// 4. Finish + start processing
|
||||
dispatchMachine({ type: 'FINISH_START' })
|
||||
|
||||
const finishController = registerController()
|
||||
const finishResponse = await window.axios.post(
|
||||
uploadEndpoints.finish(),
|
||||
{ session_id: sessionId, upload_token: uploadToken, artwork_id: artworkIdForUpload },
|
||||
{ signal: finishController.signal, headers: { 'X-Upload-Token': uploadToken } }
|
||||
)
|
||||
unregisterController(finishController)
|
||||
|
||||
const finishStatus = getProcessingValue(finishResponse?.data || {})
|
||||
dispatchMachine({ type: 'FINISH_SUCCESS', processingStatus: finishStatus })
|
||||
|
||||
if (isReadyToPublishStatus(finishStatus)) {
|
||||
dispatchMachine({ type: 'READY_TO_PUBLISH' })
|
||||
} else {
|
||||
startPolling(sessionId, uploadToken)
|
||||
}
|
||||
|
||||
emitUploadEvent('upload_complete', { session_id: sessionId, artwork_id: artworkIdForUpload })
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
|
||||
const message = error?.response?.data?.message || error?.message || 'Upload failed.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
emitUploadEvent('upload_error', { stage: 'upload_flow', message })
|
||||
}
|
||||
}, [
|
||||
primaryFile,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
metadata,
|
||||
effectiveChunkSize,
|
||||
registerController,
|
||||
unregisterController,
|
||||
clearPolling,
|
||||
startPolling,
|
||||
onArtworkCreated,
|
||||
])
|
||||
|
||||
// ── Cancel ─────────────────────────────────────────────────────────────────
|
||||
const handleCancel = useCallback(async () => {
|
||||
dispatchMachine({ type: 'CANCEL_START' })
|
||||
clearPolling()
|
||||
abortAllRequests()
|
||||
|
||||
try {
|
||||
const { sessionId, uploadToken } = machine
|
||||
if (sessionId) {
|
||||
await window.axios.post(
|
||||
uploadEndpoints.cancel(),
|
||||
{ session_id: sessionId, upload_token: uploadToken },
|
||||
{ headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined }
|
||||
)
|
||||
}
|
||||
dispatchMachine({ type: 'CANCELLED' })
|
||||
emitUploadEvent('upload_cancel', { session_id: machine.sessionId || null })
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.message || 'Cancel failed.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
emitUploadEvent('upload_error', { stage: 'cancel', message })
|
||||
}
|
||||
}, [machine, abortAllRequests, clearPolling])
|
||||
|
||||
// ── Publish ────────────────────────────────────────────────────────────────
|
||||
const handlePublish = useCallback(async (canPublish) => {
|
||||
if (!canPublish || publishLockRef.current) return
|
||||
|
||||
publishLockRef.current = true
|
||||
dispatchMachine({ type: 'PUBLISH_START' })
|
||||
|
||||
try {
|
||||
const publishTargetId =
|
||||
resolvedArtworkIdRef.current || initialDraftId || machine.sessionId
|
||||
|
||||
if (resolvedArtworkIdRef.current && resolvedArtworkIdRef.current > 0) {
|
||||
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
|
||||
emitUploadEvent('upload_publish', { id: publishTargetId })
|
||||
return
|
||||
}
|
||||
|
||||
if (!publishTargetId) throw new Error('Missing publish id.')
|
||||
|
||||
const publishController = registerController()
|
||||
await window.axios.post(
|
||||
uploadEndpoints.publish(publishTargetId),
|
||||
{
|
||||
title: String(metadata.title || '').trim() || undefined,
|
||||
description: String(metadata.description || '').trim() || null,
|
||||
},
|
||||
{ signal: publishController.signal }
|
||||
)
|
||||
unregisterController(publishController)
|
||||
|
||||
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
|
||||
emitUploadEvent('upload_publish', { id: publishTargetId })
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
|
||||
const message = error?.response?.data?.message || error?.message || 'Publish failed.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
emitUploadEvent('upload_error', { stage: 'publish', message })
|
||||
} finally {
|
||||
publishLockRef.current = false
|
||||
}
|
||||
}, [machine, initialDraftId, metadata.title, metadata.description, registerController, unregisterController])
|
||||
|
||||
// ── Reset ──────────────────────────────────────────────────────────────────
|
||||
const resetMachine = useCallback(() => {
|
||||
clearPolling()
|
||||
abortAllRequests()
|
||||
resolvedArtworkIdRef.current = (() => {
|
||||
const parsed = Number(initialDraftId)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||||
})()
|
||||
publishLockRef.current = false
|
||||
dispatchMachine({ type: 'RESET_MACHINE' })
|
||||
}, [clearPolling, abortAllRequests, initialDraftId])
|
||||
|
||||
// ── Retry ──────────────────────────────────────────────────────────────────
|
||||
const handleRetry = useCallback((canPublish) => {
|
||||
clearPolling()
|
||||
abortAllRequests()
|
||||
if (machine.lastAction === 'publish') {
|
||||
handlePublish(canPublish)
|
||||
return
|
||||
}
|
||||
runUploadFlow()
|
||||
}, [machine.lastAction, handlePublish, runUploadFlow, clearPolling, abortAllRequests])
|
||||
|
||||
// ── Cleanup on unmount ─────────────────────────────────────────────────────
|
||||
// (callers should call resetMachine or abortAllRequests on unmount if needed)
|
||||
|
||||
return {
|
||||
machine,
|
||||
dispatchMachine,
|
||||
resolvedArtworkId: resolvedArtworkIdRef.current,
|
||||
runUploadFlow,
|
||||
handleCancel,
|
||||
handlePublish,
|
||||
handleRetry,
|
||||
resetMachine,
|
||||
clearPolling,
|
||||
abortAllRequests,
|
||||
startPolling,
|
||||
}
|
||||
}
|
||||
185
resources/js/hooks/upload/useVisionTags.js
Normal file
185
resources/js/hooks/upload/useVisionTags.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const VISION_POLL_INTERVAL_MS = 5000
|
||||
const VISION_DEBOUNCE_MS = 3000
|
||||
|
||||
const initialDebug = {
|
||||
enabled: null,
|
||||
queueConnection: '',
|
||||
queuedJobs: 0,
|
||||
failedJobs: 0,
|
||||
triggered: false,
|
||||
aiTagCount: 0,
|
||||
totalTagCount: 0,
|
||||
syncDone: false,
|
||||
lastError: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* useVisionTags
|
||||
*
|
||||
* Two-phase Vision AI tag suggestions triggered as soon as the upload is ready,
|
||||
* BEFORE the user navigates to the Details step so suggestions arrive early:
|
||||
*
|
||||
* Phase 1 — Immediate (on upload completion):
|
||||
* POST /api/uploads/{id}/vision-suggest
|
||||
* Calls the Vision gateway (/analyze/all) synchronously and returns
|
||||
* CLIP + YOLO tag suggestions within ~5-8 s. Results are shown right away.
|
||||
*
|
||||
* Phase 2 — Background polling:
|
||||
* GET /api/artworks/{id}/tags?trigger=1
|
||||
* Triggers the queue-based AutoTagArtworkJob, then polls every 5 s until
|
||||
* the DB-persisted AI tags appear (after job completes). Merges with Phase 1
|
||||
* results so the suggestions panel stays populated regardless of queue lag.
|
||||
*
|
||||
* @param {number|null} artworkId
|
||||
* @param {boolean} uploadReady true once the upload reaches ready_to_publish
|
||||
* @returns {{ visionSuggestedTags: any[], visionDebug: object }}
|
||||
*/
|
||||
export default function useVisionTags(artworkId, uploadReady) {
|
||||
const [visionSuggestedTags, setVisionSuggestedTags] = useState([])
|
||||
const [visionDebug, setVisionDebug] = useState(initialDebug)
|
||||
|
||||
// Refs for deduplication
|
||||
const syncDoneRef = useRef(false)
|
||||
const lastPollAtRef = useRef(0)
|
||||
const abortRef = useRef(null)
|
||||
|
||||
// ── Tag merging helper ──────────────────────────────────────────────────
|
||||
const mergeTags = useCallback((incoming) => {
|
||||
setVisionSuggestedTags((prev) => {
|
||||
if (!incoming.length) return prev
|
||||
const seen = new Set(prev.map((t) => t?.slug || t?.name || t))
|
||||
const next = [...prev]
|
||||
for (const tag of incoming) {
|
||||
const key = tag?.slug || tag?.name || tag
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
next.push(tag)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── Phase 1: Immediate gateway call ────────────────────────────────────
|
||||
const callGatewaySync = useCallback(async () => {
|
||||
if (!artworkId || artworkId <= 0 || syncDoneRef.current) return
|
||||
syncDoneRef.current = true
|
||||
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const response = await window.axios.post(
|
||||
`/api/uploads/${artworkId}/vision-suggest`,
|
||||
{},
|
||||
{ signal: abortRef.current.signal },
|
||||
)
|
||||
const payload = response?.data || {}
|
||||
|
||||
if (payload?.vision_enabled === false) {
|
||||
setVisionDebug((prev) => ({ ...prev, enabled: false, syncDone: true }))
|
||||
return
|
||||
}
|
||||
|
||||
const tags = Array.isArray(payload?.tags) ? payload.tags : []
|
||||
mergeTags(tags)
|
||||
|
||||
setVisionDebug((prev) => ({
|
||||
...prev,
|
||||
enabled: true,
|
||||
syncDone: true,
|
||||
aiTagCount: Math.max(prev.aiTagCount, tags.length),
|
||||
}))
|
||||
|
||||
window.console?.debug?.('[upload][vision-tags][sync]', {
|
||||
artworkId,
|
||||
count: tags.length,
|
||||
source: payload?.source,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err?.code === 'ERR_CANCELED') return
|
||||
setVisionDebug((prev) => ({
|
||||
...prev,
|
||||
syncDone: true,
|
||||
lastError: err?.response?.data?.reason || err?.message || '',
|
||||
}))
|
||||
}
|
||||
}, [artworkId, mergeTags])
|
||||
|
||||
// ── Phase 2: Background DB poll ─────────────────────────────────────────
|
||||
const pollDbTags = useCallback(async () => {
|
||||
if (!artworkId || artworkId <= 0) return
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastPollAtRef.current < VISION_DEBOUNCE_MS) return
|
||||
lastPollAtRef.current = now
|
||||
|
||||
try {
|
||||
const response = await window.axios.get(`/api/artworks/${artworkId}/tags`, {
|
||||
params: { trigger: 1 },
|
||||
})
|
||||
const payload = response?.data || {}
|
||||
|
||||
if (payload?.vision_enabled === false) {
|
||||
setVisionDebug((prev) => ({ ...prev, enabled: false }))
|
||||
return
|
||||
}
|
||||
|
||||
const aiTags = Array.isArray(payload?.ai_tags) ? payload.ai_tags : []
|
||||
mergeTags(aiTags)
|
||||
|
||||
const debug = payload?.debug || {}
|
||||
setVisionDebug((prev) => ({
|
||||
...prev,
|
||||
enabled: Boolean(payload?.vision_enabled),
|
||||
queueConnection: String(debug?.queue_connection || prev.queueConnection),
|
||||
queuedJobs: Number(debug?.queued_jobs ?? prev.queuedJobs),
|
||||
failedJobs: Number(debug?.failed_jobs ?? prev.failedJobs),
|
||||
triggered: Boolean(debug?.triggered),
|
||||
aiTagCount: Math.max(prev.aiTagCount, Number(debug?.ai_tag_count || aiTags.length || 0)),
|
||||
totalTagCount: Number(debug?.total_tag_count || 0),
|
||||
lastError: '',
|
||||
}))
|
||||
|
||||
window.console?.debug?.('[upload][vision-tags][poll]', {
|
||||
artworkId,
|
||||
aiTags: aiTags.map((t) => t?.slug || t?.name || t),
|
||||
})
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 404 || err?.response?.status === 403) return
|
||||
setVisionDebug((prev) => ({
|
||||
...prev,
|
||||
lastError: err?.response?.data?.message || err?.message || '',
|
||||
}))
|
||||
}
|
||||
}, [artworkId, mergeTags])
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!artworkId || !uploadReady) return
|
||||
|
||||
// Kick off the immediate gateway call
|
||||
callGatewaySync()
|
||||
|
||||
// Start background polling
|
||||
pollDbTags()
|
||||
const timer = window.setInterval(pollDbTags, VISION_POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
}, [artworkId, uploadReady, callGatewaySync, pollDbTags])
|
||||
|
||||
// Reset when artworkId changes (new upload)
|
||||
useEffect(() => {
|
||||
syncDoneRef.current = false
|
||||
lastPollAtRef.current = 0
|
||||
setVisionSuggestedTags([])
|
||||
setVisionDebug(initialDebug)
|
||||
}, [artworkId])
|
||||
|
||||
return { visionSuggestedTags, visionDebug }
|
||||
}
|
||||
Reference in New Issue
Block a user