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 (
)
}
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.
fileInputRef.current?.click()}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15"
>
Choose files
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
Batch name
setDefaults((current) => ({ ...current, name: event.target.value }))}
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
placeholder="Optional batch label"
/>
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}
/>
Shared tags
setDefaults((current) => ({ ...current, isMature: event.target.checked }))}
label="Mark all files as creator-declared mature"
/>
{busyAction === 'create-batch' ? 'Creating batch...' : 'Start upload batch'}
Queue view
Review a batch, then work the drafts that actually need attention.
Batch
({ value: String(batch.id), label: batch.name || `Batch #${batch.id}` }))}
placeholder="Latest batch"
/>
Filter
{
setStatusFilter(nextStatus)
await loadQueue({ status: nextStatus })
}}
className="mt-2"
options={(queue?.status_options || []).map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
Sort
{
setSort(nextSort)
await loadQueue({ sort: nextSort })
}}
className="mt-2"
options={(queue?.sort_options || []).map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
{currentBatch && (
)}
{allSelected ? 'Clear selection' : 'Select visible'}
{selectedIds.length} selected
runBulkAction('publish')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Publish selected
runBulkAction('generate_ai')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Generate AI
runBulkAction('delete')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Delete selected
setBulkForm((current) => ({ ...current, categoryId: value }))}
options={categoryOptions.map((option) => ({ value: String(option.id), label: option.label }))}
placeholder="Apply category..."
/>
setBulkForm((current) => ({ ...current, tags: event.target.value }))}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
placeholder="Add shared tags to selection"
/>
setBulkForm((current) => ({ ...current, visibility: value }))}
options={[
{ value: 'public', label: 'Public' },
{ value: 'unlisted', label: 'Unlisted' },
{ value: 'private', label: 'Private' },
]}
searchable={false}
/>
runBulkAction('apply_category', { category_id: Number(bulkForm.categoryId) })}
disabled={busyAction !== '' || selectedIds.length === 0 || !bulkForm.categoryId}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Apply category
runBulkAction('apply_tags', { tags: parseTags(bulkForm.tags) })}
disabled={busyAction !== '' || selectedIds.length === 0 || parseTags(bulkForm.tags).length === 0}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Apply tags
runBulkAction('set_visibility', { visibility: bulkForm.visibility })}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Set visibility
{items.length === 0 ? (
No items match this view yet. Start a batch above or switch to another recent batch.
) : (
{items.map((item) => {
const localUpload = uploadState[item.id] || null
const progress = localUpload?.progress ?? null
const actionState = item.actions || {}
return (
handleToggleSelected(Number(item.id))}
/>
{item.thumbnail_url ? (
) : (
)}
{String(item.status || 'processing').replace(/_/g, ' ')}
{humanStage(item.processing_stage)}
{item.is_ready_to_publish && (
Ready to publish
)}
{item.title}
{item.original_filename}
Metadata
{item.metadata_label}
Updated
{formatDate(item.updated_at)}
{typeof progress === 'number' && progress < 100 && (
Uploading now: {formatPercent(progress)}
)}
{Array.isArray(item.missing) && item.missing.length > 0 && (
{item.missing.join(' • ')}
)}
{item.error_message && (
{item.error_message}
)}
{actionState.can_edit && item.edit_url && (
Edit in Studio
)}
{actionState.can_publish && (
runBulkAction('publish', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Publish
)}
{actionState.can_generate_ai && (
runBulkAction('generate_ai', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Generate AI
)}
{actionState.can_retry_processing && (
retryItem(item.id)}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-sm font-semibold text-amber-100 transition hover:border-amber-400/40 hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Retry
)}
{actionState.can_delete && (
runBulkAction('delete', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Delete draft
)}
)
})}
)}
)
}