Implement creator studio and upload updates
This commit is contained in:
@@ -100,11 +100,14 @@ export function isReadyToPublishStatus(status) {
|
||||
* @param {boolean} opts.isArchive
|
||||
* @param {number|null} opts.initialDraftId
|
||||
* @param {object} opts.metadata { title, description, tags, rightsAccepted, ... }
|
||||
* @param {number} [opts.selectedScreenshotIndex]
|
||||
* @param {number} [opts.chunkSize]
|
||||
* @param {function} [opts.onArtworkCreated] called with artworkId after draft creation
|
||||
*/
|
||||
export default function useUploadMachine({
|
||||
primaryFile,
|
||||
screenshots = [],
|
||||
selectedScreenshotIndex = 0,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
@@ -119,6 +122,8 @@ export default function useUploadMachine({
|
||||
const pollingTimerRef = useRef(null)
|
||||
const requestControllersRef = useRef(new Set())
|
||||
const publishLockRef = useRef(false)
|
||||
const archiveSessionRef = useRef({ sessionId: null, uploadToken: null })
|
||||
const additionalScreenshotSessionsRef = useRef([])
|
||||
|
||||
// Resolved artwork id (draft) created at the start of the upload
|
||||
const resolvedArtworkIdRef = useRef(
|
||||
@@ -150,6 +155,22 @@ export default function useUploadMachine({
|
||||
requestControllersRef.current.clear()
|
||||
}, [])
|
||||
|
||||
const clearArchiveSession = useCallback(() => {
|
||||
archiveSessionRef.current = { sessionId: null, uploadToken: null }
|
||||
}, [])
|
||||
|
||||
const setArchiveSession = useCallback((sessionId, uploadToken) => {
|
||||
archiveSessionRef.current = { sessionId, uploadToken }
|
||||
}, [])
|
||||
|
||||
const clearAdditionalScreenshotSessions = useCallback(() => {
|
||||
additionalScreenshotSessionsRef.current = []
|
||||
}, [])
|
||||
|
||||
const setAdditionalScreenshotSessions = useCallback((sessions) => {
|
||||
additionalScreenshotSessionsRef.current = Array.isArray(sessions) ? sessions : []
|
||||
}, [])
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────────────
|
||||
const clearPolling = useCallback(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
@@ -204,11 +225,98 @@ export default function useUploadMachine({
|
||||
}, POLL_INTERVAL_MS)
|
||||
}, [clearPolling, pollProcessing])
|
||||
|
||||
const initUploadSession = useCallback(async () => {
|
||||
const initController = registerController()
|
||||
try {
|
||||
const initResponse = await window.axios.post(
|
||||
uploadEndpoints.init(),
|
||||
{ client: 'web' },
|
||||
{ signal: initController.signal }
|
||||
)
|
||||
|
||||
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.')
|
||||
}
|
||||
|
||||
return { sessionId, uploadToken }
|
||||
} finally {
|
||||
unregisterController(initController)
|
||||
}
|
||||
}, [registerController, unregisterController])
|
||||
|
||||
const uploadSingleFile = useCallback(async (sessionId, uploadToken, file, uploadedBaseBytes, combinedTotalBytes) => {
|
||||
let uploadedForFile = 0
|
||||
const totalSize = file.size
|
||||
|
||||
while (uploadedForFile < totalSize) {
|
||||
const nextOffset = Math.min(uploadedForFile + effectiveChunkSize, totalSize)
|
||||
const blob = file.slice(uploadedForFile, nextOffset)
|
||||
|
||||
const payload = new FormData()
|
||||
payload.append('session_id', sessionId)
|
||||
payload.append('offset', String(uploadedForFile))
|
||||
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()
|
||||
try {
|
||||
await window.axios.post(uploadEndpoints.chunk(), payload, {
|
||||
signal: chunkController.signal,
|
||||
headers: { 'X-Upload-Token': uploadToken },
|
||||
})
|
||||
} finally {
|
||||
unregisterController(chunkController)
|
||||
}
|
||||
|
||||
uploadedForFile = nextOffset
|
||||
const totalUploaded = uploadedBaseBytes + uploadedForFile
|
||||
const progress = Math.max(1, Math.min(95, toPercent(totalUploaded, combinedTotalBytes)))
|
||||
dispatchMachine({ type: 'UPLOAD_PROGRESS', progress })
|
||||
}
|
||||
|
||||
return uploadedBaseBytes + totalSize
|
||||
}, [effectiveChunkSize, registerController, unregisterController])
|
||||
|
||||
const cancelUploadSession = useCallback(async (sessionId, uploadToken) => {
|
||||
if (!sessionId) return
|
||||
|
||||
await window.axios.post(
|
||||
uploadEndpoints.cancel(),
|
||||
{ session_id: sessionId, upload_token: uploadToken || undefined },
|
||||
{ headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined }
|
||||
)
|
||||
}, [])
|
||||
|
||||
// ── Core upload flow ───────────────────────────────────────────────────────
|
||||
const runUploadFlow = useCallback(async () => {
|
||||
if (!primaryFile || !canStartUpload) return
|
||||
|
||||
const normalizedScreenshotIndex = Number.isFinite(selectedScreenshotIndex)
|
||||
? Math.max(0, Math.floor(selectedScreenshotIndex))
|
||||
: 0
|
||||
const previewFile = isArchive
|
||||
? (screenshots[normalizedScreenshotIndex] || screenshots[0] || null)
|
||||
: primaryFile
|
||||
const archiveFile = isArchive ? primaryFile : null
|
||||
const additionalScreenshotFiles = isArchive
|
||||
? screenshots.filter((file, index) => index !== normalizedScreenshotIndex && Boolean(file))
|
||||
: []
|
||||
if (!previewFile) {
|
||||
const message = isArchive
|
||||
? 'Archive uploads require at least one screenshot before upload can start.'
|
||||
: 'A preview image is required before upload can start.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
onNotice?.({ type: 'error', message })
|
||||
return
|
||||
}
|
||||
|
||||
clearPolling()
|
||||
clearArchiveSession()
|
||||
clearAdditionalScreenshotSessions()
|
||||
dispatchMachine({ type: 'INIT_START' })
|
||||
emitUploadEvent('upload_start', {
|
||||
file_name: primaryFile.name,
|
||||
@@ -217,13 +325,20 @@ export default function useUploadMachine({
|
||||
is_archive: isArchive,
|
||||
})
|
||||
|
||||
let activePrimarySessionId = null
|
||||
let activePrimaryUploadToken = null
|
||||
let activeArchiveSessionId = null
|
||||
let activeArchiveUploadToken = null
|
||||
let activeAdditionalScreenshotSessions = []
|
||||
|
||||
try {
|
||||
// 1. Create or reuse the artwork draft
|
||||
let artworkIdForUpload = resolvedArtworkIdRef.current
|
||||
if (!artworkIdForUpload) {
|
||||
const titleSourceFile = archiveFile || primaryFile || previewFile
|
||||
const derivedTitle =
|
||||
String(metadata.title || '').trim() ||
|
||||
String(primaryFile.name || '').replace(/\.[^.]+$/, '') ||
|
||||
String(titleSourceFile?.name || '').replace(/\.[^.]+$/, '') ||
|
||||
'Untitled upload'
|
||||
|
||||
const draftResponse = await window.axios.post('/api/artworks', {
|
||||
@@ -246,50 +361,49 @@ export default function useUploadMachine({
|
||||
}
|
||||
|
||||
// 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.')
|
||||
}
|
||||
const { sessionId, uploadToken } = await initUploadSession()
|
||||
activePrimarySessionId = sessionId
|
||||
activePrimaryUploadToken = uploadToken
|
||||
|
||||
dispatchMachine({ type: 'INIT_SUCCESS', sessionId, uploadToken })
|
||||
dispatchMachine({ type: 'UPLOAD_START' })
|
||||
|
||||
// 3. Chunked upload
|
||||
let uploaded = 0
|
||||
const totalSize = primaryFile.size
|
||||
const combinedTotalBytes = previewFile.size + (archiveFile?.size || 0) + additionalScreenshotFiles.reduce((sum, file) => sum + (file?.size || 0), 0)
|
||||
let uploadedBytes = await uploadSingleFile(sessionId, uploadToken, previewFile, 0, combinedTotalBytes)
|
||||
|
||||
while (uploaded < totalSize) {
|
||||
const nextOffset = Math.min(uploaded + effectiveChunkSize, totalSize)
|
||||
const blob = primaryFile.slice(uploaded, nextOffset)
|
||||
let archiveSessionId = null
|
||||
let archiveUploadToken = null
|
||||
|
||||
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)
|
||||
if (archiveFile) {
|
||||
const archiveSession = await initUploadSession()
|
||||
archiveSessionId = archiveSession.sessionId
|
||||
archiveUploadToken = archiveSession.uploadToken
|
||||
activeArchiveSessionId = archiveSessionId
|
||||
activeArchiveUploadToken = archiveUploadToken
|
||||
setArchiveSession(archiveSessionId, archiveUploadToken)
|
||||
|
||||
const chunkController = registerController()
|
||||
const chunkResponse = await window.axios.post(uploadEndpoints.chunk(), payload, {
|
||||
signal: chunkController.signal,
|
||||
headers: { 'X-Upload-Token': uploadToken },
|
||||
uploadedBytes = await uploadSingleFile(archiveSessionId, archiveUploadToken, archiveFile, uploadedBytes, combinedTotalBytes)
|
||||
}
|
||||
|
||||
const additionalScreenshotSessions = []
|
||||
for (const screenshotFile of additionalScreenshotFiles) {
|
||||
const screenshotSession = await initUploadSession()
|
||||
additionalScreenshotSessions.push({
|
||||
sessionId: screenshotSession.sessionId,
|
||||
uploadToken: screenshotSession.uploadToken,
|
||||
fileName: String(screenshotFile?.name || ''),
|
||||
})
|
||||
unregisterController(chunkController)
|
||||
activeAdditionalScreenshotSessions = additionalScreenshotSessions
|
||||
setAdditionalScreenshotSessions(additionalScreenshotSessions)
|
||||
|
||||
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 })
|
||||
uploadedBytes = await uploadSingleFile(
|
||||
screenshotSession.sessionId,
|
||||
screenshotSession.uploadToken,
|
||||
screenshotFile,
|
||||
uploadedBytes,
|
||||
combinedTotalBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Finish + start processing
|
||||
@@ -302,7 +416,13 @@ export default function useUploadMachine({
|
||||
session_id: sessionId,
|
||||
upload_token: uploadToken,
|
||||
artwork_id: artworkIdForUpload,
|
||||
file_name: String(primaryFile?.name || ''),
|
||||
file_name: String(previewFile?.name || ''),
|
||||
archive_session_id: archiveSessionId,
|
||||
archive_file_name: archiveFile ? String(archiveFile?.name || '') : undefined,
|
||||
additional_screenshot_sessions: additionalScreenshotSessions.map((item) => ({
|
||||
session_id: item.sessionId,
|
||||
file_name: item.fileName,
|
||||
})),
|
||||
},
|
||||
{ signal: finishController.signal, headers: { 'X-Upload-Token': uploadToken } }
|
||||
)
|
||||
@@ -328,6 +448,13 @@ export default function useUploadMachine({
|
||||
emitUploadEvent('upload_complete', { session_id: sessionId, artwork_id: artworkIdForUpload })
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
|
||||
|
||||
await Promise.allSettled([
|
||||
cancelUploadSession(activePrimarySessionId, activePrimaryUploadToken),
|
||||
cancelUploadSession(activeArchiveSessionId, activeArchiveUploadToken),
|
||||
...activeAdditionalScreenshotSessions.map((item) => cancelUploadSession(item.sessionId, item.uploadToken)),
|
||||
])
|
||||
|
||||
const notice = mapUploadErrorNotice(error, 'Upload failed.')
|
||||
dispatchMachine({ type: 'ERROR', error: notice.message })
|
||||
onNotice?.(notice)
|
||||
@@ -335,17 +462,21 @@ export default function useUploadMachine({
|
||||
}
|
||||
}, [
|
||||
primaryFile,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
metadata,
|
||||
effectiveChunkSize,
|
||||
registerController,
|
||||
unregisterController,
|
||||
clearPolling,
|
||||
clearArchiveSession,
|
||||
startPolling,
|
||||
onArtworkCreated,
|
||||
onNotice,
|
||||
initUploadSession,
|
||||
uploadSingleFile,
|
||||
setArchiveSession,
|
||||
cancelUploadSession,
|
||||
])
|
||||
|
||||
// ── Cancel ─────────────────────────────────────────────────────────────────
|
||||
@@ -356,13 +487,18 @@ export default function useUploadMachine({
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
const archiveSessionId = archiveSessionRef.current.sessionId
|
||||
const archiveUploadToken = archiveSessionRef.current.uploadToken
|
||||
const additionalScreenshotSessions = additionalScreenshotSessionsRef.current
|
||||
|
||||
await Promise.allSettled([
|
||||
cancelUploadSession(sessionId, uploadToken),
|
||||
cancelUploadSession(archiveSessionId, archiveUploadToken),
|
||||
...additionalScreenshotSessions.map((item) => cancelUploadSession(item.sessionId, item.uploadToken)),
|
||||
])
|
||||
|
||||
clearArchiveSession()
|
||||
clearAdditionalScreenshotSessions()
|
||||
dispatchMachine({ type: 'CANCELLED' })
|
||||
onNotice?.({ type: 'warning', message: 'Upload cancelled.' })
|
||||
emitUploadEvent('upload_cancel', { session_id: machine.sessionId || null })
|
||||
@@ -372,7 +508,7 @@ export default function useUploadMachine({
|
||||
onNotice?.(notice)
|
||||
emitUploadEvent('upload_error', { stage: 'cancel', message: notice.message })
|
||||
}
|
||||
}, [machine, abortAllRequests, clearPolling, onNotice])
|
||||
}, [machine, abortAllRequests, clearPolling, onNotice, cancelUploadSession, clearArchiveSession, clearAdditionalScreenshotSessions])
|
||||
|
||||
// ── Publish ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
@@ -460,9 +596,11 @@ export default function useUploadMachine({
|
||||
const parsed = Number(initialDraftId)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||||
})()
|
||||
clearArchiveSession()
|
||||
clearAdditionalScreenshotSessions()
|
||||
publishLockRef.current = false
|
||||
dispatchMachine({ type: 'RESET_MACHINE' })
|
||||
}, [clearPolling, abortAllRequests, initialDraftId])
|
||||
}, [clearPolling, abortAllRequests, initialDraftId, clearArchiveSession, clearAdditionalScreenshotSessions])
|
||||
|
||||
// ── Retry ──────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user