Upload beautify
This commit is contained in:
130
resources/js/components/uploads/ScreenshotUploader.jsx
Normal file
130
resources/js/components/uploads/ScreenshotUploader.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
function toImageFiles(files) {
|
||||
return Array.from(files || []).filter((file) => String(file.type || '').startsWith('image/'))
|
||||
}
|
||||
|
||||
export default function ScreenshotUploader({
|
||||
files = [],
|
||||
onChange,
|
||||
min = 1,
|
||||
max = 5,
|
||||
required = false,
|
||||
error = '',
|
||||
}) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const previews = useMemo(
|
||||
() => files.map((file) => ({ file, url: window.URL.createObjectURL(file) })),
|
||||
[files]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previews.forEach((preview) => window.URL.revokeObjectURL(preview.url))
|
||||
}
|
||||
}, [previews])
|
||||
|
||||
const mergeFiles = (incomingFiles) => {
|
||||
const next = [...files, ...toImageFiles(incomingFiles)].slice(0, max)
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
const replaceFiles = (incomingFiles) => {
|
||||
const next = toImageFiles(incomingFiles).slice(0, max)
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAt = (index) => {
|
||||
const next = files.filter((_, idx) => idx !== index)
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
const move = (from, to) => {
|
||||
if (to < 0 || to >= files.length) return
|
||||
const next = [...files]
|
||||
const [picked] = next.splice(from, 1)
|
||||
next.splice(to, 0, picked)
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<label className="mb-2 block text-sm text-white/80">
|
||||
Archive screenshots {required ? <span className="text-rose-200">(required)</span> : null}
|
||||
</label>
|
||||
|
||||
<div
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
mergeFiles(event.dataTransfer?.files)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
className={`rounded-xl border-2 border-dashed p-3 text-center text-xs transition ${dragging ? 'border-sky-300 bg-sky-500/10' : 'border-white/20 bg-white/5'}`}
|
||||
>
|
||||
<p className="text-white/80">Drag & drop screenshots here</p>
|
||||
<p className="mt-1 text-white/55">Minimum {min}, maximum {max}</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
aria-label="Archive screenshots input"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="mt-3 block w-full text-xs text-white/80"
|
||||
onChange={(event) => replaceFiles(event.target.files)}
|
||||
/>
|
||||
|
||||
{error ? <p className="mt-2 text-xs text-rose-200">{error}</p> : null}
|
||||
|
||||
{previews.length > 0 ? (
|
||||
<ul className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{previews.map((preview, index) => (
|
||||
<li key={`${preview.file.name}-${index}`} className="rounded-lg border border-white/10 bg-black/20 p-2">
|
||||
<img src={preview.url} alt={`Screenshot ${index + 1}`} className="h-20 w-full rounded object-cover" />
|
||||
<div className="mt-2 truncate text-[11px] text-white/70">{preview.file.name}</div>
|
||||
<div className="mt-2 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
className="rounded border border-white/20 px-2 py-1 text-[11px] text-white disabled:opacity-40"
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === previews.length - 1}
|
||||
className="rounded border border-white/20 px-2 py-1 text-[11px] text-white disabled:opacity-40"
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(index)}
|
||||
className="rounded border border-rose-300/40 px-2 py-1 text-[11px] text-rose-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
640
resources/js/components/uploads/UploadWizard.jsx
Normal file
640
resources/js/components/uploads/UploadWizard.jsx
Normal file
@@ -0,0 +1,640 @@
|
||||
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 (
|
||||
<div className="mx-auto w-full max-w-4xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs md:text-sm">
|
||||
<span className={`rounded-full px-3 py-1 ${step >= STEP_PRELOAD ? 'bg-emerald-500/30 text-emerald-100' : 'bg-white/10 text-white/60'}`}>1. Preload</span>
|
||||
<span className={`rounded-full px-3 py-1 ${step >= STEP_DETAILS ? 'bg-emerald-500/30 text-emerald-100' : 'bg-white/10 text-white/60'}`}>2. Details</span>
|
||||
<span className={`rounded-full px-3 py-1 ${step >= STEP_PUBLISH ? 'bg-emerald-500/30 text-emerald-100' : 'bg-white/10 text-white/60'}`}>3. Publish</span>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-100">
|
||||
<div>{errorMessage}</div>
|
||||
<button type="button" onClick={retryLastAction} className="mt-2 rounded-lg border border-rose-300/40 px-2 py-1 text-xs hover:bg-rose-500/20">
|
||||
Retry last action
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-xl border border-emerald-400/30 bg-emerald-500/10 p-3 text-sm text-emerald-100">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{processingStatus && (
|
||||
<div className="mb-4 rounded-xl border border-sky-400/30 bg-sky-500/10 p-3 text-sm text-sky-100">
|
||||
<div className="font-medium">Processing status: {processingStepLabel(processingStatus.processing_state)}</div>
|
||||
<div className="mt-1 text-xs text-sky-100/80">
|
||||
status={processingStatus.status}; scanned={processingStatus.is_scanned ? 'yes' : 'no'}; preview={processingStatus.preview_ready ? 'ready' : 'pending'}; tags={processingStatus.has_tags ? 'ready' : 'pending'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{processingError && (
|
||||
<div className="mb-4 rounded-xl border border-amber-400/30 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||
{processingError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === STEP_PRELOAD && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
className={`rounded-2xl border-2 border-dashed p-6 text-center transition ${dragging ? 'border-sky-300 bg-sky-500/10' : 'border-white/20 bg-white/5'}`}
|
||||
>
|
||||
<p className="text-sm text-white/80">Drag & drop main file here</p>
|
||||
<p className="mt-1 text-xs text-white/55">or choose from your device</p>
|
||||
<input aria-label="Main upload file" type="file" className="mt-4 block w-full text-xs text-white/80" onChange={handleMainSelected} />
|
||||
{mainFile && <p className="mt-2 text-xs text-emerald-200">Main: {mainFile.name}</p>}
|
||||
</div>
|
||||
|
||||
{archiveMode && (
|
||||
<ScreenshotUploader
|
||||
files={screenshots}
|
||||
onChange={handleScreenshotsChanged}
|
||||
min={MIN_ARCHIVE_SCREENSHOTS}
|
||||
max={MAX_ARCHIVE_SCREENSHOTS}
|
||||
required
|
||||
error={screenshotValidationMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="mb-2 text-xs text-white/60">Upload progress</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div className="h-full bg-sky-400 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-white/60">{progress}%</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={runPreload}
|
||||
disabled={loadingPreload || !mainFile || (archiveMode && !archiveScreenshotsValid)}
|
||||
className="w-full rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loadingPreload ? 'Preloading…' : 'Start preload'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step >= STEP_DETAILS && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-white/80">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={details.title}
|
||||
onChange={(event) => setDetails((prev) => ({ ...prev, title: event.target.value }))}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
|
||||
placeholder="Name your artwork"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-white/80">Category</label>
|
||||
<select
|
||||
value={details.category_id}
|
||||
onChange={(event) => setDetails((prev) => ({ ...prev, category_id: event.target.value }))}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option value="">Select category</option>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-white/80">Tags</label>
|
||||
<TagInput
|
||||
value={details.tags}
|
||||
onChange={(value) => setDetails((prev) => ({ ...prev, tags: value }))}
|
||||
suggestedTags={suggestedTags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-white/80">Description</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={details.description}
|
||||
onChange={(event) => setDetails((prev) => ({ ...prev, description: event.target.value }))}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
|
||||
placeholder="Tell the story behind this upload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-white/80">License</label>
|
||||
<input
|
||||
type="text"
|
||||
value={details.license}
|
||||
onChange={(event) => setDetails((prev) => ({ ...prev, license: event.target.value }))}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
|
||||
placeholder="e.g. cc-by"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="mt-7 inline-flex items-center gap-2 text-sm text-white/80">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={details.nsfw}
|
||||
onChange={(event) => setDetails((prev) => ({ ...prev, nsfw: event.target.checked }))}
|
||||
/>
|
||||
NSFW
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-white/60">
|
||||
{loadingAutosave ? 'Autosaving…' : lastSavedAt ? `Last saved: ${lastSavedAt.toLocaleTimeString()}` : 'Autosave every 10s'}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={runAutosave}
|
||||
disabled={loadingAutosave || !uploadId}
|
||||
className="rounded-xl border border-white/20 px-3 py-2 text-sm text-white disabled:opacity-60"
|
||||
>
|
||||
Save now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(STEP_PUBLISH)}
|
||||
disabled={!uploadId}
|
||||
className="rounded-xl bg-sky-500 px-3 py-2 text-sm font-semibold text-black disabled:opacity-60"
|
||||
>
|
||||
Continue to publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step >= STEP_PUBLISH && (
|
||||
<div className="mt-6 space-y-4 rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<h3 className="text-base font-semibold text-white">Publish</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 text-sm text-white/80 md:grid-cols-2">
|
||||
<div><span className="text-white/50">Upload ID:</span> {uploadId || '—'}</div>
|
||||
<div><span className="text-white/50">Title:</span> {details.title || '—'}</div>
|
||||
<div className="md:col-span-2"><span className="text-white/50">URL preview:</span> /artwork/{urlPreviewSlug}</div>
|
||||
<div><span className="text-white/50">Category:</span> {details.category_id || '—'}</div>
|
||||
<div><span className="text-white/50">Tags:</span> {(details.tags || []).join(', ') || '—'}</div>
|
||||
<div className="md:col-span-2"><span className="text-white/50">Preview path:</span> {previewPath || 'Will be resolved by backend pipeline'}</div>
|
||||
{finalPath && <div className="md:col-span-2"><span className="text-white/50">Final path:</span> {finalPath}</div>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||
Final validation and file move happen on backend. This step only calls the publish endpoint.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(STEP_DETAILS)}
|
||||
className="rounded-xl border border-white/20 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
Back to details
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runPublish}
|
||||
disabled={loadingPublish || !uploadId}
|
||||
className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black disabled:opacity-60"
|
||||
>
|
||||
{loadingPublish ? 'Publishing…' : 'Publish now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
resources/js/components/uploads/UploadWizard.test.jsx
Normal file
113
resources/js/components/uploads/UploadWizard.test.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UploadWizard from './UploadWizard'
|
||||
|
||||
describe('UploadWizard Step 1 archive screenshot UX', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
window.URL.createObjectURL = vi.fn(() => 'blob:preview')
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
window.axios = {
|
||||
post: vi.fn(async (url) => {
|
||||
if (url === '/api/uploads/preload') {
|
||||
return {
|
||||
data: {
|
||||
upload_id: 'draft-1',
|
||||
preview_path: 'tmp/drafts/draft-1/preview.webp',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { data: {} }
|
||||
}),
|
||||
get: vi.fn(async () => ({
|
||||
data: {
|
||||
processing_state: 'ready',
|
||||
status: 'draft',
|
||||
is_scanned: true,
|
||||
preview_ready: true,
|
||||
has_tags: true,
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('blocks archive preload without screenshot', async () => {
|
||||
render(<UploadWizard contentTypes={[]} suggestedTags={[]} />)
|
||||
|
||||
const mainInput = screen.getByLabelText('Main upload file')
|
||||
const archiveFile = new File(['archive'], 'pack.zip', { type: 'application/zip' })
|
||||
|
||||
await userEvent.upload(mainInput, archiveFile)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Archive screenshots/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(screen.getByText('At least 1 screenshot is required for archives.')).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Start preload' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/preload', expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it('allows archive preload when screenshot exists and sends screenshots[]', async () => {
|
||||
render(<UploadWizard contentTypes={[]} suggestedTags={[]} />)
|
||||
|
||||
const mainInput = screen.getByLabelText('Main upload file')
|
||||
const archiveFile = new File(['archive'], 'pack.zip', { type: 'application/zip' })
|
||||
await userEvent.upload(mainInput, archiveFile)
|
||||
|
||||
const screenshotInput = await screen.findByLabelText('Archive screenshots input')
|
||||
const screenshotFile = new File(['image'], 'shot-1.png', { type: 'image/png' })
|
||||
await userEvent.upload(screenshotInput, screenshotFile)
|
||||
|
||||
const preloadButton = screen.getByRole('button', { name: 'Start preload' })
|
||||
await waitFor(() => {
|
||||
expect(preloadButton.hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
await userEvent.click(preloadButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith(
|
||||
'/api/uploads/preload',
|
||||
expect.any(FormData),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
const preloadCall = window.axios.post.mock.calls.find(([url]) => url === '/api/uploads/preload')
|
||||
const sentFormData = preloadCall?.[1]
|
||||
expect(sentFormData).toBeInstanceOf(FormData)
|
||||
expect(sentFormData.getAll('screenshots[]').length).toBe(1)
|
||||
})
|
||||
|
||||
it('bypasses screenshot uploader for image upload', async () => {
|
||||
render(<UploadWizard contentTypes={[]} suggestedTags={[]} />)
|
||||
|
||||
const mainInput = screen.getByLabelText('Main upload file')
|
||||
const imageFile = new File(['image'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
await userEvent.upload(mainInput, imageFile)
|
||||
|
||||
expect(screen.queryByText('Archive screenshots (required)')).toBeNull()
|
||||
|
||||
const preloadButton = screen.getByRole('button', { name: 'Start preload' })
|
||||
expect(preloadButton.hasAttribute('disabled')).toBe(false)
|
||||
|
||||
await userEvent.click(preloadButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith(
|
||||
'/api/uploads/preload',
|
||||
expect.any(FormData),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user