Sync deploy mirror and upstream error page
This commit is contained in:
@@ -0,0 +1,941 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
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 (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold text-white">{value}</p>
|
||||
<p className="mt-2 text-sm text-slate-400">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (event) => {
|
||||
const nextBatchId = event.target.value
|
||||
setSelectedBatchId(nextBatchId)
|
||||
await loadQueue({ batch_id: nextBatchId || undefined })
|
||||
}
|
||||
|
||||
const onDropFiles = (event) => {
|
||||
event.preventDefault()
|
||||
const dropped = Array.from(event.dataTransfer.files || [])
|
||||
setFiles(dropped)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="space-y-6">
|
||||
{notice && (
|
||||
<div className={`rounded-[24px] border px-4 py-3 text-sm ${noticeClasses(notice.type)}`}>
|
||||
{notice.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,_rgba(15,23,42,0.84),_rgba(2,6,23,0.96))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.3)]">
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Bulk upload drafts</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Start a batch, then let Studio handle the review queue.</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:w-[380px]">
|
||||
<SummaryCard label="Selected files" value={files.length} hint="Add multiple images to build a batch." />
|
||||
<SummaryCard label="Current batch" value={currentBatch?.total_items || 0} hint={currentBatch ? `Status: ${String(currentBatch.status || 'uploading').replace(/_/g, ' ')}` : 'No active batch selected.'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr,0.8fr]">
|
||||
<div
|
||||
className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.03] p-5 transition hover:border-sky-300/35"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDropFiles}
|
||||
>
|
||||
<div className="flex h-full flex-col justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Drag multiple image files here</p>
|
||||
<p className="mt-2 text-sm text-slate-400">PNG, JPG, and WebP files are supported through the normal upload pipeline. Each file becomes one draft artwork.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<i className="fa-solid fa-cloud-arrow-up" />
|
||||
Choose files
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => setFiles(Array.from(event.target.files || []))}
|
||||
/>
|
||||
<span className="text-sm text-slate-500">{files.length > 0 ? `${files.length} file(s) ready` : 'Nothing selected yet'}</span>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch contents</p>
|
||||
<div className="mt-3 max-h-48 space-y-2 overflow-y-auto pr-1 text-sm text-slate-300">
|
||||
{files.map((file) => (
|
||||
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between gap-3 rounded-2xl border border-white/5 bg-white/[0.02] px-3 py-2">
|
||||
<span className="truncate">{file.name}</span>
|
||||
<span className="text-xs text-slate-500">{Math.max(1, Math.round(file.size / 1024))} KB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-sm font-semibold text-white">Shared defaults</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch name</span>
|
||||
<input
|
||||
value={defaults.name}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Category</span>
|
||||
<select
|
||||
value={defaults.categoryId}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, categoryId: 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"
|
||||
>
|
||||
<option value="">No shared category</option>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.id} value={option.id} className="bg-slate-950">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visibility when published</span>
|
||||
<select
|
||||
value={defaults.visibility}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, visibility: 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"
|
||||
>
|
||||
<option value="public" className="bg-slate-950">Public</option>
|
||||
<option value="unlisted" className="bg-slate-950">Unlisted</option>
|
||||
<option value="private" className="bg-slate-950">Private</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shared tags</span>
|
||||
<textarea
|
||||
value={defaults.tags}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, tags: event.target.value }))}
|
||||
className="mt-2 min-h-[92px] 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="fantasy, portrait, wallpaper"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={defaults.isMature}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, isMature: event.target.checked }))}
|
||||
className="h-4 w-4 rounded border-white/20 bg-transparent"
|
||||
/>
|
||||
Mark all files as creator-declared mature
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startUpload}
|
||||
disabled={uploading || files.length === 0}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/45 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-play" />
|
||||
{busyAction === 'create-batch' ? 'Creating batch...' : 'Start upload batch'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Queue view</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Review a batch, then work the drafts that actually need attention.</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch</span>
|
||||
<select
|
||||
value={selectedBatchId}
|
||||
onChange={handleBatchChange}
|
||||
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"
|
||||
>
|
||||
<option value="">Latest batch</option>
|
||||
{batches.map((batch) => (
|
||||
<option key={batch.id} value={batch.id} className="bg-slate-950">
|
||||
{batch.name || `Batch #${batch.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Filter</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={async (event) => {
|
||||
const nextStatus = event.target.value
|
||||
setStatusFilter(nextStatus)
|
||||
await loadQueue({ status: nextStatus })
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{(queue?.status_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-950">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sort</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={async (event) => {
|
||||
const nextSort = event.target.value
|
||||
setSort(nextSort)
|
||||
await loadQueue({ sort: nextSort })
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{(queue?.sort_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-950">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentBatch && (
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-5">
|
||||
<SummaryCard label="Batch status" value={String(currentBatch.status || 'uploading').replace(/_/g, ' ')} hint={`Updated ${formatDate(currentBatch.updated_at)}`} />
|
||||
<SummaryCard label="Ready" value={currentBatch.ready_items || 0} hint="Can be published right now." />
|
||||
<SummaryCard label="Processing" value={currentBatch.processing_items || 0} hint="Still moving through the pipeline." />
|
||||
<SummaryCard label="Needs review" value={currentBatch.needs_review_items || 0} hint="Blocked on maturity or review." />
|
||||
<SummaryCard label="Failed" value={currentBatch.failed_items || 0} hint="Needs retry or a fresh upload." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 rounded-[28px] border border-white/10 bg-slate-950/35 p-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
disabled={selectableIds.length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-check-double" />
|
||||
{allSelected ? 'Clear selection' : 'Select visible'}
|
||||
</button>
|
||||
<span className="text-sm text-slate-500">{selectedIds.length} selected</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-[1fr,1fr,auto,auto]">
|
||||
<select
|
||||
value={bulkForm.categoryId}
|
||||
onChange={(event) => setBulkForm((current) => ({ ...current, categoryId: 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"
|
||||
>
|
||||
<option value="">Apply category...</option>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.id} value={option.id} className="bg-slate-950">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={bulkForm.tags}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<select
|
||||
value={bulkForm.visibility}
|
||||
onChange={(event) => setBulkForm((current) => ({ ...current, visibility: 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"
|
||||
>
|
||||
<option value="public" className="bg-slate-950">Public</option>
|
||||
<option value="unlisted" className="bg-slate-950">Unlisted</option>
|
||||
<option value="private" className="bg-slate-950">Private</option>
|
||||
</select>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.02] p-8 text-center text-sm text-slate-400">
|
||||
No items match this view yet. Start a batch above or switch to another recent batch.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
{items.map((item) => {
|
||||
const localUpload = uploadState[item.id] || null
|
||||
const progress = localUpload?.progress ?? null
|
||||
const actionState = item.actions || {}
|
||||
|
||||
return (
|
||||
<article key={item.id} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(3,7,18,0.18)]">
|
||||
<div className="flex items-start gap-4">
|
||||
<label className="mt-1 inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(Number(item.id))}
|
||||
onChange={() => handleToggleSelected(Number(item.id))}
|
||||
className="h-4 w-4 rounded border-white/20 bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="h-24 w-24 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
|
||||
{item.thumbnail_url ? (
|
||||
<img src={item.thumbnail_url} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-slate-500">
|
||||
<i className="fa-solid fa-image text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${statusClasses(item.status)}`}>
|
||||
{String(item.status || 'processing').replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${batchStatusClasses(item.processing_stage === 'finalized' ? 'completed' : 'processing')}`}>
|
||||
{humanStage(item.processing_stage)}
|
||||
</span>
|
||||
{item.is_ready_to_publish && (
|
||||
<span className="inline-flex items-center rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">
|
||||
Ready to publish
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-1 truncate text-sm text-slate-400">{item.original_filename}</p>
|
||||
|
||||
<div className="mt-4 grid gap-2 text-sm text-slate-300 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Metadata</span>
|
||||
<p className="mt-2 text-white">{item.metadata_label}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Updated</span>
|
||||
<p className="mt-2 text-white">{formatDate(item.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{typeof progress === 'number' && progress < 100 && (
|
||||
<div className="mt-4 rounded-2xl border border-sky-300/20 bg-sky-300/10 px-3 py-3 text-sm text-sky-100">
|
||||
Uploading now: {formatPercent(progress)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.isArray(item.missing) && item.missing.length > 0 && (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-3 text-sm text-slate-300">
|
||||
{item.missing.join(' • ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.error_message && (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-3 py-3 text-sm text-rose-100">
|
||||
{item.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{actionState.can_edit && item.edit_url && (
|
||||
<a href={item.edit_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06]">
|
||||
<i className="fa-solid fa-pen-to-square" />
|
||||
Edit in Studio
|
||||
</a>
|
||||
)}
|
||||
{actionState.can_publish && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<i className="fa-solid fa-rocket" />
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{actionState.can_generate_ai && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
Generate AI
|
||||
</button>
|
||||
)}
|
||||
{actionState.can_retry_processing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<i className="fa-solid fa-rotate-right" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{actionState.can_delete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<i className="fa-solid fa-trash" />
|
||||
Delete draft
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user