Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -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 ──────────────────────────────────────────────────────────────────
/**