import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import TagInput from '../tags/TagInput' import ScreenshotUploader from './ScreenshotUploader' const STEP_PRELOAD = 1 const STEP_DETAILS = 2 const STEP_PUBLISH = 3 const AUTOSAVE_INTERVAL_MS = 10_000 const STATUS_POLL_INTERVAL_MS = 3_000 const TERMINAL_PROCESSING_STEPS = new Set(['ready', 'rejected', 'published']) const MIN_ARCHIVE_SCREENSHOTS = 1 const MAX_ARCHIVE_SCREENSHOTS = 5 function processingStepLabel(state) { switch (state) { case 'pending_scan': return 'Pending scan' case 'scanning': return 'Scanning' case 'generating_preview': return 'Generating preview' case 'analyzing_tags': return 'Analyzing tags' case 'ready': return 'Ready' case 'rejected': return 'Rejected' case 'published': return 'Published' default: return 'Processing' } } function isArchiveFile(file) { if (!file) return false const mime = String(file.type || '').toLowerCase() if ( [ 'application/zip', 'application/x-zip-compressed', 'application/x-rar-compressed', 'application/x-tar', 'application/x-gzip', 'application/octet-stream', ].includes(mime) ) { return true } return /\.(zip|rar|7z|tar|gz)$/i.test(file.name || '') } function flattenCategories(contentTypes) { const result = [] for (const type of contentTypes || []) { const roots = Array.isArray(type.categories) ? type.categories : [] for (const root of roots) { result.push({ id: root.id, label: `${type.name} / ${root.name}`, }) const children = Array.isArray(root.children) ? root.children : [] for (const child of children) { result.push({ id: child.id, label: `${type.name} / ${root.name} / ${child.name}`, }) } } } return result } function detailsStorageKey(uploadId) { return `sb.upload.wizard.details.${uploadId}` } function makeClientSlug(title) { const base = String(title || '') .toLowerCase() .trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') return base || 'artwork' } export default function UploadWizard({ initialDraftId = null, contentTypes = [], suggestedTags = [], onPublished, }) { const [step, setStep] = useState(initialDraftId ? STEP_DETAILS : STEP_PRELOAD) const [uploadId, setUploadId] = useState(initialDraftId) const [mainFile, setMainFile] = useState(null) const [screenshots, setScreenshots] = useState([]) const [dragging, setDragging] = useState(false) const [progress, setProgress] = useState(0) const [details, setDetails] = useState({ title: '', category_id: '', tags: [], description: '', license: '', nsfw: false, }) const [autosaveDirty, setAutosaveDirty] = useState(false) const [lastSavedAt, setLastSavedAt] = useState(null) const [previewPath, setPreviewPath] = useState(null) const [finalPath, setFinalPath] = useState(null) const [loadingPreload, setLoadingPreload] = useState(false) const [loadingAutosave, setLoadingAutosave] = useState(false) const [loadingPublish, setLoadingPublish] = useState(false) const [processingStatus, setProcessingStatus] = useState(null) const [processingError, setProcessingError] = useState('') const [screenshotValidationMessage, setScreenshotValidationMessage] = useState('') const [errorMessage, setErrorMessage] = useState('') const [successMessage, setSuccessMessage] = useState('') const autosaveTimerRef = useRef(null) const statusPollTimerRef = useRef(null) const lastActionRef = useRef(null) const categoryOptions = useMemo(() => flattenCategories(contentTypes), [contentTypes]) const archiveMode = useMemo(() => isArchiveFile(mainFile), [mainFile]) const urlPreviewSlug = useMemo(() => makeClientSlug(details.title), [details.title]) const archiveScreenshotsValid = useMemo(() => { if (!archiveMode) return true return screenshots.length >= MIN_ARCHIVE_SCREENSHOTS && screenshots.length <= MAX_ARCHIVE_SCREENSHOTS }, [archiveMode, screenshots.length]) useEffect(() => { if (!uploadId) return const raw = window.localStorage.getItem(detailsStorageKey(uploadId)) if (!raw) return try { const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object') { setDetails((prev) => ({ ...prev, ...parsed })) } } catch { // ignore invalid local state } }, [uploadId]) useEffect(() => { if (!uploadId) return window.localStorage.setItem(detailsStorageKey(uploadId), JSON.stringify(details)) setAutosaveDirty(true) }, [details, uploadId]) useEffect(() => { if (uploadId) { window.localStorage.setItem('sb.upload.wizard.lastDraft', uploadId) } }, [uploadId]) useEffect(() => { if (initialDraftId || uploadId) return const lastDraft = window.localStorage.getItem('sb.upload.wizard.lastDraft') if (lastDraft) { setUploadId(lastDraft) setStep(STEP_DETAILS) setSuccessMessage('Resumed unfinished draft.') } }, [initialDraftId, uploadId]) const handleDrop = useCallback((event) => { event.preventDefault() setDragging(false) setErrorMessage('') const files = Array.from(event.dataTransfer?.files || []) if (!files.length) return const [first, ...rest] = files setMainFile(first) if (isArchiveFile(first)) { setScreenshots(rest.filter((file) => file.type?.startsWith('image/')).slice(0, MAX_ARCHIVE_SCREENSHOTS)) } else { setScreenshots([]) } }, []) const handleMainSelected = useCallback((event) => { const file = event.target.files?.[0] if (!file) return setErrorMessage('') setMainFile(file) if (!isArchiveFile(file)) { setScreenshots([]) setScreenshotValidationMessage('') } }, []) const handleScreenshotsChanged = useCallback((files) => { const next = Array.from(files || []).slice(0, MAX_ARCHIVE_SCREENSHOTS) setScreenshots(next) if (next.length >= MIN_ARCHIVE_SCREENSHOTS) { setScreenshotValidationMessage('') } }, []) useEffect(() => { if (!archiveMode) { setScreenshotValidationMessage('') return } if (screenshots.length === 0) { setScreenshotValidationMessage(`At least ${MIN_ARCHIVE_SCREENSHOTS} screenshot is required for archives.`) return } if (screenshots.length > MAX_ARCHIVE_SCREENSHOTS) { setScreenshotValidationMessage(`Maximum ${MAX_ARCHIVE_SCREENSHOTS} screenshots allowed.`) return } setScreenshotValidationMessage('') }, [archiveMode, screenshots.length]) const fetchProcessingStatus = useCallback(async () => { if (!uploadId) return try { const response = await window.axios.get(`/api/uploads/${uploadId}/status`) const payload = response.data || null setProcessingStatus(payload) setProcessingError('') } catch (error) { const message = error?.response?.data?.message || 'Status polling failed.' setProcessingError(message) } }, [uploadId]) const runPreload = useCallback(async () => { if (!mainFile) { setErrorMessage('Please select a main file first.') return } if (archiveMode && !archiveScreenshotsValid) { setScreenshotValidationMessage(`At least ${MIN_ARCHIVE_SCREENSHOTS} screenshot is required for archives.`) return } setLoadingPreload(true) setErrorMessage('') setSuccessMessage('') lastActionRef.current = 'preload' try { const formData = new FormData() formData.append('main', mainFile) if (archiveMode) { screenshots.slice(0, MAX_ARCHIVE_SCREENSHOTS).forEach((file) => formData.append('screenshots[]', file)) } const response = await window.axios.post('/api/uploads/preload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (event) => { const total = Number(event.total || 0) const loaded = Number(event.loaded || 0) if (total > 0) { setProgress(Math.round((loaded / total) * 100)) } }, }) const payload = response.data || {} setUploadId(payload.upload_id || null) setPreviewPath(payload.preview_path || null) setProcessingStatus(null) setProcessingError('') setStep(STEP_DETAILS) setSuccessMessage('Draft created. Fill in details next.') } catch (error) { const message = error?.response?.data?.message || error?.response?.data?.errors?.main?.[0] || 'Preload failed. Try again.' setErrorMessage(message) } finally { setLoadingPreload(false) } }, [archiveMode, archiveScreenshotsValid, mainFile, screenshots]) const runAutosave = useCallback(async () => { if (!uploadId || !autosaveDirty) return setLoadingAutosave(true) setErrorMessage('') lastActionRef.current = 'autosave' try { await window.axios.post(`/api/uploads/${uploadId}/autosave`, { title: details.title || null, category_id: details.category_id || null, tags: details.tags || [], description: details.description || null, license: details.license || null, nsfw: Boolean(details.nsfw), }) setAutosaveDirty(false) setLastSavedAt(new Date()) } catch (error) { const message = error?.response?.data?.message || 'Autosave failed. Your local draft is preserved.' setErrorMessage(message) } finally { setLoadingAutosave(false) } }, [autosaveDirty, details, uploadId]) useEffect(() => { if (step !== STEP_DETAILS || !uploadId) return autosaveTimerRef.current = window.setInterval(() => { runAutosave() }, AUTOSAVE_INTERVAL_MS) return () => { if (autosaveTimerRef.current) { window.clearInterval(autosaveTimerRef.current) autosaveTimerRef.current = null } } }, [runAutosave, step, uploadId]) useEffect(() => { if (!uploadId || step < STEP_DETAILS) return if (processingStatus?.processing_state && TERMINAL_PROCESSING_STEPS.has(processingStatus.processing_state)) { return } fetchProcessingStatus() statusPollTimerRef.current = window.setInterval(() => { fetchProcessingStatus() }, STATUS_POLL_INTERVAL_MS) return () => { if (statusPollTimerRef.current) { window.clearInterval(statusPollTimerRef.current) statusPollTimerRef.current = null } } }, [fetchProcessingStatus, processingStatus?.processing_state, step, uploadId]) const runPublish = useCallback(async () => { if (!uploadId) return setLoadingPublish(true) setErrorMessage('') setSuccessMessage('') lastActionRef.current = 'publish' try { if (autosaveDirty) { await runAutosave() } const response = await window.axios.post(`/api/uploads/${uploadId}/publish`) const payload = response.data || {} setFinalPath(payload.final_path || null) setProcessingStatus((prev) => ({ ...(prev || {}), id: uploadId, status: payload.status || 'published', is_scanned: true, preview_ready: true, has_tags: true, processing_state: 'published', })) setStep(STEP_PUBLISH) setSuccessMessage('Upload published successfully.') if (typeof onPublished === 'function') { onPublished(payload) } } catch (error) { const message = error?.response?.data?.message || 'Publish failed. Resolve issues and retry.' setErrorMessage(message) } finally { setLoadingPublish(false) } }, [autosaveDirty, onPublished, runAutosave, uploadId]) const retryLastAction = useCallback(() => { if (lastActionRef.current === 'preload') return runPreload() if (lastActionRef.current === 'autosave') return runAutosave() if (lastActionRef.current === 'publish') return runPublish() return undefined }, [runAutosave, runPreload, runPublish]) return (
Drag & drop main file here
or choose from your device
{mainFile &&Main: {mainFile.name}
}