import React, { useEffect, useMemo, useRef, useState } from 'react' import { usePage } from '@inertiajs/react' import StudioLayout from '../../Layouts/StudioLayout' import Checkbox from '../../components/ui/Checkbox' import NovaSelect from '../../components/ui/NovaSelect' const MIN_CHUNK_SIZE_BYTES = 256 * 1024 function formatDate(value) { if (!value) return 'Just now' const date = new Date(value) if (Number.isNaN(date.getTime())) return 'Just now' return date.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', }) } function formatPercent(value) { const normalized = Number(value || 0) if (!Number.isFinite(normalized)) return '0%' return `${Math.max(0, Math.min(100, Math.round(normalized)))}%` } function parseTags(raw) { return String(raw || '') .split(/[\n,]+/) .map((tag) => tag.trim()) .filter(Boolean) } function statusClasses(status) { switch (status) { case 'ready': return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100' case 'published': return 'border-sky-400/30 bg-sky-400/10 text-sky-100' case 'failed': return 'border-rose-400/30 bg-rose-400/10 text-rose-100' case 'needs_review': case 'needs_metadata': return 'border-amber-400/30 bg-amber-400/10 text-amber-100' default: return 'border-white/15 bg-white/5 text-slate-300' } } function batchStatusClasses(status) { switch (status) { case 'completed': return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100' case 'completed_with_errors': return 'border-amber-400/30 bg-amber-400/10 text-amber-100' case 'processing': return 'border-sky-400/30 bg-sky-400/10 text-sky-100' default: return 'border-white/15 bg-white/5 text-slate-300' } } function noticeClasses(type) { switch (type) { case 'success': return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100' case 'warning': return 'border-amber-400/30 bg-amber-400/10 text-amber-100' default: return 'border-rose-400/30 bg-rose-400/10 text-rose-100' } } function humanStage(stage) { return String(stage || 'queued').replace(/_/g, ' ') } function flattenCategories(contentTypes) { return (Array.isArray(contentTypes) ? contentTypes : []).flatMap((type) => { const parents = Array.isArray(type?.categories) ? type.categories : [] return parents.flatMap((category) => { const children = Array.isArray(category?.children) ? category.children : [] if (children.length === 0) { return [{ id: category.id, label: `${type.name} / ${category.name}`, }] } return children.map((child) => ({ id: child.id, label: `${type.name} / ${category.name} / ${child.name}`, })) }) }) } function SummaryCard({ label, value, hint }) { return (

{label}

{value}

{hint}

) } export default function StudioUploadQueue() { const { props } = usePage() const queueProp = props.queue || {} const chunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Number(props.chunkSize || 0) || (5 * 1024 * 1024)) const chunkRequestTimeoutMs = Math.max(15000, Number(props.chunkRequestTimeoutMs || 0) || 45000) const categoryOptions = useMemo(() => flattenCategories(props.contentTypes || []), [props.contentTypes]) const [queue, setQueue] = useState(queueProp) const [selectedBatchId, setSelectedBatchId] = useState(queueProp?.filters?.batch_id ?? queueProp?.current_batch?.id ?? '') const [statusFilter, setStatusFilter] = useState(queueProp?.filters?.status || 'all') const [sort, setSort] = useState(queueProp?.filters?.sort || 'newest') const [selectedIds, setSelectedIds] = useState([]) const [files, setFiles] = useState([]) const [uploading, setUploading] = useState(false) const [uploadState, setUploadState] = useState({}) const [notice, setNotice] = useState(null) const [busyAction, setBusyAction] = useState('') const [defaults, setDefaults] = useState({ name: '', categoryId: '', tags: '', visibility: 'public', isMature: false, }) const [bulkForm, setBulkForm] = useState({ categoryId: '', tags: '', visibility: 'public', }) const fileInputRef = useRef(null) const noticeTimeoutRef = useRef(null) const items = Array.isArray(queue?.items) ? queue.items : [] const currentBatch = queue?.current_batch || null const batches = Array.isArray(queue?.batches) ? queue.batches : [] const selectableIds = items .filter((item) => item?.actions?.can_delete || item?.actions?.can_publish || item?.actions?.can_generate_ai) .map((item) => Number(item.id)) const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id)) const activeProcessing = uploading || ['uploading', 'processing'].includes(String(currentBatch?.status || '')) const pushNotice = (type, message) => { setNotice({ type, message }) window.clearTimeout(noticeTimeoutRef.current) noticeTimeoutRef.current = window.setTimeout(() => setNotice(null), 4500) } useEffect(() => () => window.clearTimeout(noticeTimeoutRef.current), []) const syncSelectedIds = (queueItems) => { const validIds = new Set((queueItems || []).map((item) => Number(item.id))) setSelectedIds((current) => current.filter((id) => validIds.has(id))) } const loadQueue = async (overrides = {}) => { const params = { batch_id: overrides.batch_id ?? (selectedBatchId || undefined), status: overrides.status ?? statusFilter, sort: overrides.sort ?? sort, } try { const response = await window.axios.get('/api/studio/upload-queue', { params }) const nextQueue = response.data || {} setQueue(nextQueue) setSelectedBatchId(nextQueue?.filters?.batch_id ?? '') syncSelectedIds(nextQueue?.items || []) return nextQueue } catch (error) { pushNotice('error', error?.response?.data?.message || 'Failed to refresh the upload queue.') return null } } useEffect(() => { if (!activeProcessing || !selectedBatchId) return undefined const timer = window.setInterval(() => { loadQueue({ batch_id: selectedBatchId }) }, 3000) return () => window.clearInterval(timer) }, [activeProcessing, selectedBatchId, statusFilter, sort]) const uploadChunk = async (sessionId, uploadToken, blob, offset, totalSize) => { const payload = new FormData() payload.append('session_id', sessionId) payload.append('offset', String(offset)) payload.append('chunk_size', String(blob.size)) payload.append('total_size', String(totalSize)) payload.append('chunk', blob) payload.append('upload_token', uploadToken) const response = await window.axios.post('/api/uploads/chunk', payload, { timeout: chunkRequestTimeoutMs, headers: { 'X-Upload-Token': uploadToken }, }) return response.data || {} } const uploadSingleFile = async (item, file) => { const init = await window.axios.post('/api/uploads/init', { client: 'web' }) const sessionId = init?.data?.session_id const uploadToken = init?.data?.upload_token if (!sessionId || !uploadToken) { throw new Error('Upload session initialization failed.') } let offset = 0 while (offset < file.size) { const nextOffset = Math.min(offset + chunkSize, file.size) const chunk = file.slice(offset, nextOffset) const data = await uploadChunk(sessionId, uploadToken, chunk, offset, file.size) offset = Number(data?.received_bytes ?? nextOffset) const progress = Math.max(1, Math.min(100, Math.round((offset / file.size) * 100))) setUploadState((current) => ({ ...current, [item.id]: { ...current[item.id], status: 'uploading', progress, }, })) } await window.axios.post('/api/uploads/finish', { session_id: sessionId, artwork_id: item.artwork_id, batch_item_id: item.id, file_name: file.name, upload_token: uploadToken, }, { headers: { 'X-Upload-Token': uploadToken }, }) setUploadState((current) => ({ ...current, [item.id]: { status: 'processing', progress: 100, }, })) } const markItemFailed = async (itemId, error) => { try { await window.axios.post(`/api/studio/upload-queue/items/${itemId}/fail`, { error_code: error?.response?.data?.reason || 'upload_failed', error_message: error?.response?.data?.message || error?.message || 'Upload failed.', }) } catch (markError) { // Keep the original upload error as the visible one. } } const startUpload = async () => { if (files.length === 0) { pushNotice('error', 'Choose at least one image file to start a batch.') return } setUploading(true) setBusyAction('create-batch') try { const response = await window.axios.post('/api/studio/upload-queue/batches', { name: defaults.name || null, files: files.map((file) => ({ name: file.name })), defaults: { category_id: defaults.categoryId ? Number(defaults.categoryId) : null, tags: parseTags(defaults.tags), visibility: defaults.visibility, is_mature: Boolean(defaults.isMature), }, }) const createdItems = Array.isArray(response?.data?.items) ? response.data.items : [] const batchId = response?.data?.batch?.id if (!batchId || createdItems.length !== files.length) { throw new Error('Batch registration did not return a usable file map.') } setQueue(response.data.queue || queue) setSelectedBatchId(batchId) setSelectedIds([]) for (let index = 0; index < createdItems.length; index += 1) { const item = createdItems[index] const file = files[index] setUploadState((current) => ({ ...current, [item.id]: { status: 'queued', progress: 0 }, })) try { await uploadSingleFile(item, file) } catch (error) { await markItemFailed(item.id, error) setUploadState((current) => ({ ...current, [item.id]: { status: 'failed', progress: current[item.id]?.progress || 0, }, })) } await loadQueue({ batch_id: batchId }) } setFiles([]) if (fileInputRef.current) { fileInputRef.current.value = '' } pushNotice('success', 'Upload batch created. Processing continues in the queue.') } catch (error) { pushNotice('error', error?.response?.data?.message || error?.message || 'Failed to create the upload batch.') } finally { setUploading(false) setBusyAction('') } } const handleSelectAll = () => { if (allSelected) { setSelectedIds([]) return } setSelectedIds(selectableIds) } const handleToggleSelected = (itemId) => { setSelectedIds((current) => current.includes(itemId) ? current.filter((id) => id !== itemId) : [...current, itemId]) } const summarizePublishSelection = (ids) => { const selectedItems = items.filter((item) => ids.includes(Number(item.id))) const readyItems = selectedItems.filter((item) => item?.is_ready_to_publish) const blockedItems = selectedItems.filter((item) => !item?.is_ready_to_publish) const reviewBlockedCount = blockedItems.filter((item) => item?.status === 'needs_review').length const metadataBlockedCount = blockedItems.filter((item) => item?.status === 'needs_metadata').length const processingBlockedCount = blockedItems.filter((item) => item?.status === 'processing').length const failedBlockedCount = blockedItems.filter((item) => item?.status === 'failed').length return { totalCount: selectedItems.length, readyCount: readyItems.length, blockedCount: blockedItems.length, reviewBlockedCount, metadataBlockedCount, processingBlockedCount, failedBlockedCount, } } const confirmPublishSelection = (ids) => { const summary = summarizePublishSelection(ids) if (summary.totalCount === 0) { pushNotice('warning', 'Select at least one queue item first.') return false } if (summary.readyCount === 0) { pushNotice('warning', 'None of the selected drafts are ready to publish yet.') return false } const message = [ `Publish ${summary.readyCount} ready draft(s)?`, `Selected: ${summary.totalCount}`, `Ready now: ${summary.readyCount}`, `Blocked and skipped: ${summary.blockedCount}`, ] if (summary.reviewBlockedCount > 0) { message.push(`Needs review: ${summary.reviewBlockedCount}`) } if (summary.metadataBlockedCount > 0) { message.push(`Missing metadata: ${summary.metadataBlockedCount}`) } if (summary.processingBlockedCount > 0) { message.push(`Still processing: ${summary.processingBlockedCount}`) } if (summary.failedBlockedCount > 0) { message.push(`Failed items: ${summary.failedBlockedCount}`) } message.push('Blocked drafts will not be published.') return window.confirm(message.join('\n')) } const runBulkAction = async (action, params = {}, ids = selectedIds) => { if (!Array.isArray(ids) || ids.length === 0) { pushNotice('warning', 'Select at least one queue item first.') return } let confirmValue = undefined if (action === 'publish') { if (!confirmPublishSelection(ids)) { return } } if (action === 'delete') { const value = window.prompt('Type DELETE to remove the selected drafts from the queue.') if (value !== 'DELETE') { return } confirmValue = value } setBusyAction(action) try { const response = await window.axios.post('/api/studio/upload-queue/bulk', { action, item_ids: ids, params, confirm: confirmValue, }) const success = Number(response?.data?.success || 0) const failed = Number(response?.data?.failed || 0) if (failed > 0 && success === 0) { pushNotice('error', response?.data?.errors?.[0] || 'The queue action failed.') } else if (failed > 0) { pushNotice('warning', `${success} item(s) updated. ${failed} item(s) could not be changed.`) } else { pushNotice('success', `${success} item(s) updated.`) } await loadQueue({ batch_id: selectedBatchId }) setSelectedIds([]) } catch (error) { const message = error?.response?.data?.errors?.[0] || error?.response?.data?.message || 'The queue action failed.' pushNotice('error', message) } finally { setBusyAction('') } } const retryItem = async (itemId) => { setBusyAction(`retry-${itemId}`) try { await window.axios.post(`/api/studio/upload-queue/items/${itemId}/retry`) pushNotice('success', 'Background processing has been queued again for this draft.') await loadQueue({ batch_id: selectedBatchId }) } catch (error) { pushNotice('error', error?.response?.data?.message || 'Retry failed for this queue item.') } finally { setBusyAction('') } } const handleBatchChange = async (nextBatchId) => { setSelectedBatchId(nextBatchId) await loadQueue({ batch_id: nextBatchId || undefined }) } const onDropFiles = (event) => { event.preventDefault() const dropped = Array.from(event.dataTransfer.files || []) setFiles(dropped) } return (
{notice && (
{notice.message}
)}

Bulk upload drafts

Start a batch, then let Studio handle the review queue.

Each file becomes a normal draft artwork. Upload transport happens now, thumbnail and maturity work continue in the background, and publishing stays blocked until the draft is actually ready.

event.preventDefault()} onDrop={onDropFiles} >

Drag multiple image files here

PNG, JPG, and WebP files are supported through the normal upload pipeline. Each file becomes one draft artwork.

setFiles(Array.from(event.target.files || []))} /> {files.length > 0 ? `${files.length} file(s) ready` : 'Nothing selected yet'}
{files.length > 0 && (

Batch contents

{files.map((file) => (
{file.name} {Math.max(1, Math.round(file.size / 1024))} KB
))}
)}

Shared defaults

Category setDefaults((current) => ({ ...current, categoryId: value }))} className="mt-2" options={categoryOptions.map((option) => ({ value: String(option.id), label: option.label }))} placeholder="No shared category" />
Visibility when published setDefaults((current) => ({ ...current, visibility: value }))} className="mt-2" options={[ { value: 'public', label: 'Public' }, { value: 'unlisted', label: 'Unlisted' }, { value: 'private', label: 'Private' }, ]} searchable={false} />