Upload beautify
This commit is contained in:
94
resources/js/components/admin/AdminUploadQueue.jsx
Normal file
94
resources/js/components/admin/AdminUploadQueue.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export default function AdminUploadQueue() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notes, setNotes] = useState({})
|
||||
|
||||
const loadPending = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await window.axios.get('/api/admin/uploads/pending')
|
||||
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.message || 'Failed to load moderation queue.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPending()
|
||||
}, [])
|
||||
|
||||
const moderate = async (id, action) => {
|
||||
try {
|
||||
const payload = { note: String(notes[id] || '') }
|
||||
await window.axios.post(`/api/admin/uploads/${id}/${action}`, payload)
|
||||
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||
} catch (moderateError) {
|
||||
setError(moderateError?.response?.data?.message || `Failed to ${action} upload.`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Pending Upload Moderation</h2>
|
||||
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? <p role="status" className="text-sm text-white/70">Loading…</p> : null}
|
||||
{error ? <p role="alert" className="mb-3 text-sm text-rose-200">{error}</p> : null}
|
||||
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending uploads.</p> : null}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
|
||||
<div className="mt-1 text-xs text-white/65">{item.type} · {item.id}</div>
|
||||
{item.preview_path ? <div className="mt-1 text-xs text-white/55">Preview: {item.preview_path}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Moderation note for ${item.id}`}
|
||||
value={notes[item.id] || ''}
|
||||
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
|
||||
placeholder="Moderation note"
|
||||
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Approve upload ${item.id}`}
|
||||
onClick={() => moderate(item.id, 'approve')}
|
||||
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Reject upload ${item.id}`}
|
||||
onClick={() => moderate(item.id, 'reject')}
|
||||
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
112
resources/js/components/admin/AdminUploadQueue.test.jsx
Normal file
112
resources/js/components/admin/AdminUploadQueue.test.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AdminUploadQueue from './AdminUploadQueue'
|
||||
|
||||
function makePendingUpload(overrides = {}) {
|
||||
return {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
title: 'Neon Skyline',
|
||||
type: 'image',
|
||||
preview_path: 'tmp/drafts/1111/preview.webp',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AdminUploadQueue', () => {
|
||||
beforeEach(() => {
|
||||
window.axios = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders pending list with accessible controls', async () => {
|
||||
const upload = makePendingUpload()
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Pending Upload Moderation' })).not.toBeNull()
|
||||
|
||||
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
||||
expect(within(item).getByText('Neon Skyline')).not.toBeNull()
|
||||
expect(within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` })).not.toBeNull()
|
||||
expect(within(item).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
|
||||
expect(within(item).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('approves upload and removes it from queue', async () => {
|
||||
const upload = makePendingUpload()
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
||||
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
||||
await userEvent.click(within(item).getByRole('button', { name: `Approve upload ${upload.id}` }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listitem', { name: `Pending upload ${upload.id}` })).toBeNull()
|
||||
})
|
||||
|
||||
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/uploads/${upload.id}/approve`, { note: '' })
|
||||
})
|
||||
|
||||
it('rejects upload with note and removes it from queue', async () => {
|
||||
const upload = makePendingUpload({ id: '22222222-2222-2222-2222-222222222222', title: 'Retro Pack' })
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
||||
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
||||
|
||||
await userEvent.type(
|
||||
within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` }),
|
||||
'Needs better quality screenshots'
|
||||
)
|
||||
|
||||
await userEvent.click(within(item).getByRole('button', { name: `Reject upload ${upload.id}` }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listitem', { name: `Pending upload ${upload.id}` })).toBeNull()
|
||||
})
|
||||
|
||||
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/uploads/${upload.id}/reject`, {
|
||||
note: 'Needs better quality screenshots',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows API failure message and keeps item when moderation action fails', async () => {
|
||||
const upload = makePendingUpload({ id: '33333333-3333-3333-3333-333333333333' })
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
||||
window.axios.post.mockRejectedValueOnce({
|
||||
response: { data: { message: 'Moderation API failed.' } },
|
||||
})
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
||||
await userEvent.click(within(item).getByRole('button', { name: `Approve upload ${upload.id}` }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toContain('Moderation API failed.')
|
||||
expect(screen.getByRole('listitem', { name: `Pending upload ${upload.id}` })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows empty state when no pending uploads exist', async () => {
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [] } })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
const empty = await screen.findByText('No pending uploads.')
|
||||
expect(empty).not.toBeNull()
|
||||
expect(screen.queryAllByRole('listitem').length).toBe(0)
|
||||
})
|
||||
})
|
||||
498
resources/js/components/tags/TagInput.jsx
Normal file
498
resources/js/components/tags/TagInput.jsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
const DEFAULT_MAX_TAGS = 15
|
||||
const DEFAULT_MIN_LENGTH = 2
|
||||
const DEFAULT_MAX_LENGTH = 32
|
||||
const DEBOUNCE_MS = 300
|
||||
const MAX_SUGGESTIONS = 8
|
||||
|
||||
function normalizeTag(rawTag) {
|
||||
const raw = String(rawTag ?? '').trim().toLowerCase()
|
||||
if (!raw) return ''
|
||||
|
||||
const noSpaces = raw.replace(/\s+/g, '-')
|
||||
const cleaned = noSpaces.replace(/[^a-z0-9_-]/g, '')
|
||||
const compact = cleaned.replace(/-+/g, '-').replace(/_+/g, '_').replace(/^[-_]+|[-_]+$/g, '')
|
||||
|
||||
return compact.slice(0, DEFAULT_MAX_LENGTH)
|
||||
}
|
||||
|
||||
function parseTagList(input) {
|
||||
if (Array.isArray(input)) return input
|
||||
if (typeof input !== 'string') return []
|
||||
|
||||
return input
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function validateTag(tag, selectedTags, minLength, maxLength, maxTags) {
|
||||
if (selectedTags.length >= maxTags) return 'Max tags reached'
|
||||
if (tag.length < minLength) return 'Tag too short'
|
||||
if (tag.length > maxLength) return 'Tag too long'
|
||||
if (!/^[a-z0-9_-]+$/.test(tag)) return 'Invalid tag format'
|
||||
if (selectedTags.includes(tag)) return 'Duplicate tag'
|
||||
return null
|
||||
}
|
||||
|
||||
function toSuggestionItems(raw) {
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
return raw
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return { key: item, label: item, tag: item, usageCount: null, isAi: false }
|
||||
}
|
||||
|
||||
const tag = item?.slug || item?.name || item?.tag || ''
|
||||
if (!tag) return null
|
||||
|
||||
return {
|
||||
key: item?.id ?? tag,
|
||||
label: item?.name || item?.tag || item?.slug || tag,
|
||||
tag,
|
||||
usageCount: typeof item?.usage_count === 'number' ? item.usage_count : null,
|
||||
isAi: Boolean(item?.is_ai || item?.source === 'ai'),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function TagPillList({ tags, onRemove, disabled }) {
|
||||
return (
|
||||
<div className="min-h-[3rem] rounded-xl border border-white/10 bg-white/5 p-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.length === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-white/50">No tags selected</span>
|
||||
)}
|
||||
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="group inline-flex max-w-full items-center gap-2 rounded-full border border-white/20 bg-slate-900/80 px-3 py-1.5 text-xs text-slate-100 transition-all duration-150 ease-in-out"
|
||||
title={tag}
|
||||
>
|
||||
<span className="truncate">{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(tag)}
|
||||
disabled={disabled}
|
||||
className="rounded-full p-0.5 text-slate-300 transition-colors duration-150 ease-in-out hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove tag ${tag}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onFocus,
|
||||
disabled,
|
||||
expanded,
|
||||
listboxId,
|
||||
placeholder,
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
value={inputValue}
|
||||
onChange={(event) => onInputChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onFocus={onFocus}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white placeholder:text-white/45 focus:border-sky-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder={placeholder}
|
||||
aria-label="Tag input"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={listboxId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightedMatch({ label, query }) {
|
||||
if (!query) return <>{label}</>
|
||||
|
||||
const lowerLabel = label.toLowerCase()
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const start = lowerLabel.indexOf(lowerQuery)
|
||||
|
||||
if (start === -1) return <>{label}</>
|
||||
|
||||
const end = start + query.length
|
||||
const before = label.slice(0, start)
|
||||
const match = label.slice(start, end)
|
||||
const after = label.slice(end)
|
||||
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<span className="font-semibold text-sky-300">{match}</span>
|
||||
{after}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestionDropdown({
|
||||
isOpen,
|
||||
loading,
|
||||
error,
|
||||
suggestions,
|
||||
highlightedIndex,
|
||||
onSelect,
|
||||
query,
|
||||
listboxId,
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-slate-950/95">
|
||||
<ul id={listboxId} role="listbox" className="max-h-56 overflow-auto py-1">
|
||||
{loading && (
|
||||
<li className="px-3 py-2 text-xs text-white/60">Searching tags…</li>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<li className="px-3 py-2 text-xs text-amber-200">Tag search unavailable</li>
|
||||
)}
|
||||
|
||||
{!loading && !error && suggestions.length === 0 && (
|
||||
<li className="px-3 py-2 text-xs text-white/50">No suggestions</li>
|
||||
)}
|
||||
|
||||
{!loading && !error && suggestions.map((item, index) => {
|
||||
const active = highlightedIndex === index
|
||||
return (
|
||||
<li
|
||||
key={item.key}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm transition-colors duration-150 ease-in-out ${active ? 'bg-sky-500/20 text-white' : 'text-white/85 hover:bg-white/10'}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onSelect(item.tag)
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="truncate"><HighlightedMatch label={item.label} query={query} /></span>
|
||||
{item.isAi && (
|
||||
<span className="ml-2 rounded-full border border-purple-400/40 bg-purple-400/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-purple-200">
|
||||
AI
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof item.usageCount === 'number' && (
|
||||
<span className="shrink-0 text-[11px] text-white/50">{item.usageCount}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestedTagsPanel({ items, selectedTags, onAdd, disabled }) {
|
||||
const filtered = useMemo(
|
||||
() => items.filter((item) => !selectedTags.includes(normalizeTag(item.tag))),
|
||||
[items, selectedTags]
|
||||
)
|
||||
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-purple-400/20 bg-purple-500/5 p-3">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-purple-200">AI Suggested tags</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filtered.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => onAdd(item.tag)}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-purple-300/40 bg-purple-400/10 px-3 py-1.5 text-xs text-purple-100 transition-all duration-150 ease-in-out hover:bg-purple-400/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{item.label}
|
||||
<span className="text-[11px]">+</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusHints({ error, count, maxTags }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className={error ? 'text-amber-200' : 'text-white/55'} role="status" aria-live="polite">
|
||||
{error || 'Type and press Enter, comma, or Tab to add'}
|
||||
</span>
|
||||
<span className="text-white/50">{count}/{maxTags}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
suggestedTags = [],
|
||||
disabled = false,
|
||||
maxTags = DEFAULT_MAX_TAGS,
|
||||
minLength = DEFAULT_MIN_LENGTH,
|
||||
maxLength = DEFAULT_MAX_LENGTH,
|
||||
placeholder = 'Type tags…',
|
||||
searchEndpoint = '/api/tags/search',
|
||||
popularEndpoint = '/api/tags/popular',
|
||||
}) {
|
||||
const selectedTags = useMemo(() => parseTagList(value).map(normalizeTag).filter(Boolean), [value])
|
||||
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [searchError, setSearchError] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
|
||||
const queryCacheRef = useRef(new Map())
|
||||
const abortControllerRef = useRef(null)
|
||||
const debounceTimerRef = useRef(null)
|
||||
|
||||
const listboxId = useMemo(() => `tag-input-listbox-${Math.random().toString(16).slice(2)}`, [])
|
||||
const aiSuggestedItems = useMemo(() => toSuggestionItems(suggestedTags), [suggestedTags])
|
||||
|
||||
const updateTags = useCallback((nextTags) => {
|
||||
const unique = Array.from(new Set(nextTags.map(normalizeTag).filter(Boolean)))
|
||||
onChange(unique)
|
||||
}, [onChange])
|
||||
|
||||
const addTag = useCallback((rawTag) => {
|
||||
const normalized = normalizeTag(rawTag)
|
||||
const validation = validateTag(normalized, selectedTags, minLength, maxLength, maxTags)
|
||||
if (validation) {
|
||||
setError(validation)
|
||||
return false
|
||||
}
|
||||
|
||||
setError('')
|
||||
updateTags([...selectedTags, normalized])
|
||||
setInputValue('')
|
||||
setIsOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
return true
|
||||
}, [selectedTags, minLength, maxLength, maxTags, updateTags])
|
||||
|
||||
const removeTag = useCallback((tagToRemove) => {
|
||||
setError('')
|
||||
updateTags(selectedTags.filter((tag) => tag !== tagToRemove))
|
||||
}, [selectedTags, updateTags])
|
||||
|
||||
const applyPastedTags = useCallback((rawText) => {
|
||||
const parts = parseTagList(rawText)
|
||||
if (parts.length === 0) return
|
||||
|
||||
let next = [...selectedTags]
|
||||
for (const part of parts) {
|
||||
const normalized = normalizeTag(part)
|
||||
const validation = validateTag(normalized, next, minLength, maxLength, maxTags)
|
||||
if (!validation) {
|
||||
next.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
next = Array.from(new Set(next))
|
||||
updateTags(next)
|
||||
setInputValue('')
|
||||
setError('')
|
||||
}, [selectedTags, minLength, maxLength, maxTags, updateTags])
|
||||
|
||||
const runSearch = useCallback(async (query) => {
|
||||
const normalizedQuery = normalizeTag(query)
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
const cacheKey = normalizedQuery || '__popular__'
|
||||
if (queryCacheRef.current.has(cacheKey)) {
|
||||
const cached = queryCacheRef.current.get(cacheKey)
|
||||
setSuggestions(cached)
|
||||
setSearchError(false)
|
||||
setIsOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
setLoading(true)
|
||||
setSearchError(false)
|
||||
|
||||
try {
|
||||
const url = normalizedQuery
|
||||
? `${searchEndpoint}?q=${encodeURIComponent(normalizedQuery)}`
|
||||
: popularEndpoint
|
||||
|
||||
const res = await window.axios.get(url, { signal: controller.signal })
|
||||
const items = toSuggestionItems(res?.data?.data || [])
|
||||
.filter((item) => !selectedTags.includes(normalizeTag(item.tag)))
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
|
||||
queryCacheRef.current.set(cacheKey, items)
|
||||
setSuggestions(items)
|
||||
setHighlightedIndex(items.length > 0 ? 0 : -1)
|
||||
setIsOpen(true)
|
||||
} catch (requestError) {
|
||||
if (controller.signal.aborted) return
|
||||
setSuggestions([])
|
||||
setSearchError(true)
|
||||
setIsOpen(true)
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [searchEndpoint, popularEndpoint, selectedTags])
|
||||
|
||||
useEffect(() => {
|
||||
const query = inputValue.trim()
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
window.clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
runSearch(query)
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
window.clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [inputValue, runSearch])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInputKeyDown = useCallback((event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && inputValue.length === 0 && selectedTags.length > 0) {
|
||||
removeTag(selectedTags[selectedTags.length - 1])
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
if (!isOpen || suggestions.length === 0) return
|
||||
setHighlightedIndex((prev) => {
|
||||
if (prev < 0) return 0
|
||||
return Math.min(prev + 1, suggestions.length - 1)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!isOpen || suggestions.length === 0) return
|
||||
setHighlightedIndex((prev) => {
|
||||
if (prev <= 0) return 0
|
||||
return prev - 1
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const shouldCommit = event.key === 'Enter' || event.key === ',' || event.key === 'Tab'
|
||||
if (!shouldCommit) return
|
||||
|
||||
if (event.key === 'Tab' && !isOpen && inputValue.trim() === '') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if ((event.key === 'Enter' || event.key === 'Tab') && isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
|
||||
addTag(suggestions[highlightedIndex].tag)
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = inputValue.trim().replace(/,$/, '')
|
||||
if (!candidate) return
|
||||
addTag(candidate)
|
||||
}, [inputValue, selectedTags, removeTag, isOpen, suggestions, highlightedIndex, addTag])
|
||||
|
||||
const handlePaste = useCallback((event) => {
|
||||
const raw = event.clipboardData?.getData('text')
|
||||
if (!raw || !raw.includes(',')) return
|
||||
|
||||
event.preventDefault()
|
||||
applyPastedTags(raw)
|
||||
}, [applyPastedTags])
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (inputValue.trim() !== '') return
|
||||
runSearch('')
|
||||
}, [inputValue, runSearch])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3" data-testid="tag-input-root">
|
||||
<TagPillList tags={selectedTags} onRemove={removeTag} disabled={disabled} />
|
||||
|
||||
<SearchInput
|
||||
inputValue={inputValue}
|
||||
onInputChange={(next) => {
|
||||
setInputValue(next)
|
||||
setError('')
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={handleFocus}
|
||||
disabled={disabled}
|
||||
expanded={isOpen}
|
||||
listboxId={listboxId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<SuggestionDropdown
|
||||
isOpen={isOpen}
|
||||
loading={loading}
|
||||
error={searchError}
|
||||
suggestions={suggestions}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onSelect={addTag}
|
||||
query={inputValue.trim()}
|
||||
listboxId={listboxId}
|
||||
/>
|
||||
|
||||
<SuggestedTagsPanel
|
||||
items={aiSuggestedItems}
|
||||
selectedTags={selectedTags}
|
||||
onAdd={addTag}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<StatusHints error={error} count={selectedTags.length} maxTags={maxTags} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
resources/js/components/tags/TagInput.test.jsx
Normal file
113
resources/js/components/tags/TagInput.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 TagInput from './TagInput'
|
||||
|
||||
function Harness({ initial = [] }) {
|
||||
const [tags, setTags] = React.useState(initial)
|
||||
|
||||
return (
|
||||
<TagInput
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
suggestedTags={['sunset', 'city']}
|
||||
searchEndpoint="/api/tags/search"
|
||||
popularEndpoint="/api/tags/popular"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('TagInput', () => {
|
||||
beforeEach(() => {
|
||||
window.axios = {
|
||||
get: vi.fn(async (url) => {
|
||||
if (url.startsWith('/api/tags/search')) {
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 },
|
||||
{ id: 2, name: 'city', slug: 'city', usage_count: 30 },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
{ id: 3, name: 'popular', slug: 'popular', usage_count: 99 },
|
||||
],
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('adds and removes tags', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
const input = screen.getByLabelText('Tag input')
|
||||
await userEvent.type(input, 'night,')
|
||||
|
||||
expect(screen.getByText('night')).not.toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Remove tag night' }))
|
||||
expect(screen.queryByText('night')).toBeNull()
|
||||
})
|
||||
|
||||
it('supports keyboard suggestion accept with tab', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
const input = screen.getByLabelText('Tag input')
|
||||
await userEvent.type(input, 'city')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('option').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
await userEvent.keyboard('{Tab}')
|
||||
expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('supports comma-separated paste', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
const input = screen.getByLabelText('Tag input')
|
||||
await userEvent.click(input)
|
||||
await userEvent.paste('art, city, night')
|
||||
|
||||
expect(screen.getByText('art')).not.toBeNull()
|
||||
expect(screen.getByText('city')).not.toBeNull()
|
||||
expect(screen.getByText('night')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('handles API failure gracefully', async () => {
|
||||
window.axios.get = vi.fn(async () => {
|
||||
throw new Error('network')
|
||||
})
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
const input = screen.getByLabelText('Tag input')
|
||||
await userEvent.type(input, 'cyber')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tag search unavailable')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces max tags limit', async () => {
|
||||
render(<Harness initial={['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'a10', 'a11', 'a12', 'a13', 'a14', 'a15']} />)
|
||||
|
||||
const input = screen.getByLabelText('Tag input')
|
||||
await userEvent.type(input, 'overflow{enter}')
|
||||
|
||||
expect(screen.getByText('Max tags reached')).not.toBeNull()
|
||||
expect(screen.queryByText('overflow')).toBeNull()
|
||||
})
|
||||
})
|
||||
156
resources/js/components/upload/ScreenshotUploader.jsx
Normal file
156
resources/js/components/upload/ScreenshotUploader.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
export default function ScreenshotUploader({
|
||||
title = 'Archive screenshots',
|
||||
description = 'Screenshot requirement placeholder for archive uploads',
|
||||
visible = false,
|
||||
files = [],
|
||||
perFileErrors = [],
|
||||
errors = [],
|
||||
invalid = false,
|
||||
showLooksGood = false,
|
||||
looksGoodText = 'Looks good',
|
||||
onFilesChange,
|
||||
min = 1,
|
||||
max = 5,
|
||||
}) {
|
||||
const inputRef = useRef(null)
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const quickTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
|
||||
const previewItems = useMemo(() => files.map((file) => ({
|
||||
file,
|
||||
url: URL.createObjectURL(file),
|
||||
})), [files])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previewItems.forEach((item) => URL.revokeObjectURL(item.url))
|
||||
}
|
||||
}, [previewItems])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const emitFiles = (fileList, merge = false) => {
|
||||
const incoming = Array.from(fileList || [])
|
||||
const next = merge ? [...files, ...incoming] : incoming
|
||||
if (typeof onFilesChange === 'function') {
|
||||
onFilesChange(next.slice(0, max))
|
||||
}
|
||||
}
|
||||
|
||||
const removeAt = (index) => {
|
||||
const next = files.filter((_, idx) => idx !== index)
|
||||
if (typeof onFilesChange === 'function') {
|
||||
onFilesChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_28px_rgba(0,0,0,0.3)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/40' : 'border-amber-300/25 from-amber-500/10 to-slate-900/40'}`}>
|
||||
{/* Intended props: screenshots, minResolution, maxFileSizeMb, required, onChange, onRemove, error */}
|
||||
<div className={`rounded-lg border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-amber-300/30 bg-black/20'}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-lg font-semibold text-amber-100">{title} <span className="text-red-200">(Required)</span></h3>
|
||||
<span className="rounded-full border border-amber-200/35 bg-amber-500/15 px-2.5 py-1 text-xs text-amber-100">{Math.min(files.length, max)}/{max} screenshots</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-amber-100/85">{description}</p>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-amber-200/20 bg-amber-500/10 px-3 py-3 text-xs text-amber-50/90">
|
||||
<p className="font-semibold">Why we need screenshots</p>
|
||||
<p className="mt-1">Screenshots provide a visual thumbnail and help AI analysis/moderation before archive contents are published.</p>
|
||||
<p className="mt-2 text-amber-100/85">Rules: JPG/PNG/WEBP · 1280×720 minimum · 10MB max each · {min} to {max} files.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-3 rounded-lg border-2 border-dashed p-4 text-center transition-colors ${invalid ? 'border-red-300/45 bg-red-500/10' : 'border-white/20 bg-white/5 hover:border-amber-300/45 hover:bg-amber-500/5'}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
emitFiles(event.dataTransfer?.files, true)
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-white/85">Drop screenshots here or click to browse</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 rounded-md border border-white/20 bg-white/10 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
Browse screenshots
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
aria-label="Screenshot file input"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
|
||||
onChange={(event) => emitFiles(event.target.files, true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-white/70">
|
||||
{files.length} selected · minimum {min}, maximum {max}
|
||||
</div>
|
||||
|
||||
{showLooksGood && (
|
||||
<div className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span>{looksGoodText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewItems.length > 0 && (
|
||||
<ul className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{previewItems.map((item, index) => (
|
||||
<motion.li
|
||||
layout={!prefersReducedMotion}
|
||||
key={`${item.file.name}-${index}`}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.96 }}
|
||||
animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }}
|
||||
transition={quickTransition}
|
||||
className="rounded-lg border border-white/50 bg-white/5 p-2 text-xs"
|
||||
>
|
||||
<div className="overflow-hidden rounded-md border border-white/50 bg-black/25">
|
||||
<img src={item.url} alt={`Screenshot ${index + 1}`} className="h-24 w-full object-cover" />
|
||||
</div>
|
||||
<div className="mt-2 truncate text-white/90">{item.file.name}</div>
|
||||
<div className="mt-1 text-white/55">{Math.round(item.file.size / 1024)} KB</div>
|
||||
{perFileErrors[index] && <div className="mt-1 rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1 text-red-200">{perFileErrors[index]}</div>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(index)}
|
||||
className="mt-2 rounded-md border border-white/20 bg-white/5 px-2.5 py-1 text-[11px] text-white/80 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<ul className="mt-3 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
|
||||
{errors.map((error, index) => (
|
||||
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{invalid && (
|
||||
<p className="mt-3 text-xs text-red-200">Continue is blocked until screenshot requirements are valid.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
152
resources/js/components/upload/UploadActions.jsx
Normal file
152
resources/js/components/upload/UploadActions.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export default function UploadActions({
|
||||
step = 1,
|
||||
canStart = false,
|
||||
canContinue = false,
|
||||
canPublish = false,
|
||||
canGoBack = false,
|
||||
canReset = true,
|
||||
canCancel = false,
|
||||
canRetry = false,
|
||||
isUploading = false,
|
||||
isProcessing = false,
|
||||
isPublishing = false,
|
||||
isCancelling = false,
|
||||
disableReason = 'Complete required fields',
|
||||
onStart,
|
||||
onContinue,
|
||||
onPublish,
|
||||
onBack,
|
||||
onCancel,
|
||||
onReset,
|
||||
onRetry,
|
||||
onSaveDraft,
|
||||
showSaveDraft = false,
|
||||
mobileSticky = true,
|
||||
resetLabel = 'Reset',
|
||||
}) {
|
||||
const [confirmCancel, setConfirmCancel] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmCancel) return
|
||||
const timer = window.setTimeout(() => setConfirmCancel(false), 3200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [confirmCancel])
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!canCancel || isCancelling) return
|
||||
if (!confirmCancel) {
|
||||
setConfirmCancel(true)
|
||||
return
|
||||
}
|
||||
setConfirmCancel(false)
|
||||
onCancel?.()
|
||||
}
|
||||
|
||||
const renderPrimary = () => {
|
||||
if (step === 1) {
|
||||
const disabled = !canStart || isUploading || isProcessing || isCancelling
|
||||
const label = isUploading ? 'Uploading…' : isProcessing ? 'Processing…' : 'Start upload'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabled ? disableReason : 'Start upload'}
|
||||
onClick={() => onStart?.()}
|
||||
className={`rounded-lg px-5 py-2.5 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/75 ${disabled ? 'cursor-not-allowed bg-emerald-700/55 text-white/75' : 'bg-emerald-500 text-white hover:bg-emerald-400 shadow-[0_10px_28px_rgba(16,185,129,0.32)] ring-1 ring-emerald-200/40'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
const disabled = !canContinue
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabled ? disableReason : 'Continue to Publish'}
|
||||
onClick={() => onContinue?.()}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 ${disabled ? 'cursor-not-allowed bg-sky-700/45 text-sky-100/75' : 'bg-sky-400 text-slate-950 hover:bg-sky-300 shadow-[0_10px_28px_rgba(56,189,248,0.28)] ring-1 ring-sky-100/45'}`}
|
||||
>
|
||||
Continue to Publish
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const disabled = !canPublish || isPublishing
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabled ? disableReason : 'Publish artwork'}
|
||||
onClick={() => onPublish?.()}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/75 ${disabled ? 'cursor-not-allowed bg-emerald-700/45 text-emerald-100/75' : 'bg-emerald-400 text-slate-950 shadow-[0_0_0_1px_rgba(167,243,208,0.85),0_0_24px_rgba(52,211,153,0.45)] hover:bg-emerald-300'}`}
|
||||
>
|
||||
{isPublishing ? 'Publishing…' : 'Publish'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20' : ''} rounded-xl border border-white/10 bg-slate-950/80 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur sm:p-4 lg:static lg:shadow-none`}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2.5">
|
||||
{canGoBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBack?.()}
|
||||
className="rounded-lg border border-white/30 bg-white/10 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSaveDraft && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSaveDraft?.()}
|
||||
className="rounded-lg border border-purple-300/45 bg-purple-500/20 px-3.5 py-2 text-sm font-medium text-purple-50 transition hover:bg-purple-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-300/70"
|
||||
>
|
||||
Save draft
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === 1 && canCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
title={confirmCancel ? 'Click again to confirm cancel' : 'Cancel current upload'}
|
||||
className="rounded-lg border border-amber-300/45 bg-amber-500/20 px-3.5 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/75 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isCancelling ? 'Cancelling…' : confirmCancel ? 'Cancel upload?' : 'Cancel'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry?.()}
|
||||
className="rounded-lg border border-amber-300/45 bg-amber-500/20 px-3.5 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/75"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canReset && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReset?.()}
|
||||
className="rounded-lg border border-white/30 bg-white/10 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||||
>
|
||||
{resetLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{renderPrimary()}
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
199
resources/js/components/upload/UploadDropzone.jsx
Normal file
199
resources/js/components/upload/UploadDropzone.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
function getExtension(fileName = '') {
|
||||
const parts = String(fileName).toLowerCase().split('.')
|
||||
return parts.length > 1 ? parts.pop() : ''
|
||||
}
|
||||
|
||||
function detectPrimaryType(file) {
|
||||
if (!file) return 'unknown'
|
||||
|
||||
const extension = getExtension(file.name)
|
||||
const mime = String(file.type || '').toLowerCase()
|
||||
|
||||
const imageExt = new Set(['jpg', 'jpeg', 'png', 'webp'])
|
||||
const archiveExt = new Set(['zip', 'rar', '7z', 'tar', 'gz'])
|
||||
|
||||
const imageMime = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
const archiveMime = new Set([
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-rar-compressed',
|
||||
'application/vnd.rar',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip',
|
||||
'application/x-gzip',
|
||||
'application/octet-stream',
|
||||
])
|
||||
|
||||
if (imageMime.has(mime) || imageExt.has(extension)) return 'image'
|
||||
if (archiveMime.has(mime) || archiveExt.has(extension)) return 'archive'
|
||||
return 'unsupported'
|
||||
}
|
||||
|
||||
export default function UploadDropzone({
|
||||
title = 'Upload file',
|
||||
description = 'Drop file here or click to browse',
|
||||
fileName = '',
|
||||
fileHint = 'No file selected yet',
|
||||
previewUrl = '',
|
||||
fileMeta = null,
|
||||
errors = [],
|
||||
invalid = false,
|
||||
showLooksGood = false,
|
||||
looksGoodText = 'Looks good',
|
||||
locked = false,
|
||||
onPrimaryFileChange,
|
||||
onValidationResult,
|
||||
}) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const dragTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
|
||||
const emitFile = (file) => {
|
||||
const detectedType = detectPrimaryType(file)
|
||||
if (typeof onPrimaryFileChange === 'function') {
|
||||
onPrimaryFileChange(file, { detectedType })
|
||||
}
|
||||
if (typeof onValidationResult === 'function') {
|
||||
onValidationResult({ file, detectedType })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`rounded-xl border bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/50'}`}>
|
||||
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
|
||||
<motion.div
|
||||
data-testid="upload-dropzone"
|
||||
role="button"
|
||||
aria-disabled={locked ? 'true' : 'false'}
|
||||
tabIndex={locked ? -1 : 0}
|
||||
onClick={() => {
|
||||
if (locked) return
|
||||
inputRef.current?.click()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (locked) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (locked) return
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
if (locked) return
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
const droppedFile = event.dataTransfer?.files?.[0]
|
||||
if (droppedFile) emitFile(droppedFile)
|
||||
}}
|
||||
animate={prefersReducedMotion ? undefined : {
|
||||
scale: dragging ? 1.01 : 1,
|
||||
borderColor: invalid ? 'rgba(252,165,165,0.7)' : dragging ? 'rgba(103,232,249,0.9)' : 'rgba(56,189,248,0.35)',
|
||||
backgroundColor: invalid ? 'rgba(23,68,68,0.10)' : dragging ? 'rgba(6,182,212,0.20)' : 'rgba(14,165,233,0.05)',
|
||||
}}
|
||||
transition={dragTransition}
|
||||
className={`group rounded-xl border-2 border-dashed border-white/50 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<div className="mt-2 grid place-items-center">
|
||||
<div className="relative w-full max-w-[520px]">
|
||||
<img src={previewUrl} alt="Selected preview" className="mx-auto max-h-64 w-auto rounded-lg object-contain" />
|
||||
<div className="pointer-events-none absolute bottom-2 right-2 rounded-full bg-black/40 px-2 py-1 text-xs text-white/90">Click to replace</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full border border-sky-400/60 bg-sky-500/12 text-sky-100 shadow-sm">
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
|
||||
<path d="M7 10l5-5 5 5" />
|
||||
<path d="M12 5v10" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
|
||||
<p className="mt-1 text-xs text-soft">{description}</p>
|
||||
<p className="mt-1 text-xs text-soft">Accepted: JPG, JPEG, PNG, WEBP, ZIP, RAR, 7Z, TAR, GZ</p>
|
||||
<p className="text-xs text-soft">Max size: images 50MB · archives 200MB</p>
|
||||
|
||||
<span className={`btn-secondary mt-3 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
|
||||
Click to browse files
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
aria-label="Upload file input"
|
||||
disabled={locked}
|
||||
accept=".jpg,.jpeg,.png,.webp,.zip,.rar,.7z,.tar,.gz,image/jpeg,image/png,image/webp"
|
||||
onChange={(event) => {
|
||||
const selectedFile = event.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
emitFile(selectedFile)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
|
||||
<div className="mt-3 rounded-lg border border-white/50 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
|
||||
<div className="font-medium text-white/85">Selected file</div>
|
||||
<div className="mt-1 truncate">{fileName || fileHint}</div>
|
||||
{fileMeta && (
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-white/60">
|
||||
<span>Type: <span className="text-white/80">{fileMeta.type || '—'}</span></span>
|
||||
<span>·</span>
|
||||
<span>Size: <span className="text-white/80">{fileMeta.size || '—'}</span></span>
|
||||
<span>·</span>
|
||||
<span>Resolution: <span className="text-white/80">{fileMeta.resolution || '—'}</span></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLooksGood && (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span>{looksGoodText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{errors.length > 0 && (
|
||||
<motion.div
|
||||
key="dropzone-errors"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
||||
transition={dragTransition}
|
||||
className="mt-4 rounded-lg border border-red-300/40 bg-red-500/10 p-3 text-left"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-red-100">Please fix the following</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
|
||||
{errors.map((error, index) => (
|
||||
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
76
resources/js/components/upload/UploadPreview.jsx
Normal file
76
resources/js/components/upload/UploadPreview.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function UploadPreview({
|
||||
title = 'Preview',
|
||||
description = 'Live artwork preview placeholder',
|
||||
previewUrl = '',
|
||||
isArchive = false,
|
||||
metadata = {
|
||||
resolution: '—',
|
||||
size: '—',
|
||||
type: '—',
|
||||
},
|
||||
warnings = [],
|
||||
errors = [],
|
||||
invalid = false,
|
||||
}) {
|
||||
return (
|
||||
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/45'}`}>
|
||||
{/* Intended props: file, previewUrl, isArchive, dimensions, fileSize, format, warning */}
|
||||
<div className={`rounded-xl border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-white/50 bg-black/25'}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<span className="rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-[11px] text-white/65">
|
||||
{isArchive ? 'Archive' : 'Image'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-white/65">{description}</p>
|
||||
|
||||
<div className="mt-4 flex flex-col md:flex-row gap-4 items-start">
|
||||
<div className="w-40 h-40 rounded-lg overflow-hidden bg-black/40 ring-1 ring-white/10 flex items-center justify-center">
|
||||
{previewUrl && !isArchive ? (
|
||||
<img src={previewUrl} alt="Upload preview" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-sm text-soft">{isArchive ? 'Archive selected' : 'Image preview placeholder'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-soft">Type</span>
|
||||
<span className="text-white ml-2">{metadata.type}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-soft">Size</span>
|
||||
<span className="text-white ml-2">{metadata.size}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-soft">Resolution</span>
|
||||
<span className="text-white ml-2">{metadata.resolution}</span>
|
||||
</div>
|
||||
{errors.length > 0 && (
|
||||
<ul className="space-y-1" role="status" aria-live="polite">
|
||||
{errors.map((error, index) => (
|
||||
<li key={`${error}-${index}`} className="text-red-400 text-xs">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<ul className="mt-4 space-y-1 text-xs text-amber-100" role="status" aria-live="polite">
|
||||
{warnings.map((warning, index) => (
|
||||
<li key={`${warning}-${index}`} className="rounded-md border border-amber-300/35 bg-amber-500/10 px-2 py-1">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
152
resources/js/components/upload/UploadProgress.jsx
Normal file
152
resources/js/components/upload/UploadProgress.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react'
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
export default function UploadProgress({
|
||||
title = 'Upload Artwork',
|
||||
description = 'Preload → Details → Publish',
|
||||
progress = 24,
|
||||
status = 'Idle',
|
||||
state,
|
||||
processingStatus,
|
||||
processingLabel = '',
|
||||
isCancelling = false,
|
||||
error = '',
|
||||
onRetry,
|
||||
onReset,
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const getRecoveryHint = () => {
|
||||
const text = String(error || '').toLowerCase()
|
||||
if (!text) return ''
|
||||
if (text.includes('network') || text.includes('timeout') || text.includes('failed to fetch')) {
|
||||
return 'Your connection may be unstable. Retry now or wait a moment and try again.'
|
||||
}
|
||||
if (text.includes('busy') || text.includes('unavailable') || text.includes('503') || text.includes('server')) {
|
||||
return 'The server looks busy right now. Waiting 20–30 seconds before retrying can help.'
|
||||
}
|
||||
if (text.includes('validation') || text.includes('invalid') || text.includes('too large') || text.includes('format')) {
|
||||
return 'Please review the file requirements, then update your selection and try again.'
|
||||
}
|
||||
return 'You can retry now, or reset this upload and start again with the same files.'
|
||||
}
|
||||
|
||||
const recoveryHint = getRecoveryHint()
|
||||
|
||||
const resolvedStatus = (() => {
|
||||
if (isCancelling) return 'Processing'
|
||||
if (state === 'error') return 'Error'
|
||||
if (processingStatus === 'ready') return 'Ready'
|
||||
if (state === 'uploading') return 'Uploading'
|
||||
if (state === 'processing' || state === 'finishing' || state === 'publishing') return 'Processing'
|
||||
if (status) return status
|
||||
return 'Idle'
|
||||
})()
|
||||
|
||||
const statusTheme = {
|
||||
Idle: 'border-slate-400/35 bg-slate-400/15 text-slate-200',
|
||||
Uploading: 'border-sky-400/35 bg-sky-400/15 text-sky-100',
|
||||
Processing: 'border-amber-400/35 bg-amber-400/15 text-amber-100',
|
||||
Ready: 'border-emerald-400/35 bg-emerald-400/15 text-emerald-100',
|
||||
Error: 'border-red-400/35 bg-red-400/15 text-red-100',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
Idle: { borderColor: 'rgba(148,163,184,0.35)', backgroundColor: 'rgba(148,163,184,0.15)', color: 'rgb(226,232,240)' },
|
||||
Uploading: { borderColor: 'rgba(56,189,248,0.35)', backgroundColor: 'rgba(56,189,248,0.15)', color: 'rgb(224,242,254)' },
|
||||
Processing: { borderColor: 'rgba(251,191,36,0.35)', backgroundColor: 'rgba(251,191,36,0.15)', color: 'rgb(254,243,199)' },
|
||||
Ready: { borderColor: 'rgba(52,211,153,0.35)', backgroundColor: 'rgba(52,211,153,0.15)', color: 'rgb(209,250,229)' },
|
||||
Error: { borderColor: 'rgba(248,113,113,0.35)', backgroundColor: 'rgba(248,113,113,0.15)', color: 'rgb(254,226,226)' },
|
||||
}
|
||||
|
||||
const quickTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
|
||||
const stepLabels = ['Preload', 'Details', 'Publish']
|
||||
const stepIndex = progress >= 100 ? 2 : progress >= 34 ? 1 : 0
|
||||
|
||||
return (
|
||||
<header className="rounded-xl border border-white/50 bg-gradient-to-br from-slate-900/80 to-slate-900/50 p-5 shadow-[0_12px_40px_rgba(0,0,0,0.35)] sm:p-6">
|
||||
{/* Intended props: step, steps, phase, badge, progress, statusMessage */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">{title}</h1>
|
||||
<p className="mt-1 text-sm text-white/65">{description}</p>
|
||||
</div>
|
||||
|
||||
<motion.span
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusTheme[resolvedStatus] || statusTheme.Idle}`}
|
||||
animate={statusColors[resolvedStatus] || statusColors.Idle}
|
||||
transition={quickTransition}
|
||||
>
|
||||
{resolvedStatus}
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2 overflow-x-auto">
|
||||
{stepLabels.map((label, idx) => {
|
||||
const active = idx <= stepIndex
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<span className={`rounded-full border px-3 py-1 text-xs ${active ? 'border-emerald-400/40 bg-emerald-400/20 text-emerald-100' : 'border-white/15 bg-white/5 text-white/55'}`}>
|
||||
{label}
|
||||
</span>
|
||||
{idx < stepLabels.length - 1 && <span className="text-white/30">→</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, progress))}%`,
|
||||
background: 'linear-gradient(90deg,#38bdf8,#06b6d4,#34d399)',
|
||||
transition: prefersReducedMotion ? 'none' : 'width 200ms ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-right text-xs text-white/55">{Math.round(progress)}%</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{(state === 'processing' || state === 'finishing' || state === 'publishing' || isCancelling) && (
|
||||
<motion.div
|
||||
key="processing-note"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
||||
transition={quickTransition}
|
||||
className="mt-3 rounded-lg border border-cyan-300/25 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-100"
|
||||
>
|
||||
{processingLabel || 'Analyzing content'} — you can continue editing details while processing finishes.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{error && (
|
||||
<motion.div
|
||||
key="progress-error"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
||||
transition={quickTransition}
|
||||
className="mt-3 rounded-lg border border-rose-200/25 bg-rose-400/8 px-3 py-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-rose-100">Something went wrong while uploading.</p>
|
||||
<p className="mt-1 text-xs text-rose-100/90">You can retry safely. {error}</p>
|
||||
{recoveryHint && <p className="mt-1 text-xs text-rose-100/80">{recoveryHint}</p>}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={onRetry} className="rounded-md border border-rose-200/35 bg-rose-400/10 px-2.5 py-1 text-xs text-rose-100 transition hover:bg-rose-400/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200/75">Retry</button>
|
||||
<button type="button" onClick={onReset} className="rounded-md border border-white/25 bg-white/10 px-2.5 py-1 text-xs text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60">Reset</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
96
resources/js/components/upload/UploadSidebar.jsx
Normal file
96
resources/js/components/upload/UploadSidebar.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import TagInput from '../tags/TagInput'
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
description = 'Complete metadata before publishing',
|
||||
showHeader = true,
|
||||
metadata,
|
||||
suggestedTags = [],
|
||||
errors = {},
|
||||
onChangeTitle,
|
||||
onChangeTags,
|
||||
onChangeDescription,
|
||||
onToggleRights,
|
||||
}) {
|
||||
return (
|
||||
<aside className="rounded-2xl border border-white/7 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_10px_24px_rgba(0,0,0,0.22)] sm:p-7">
|
||||
{showHeader && (
|
||||
<div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="mt-1 text-sm text-white/65">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-white">Basics</h4>
|
||||
<p className="mt-1 text-xs text-white/60">Add a clear title and short description.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Title <span className="text-red-300">*</span></span>
|
||||
<input
|
||||
id="upload-sidebar-title"
|
||||
value={metadata.title}
|
||||
onChange={(event) => onChangeTitle?.(event.target.value)}
|
||||
className={`mt-2 w-full rounded-xl border bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 ${errors.title ? 'border-red-300/60 focus:ring-red-300/70' : 'border-white/15 focus:ring-sky-300/70'}`}
|
||||
placeholder="Give your artwork a clear title"
|
||||
/>
|
||||
{errors.title && <p className="mt-1 text-xs text-red-200">{errors.title}</p>}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Description</span>
|
||||
<textarea
|
||||
id="upload-sidebar-description"
|
||||
value={metadata.description}
|
||||
onChange={(event) => onChangeDescription?.(event.target.value)}
|
||||
rows={5}
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-300/70"
|
||||
placeholder="Describe your artwork (Markdown supported)."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-white">Tags</h4>
|
||||
<p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p>
|
||||
</div>
|
||||
<TagInput
|
||||
value={metadata.tags}
|
||||
onChange={(nextTags) => onChangeTags?.(nextTags)}
|
||||
suggestedTags={suggestedTags}
|
||||
maxTags={15}
|
||||
minLength={2}
|
||||
maxLength={32}
|
||||
searchEndpoint="/api/tags/search"
|
||||
popularEndpoint="/api/tags/popular"
|
||||
placeholder="Type tags (e.g. cyberpunk, city)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<label className="flex items-start gap-3 text-sm text-white/90">
|
||||
<input
|
||||
id="upload-sidebar-rights"
|
||||
type="checkbox"
|
||||
checked={Boolean(metadata.rightsAccepted)}
|
||||
onChange={(event) => onToggleRights?.(event.target.checked)}
|
||||
className="mt-0.5 h-5 w-5 rounded-md border border-white/30 bg-slate-900/70 text-emerald-400 accent-emerald-500 focus:ring-2 focus:ring-emerald-400/40"
|
||||
/>
|
||||
<span>
|
||||
I confirm I own the rights to this content. <span className="text-red-300">*</span>
|
||||
<span className="mt-1 block text-xs text-white/60">Required before publishing.</span>
|
||||
{errors.rights && <span className="mt-1 block text-xs text-red-200">{errors.rights}</span>}
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
53
resources/js/components/upload/UploadStepper.jsx
Normal file
53
resources/js/components/upload/UploadStepper.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function UploadStepper({ steps = [], activeStep = 1, highestUnlockedStep = 1, onStepClick }) {
|
||||
const safeActive = Math.max(1, Math.min(steps.length || 1, activeStep))
|
||||
|
||||
return (
|
||||
<nav aria-label="Upload steps" className="rounded-xl border border-white/50 bg-slate-900/70 px-3 py-3 sm:px-4">
|
||||
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto sm:gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const number = index + 1
|
||||
const isActive = number === safeActive
|
||||
const isComplete = number < safeActive
|
||||
const isLocked = number > highestUnlockedStep
|
||||
const canNavigate = number < safeActive && !isLocked
|
||||
|
||||
const baseBtn = 'inline-flex items-center gap-2 rounded-full border px-2.5 py-1.5 text-xs sm:px-3'
|
||||
const stateClass = isActive
|
||||
? 'border-sky-300/80 bg-sky-500/30 text-white shadow-[0_8px_24px_rgba(14,165,233,0.12)]'
|
||||
: isComplete
|
||||
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25'
|
||||
: isLocked
|
||||
? 'cursor-default border-white/50 bg-white/5 text-white/40'
|
||||
: 'border-white/10 bg-white/5 text-white/80 hover:bg-white/10'
|
||||
|
||||
const circleClass = isComplete
|
||||
? 'border-emerald-300/60 bg-emerald-500/20 text-emerald-100'
|
||||
: isActive
|
||||
? 'border-sky-300/60 bg-sky-500/30 text-white'
|
||||
: 'border-white/20 bg-white/5 text-white/80'
|
||||
|
||||
return (
|
||||
<li key={step.key} className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canNavigate && onStepClick?.(number)}
|
||||
disabled={isLocked}
|
||||
aria-disabled={isLocked ? 'true' : 'false'}
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
className={`${baseBtn} ${stateClass}`}
|
||||
>
|
||||
<span className={`grid h-5 w-5 place-items-center rounded-full border text-[11px] ${circleClass}`}>
|
||||
{isComplete ? '✓' : number}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{step.label}</span>
|
||||
</button>
|
||||
{index < steps.length - 1 && <span className="text-white/50">→</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
1199
resources/js/components/upload/UploadWizard.jsx
Normal file
1199
resources/js/components/upload/UploadWizard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
310
resources/js/components/upload/__tests__/UploadWizard.test.jsx
Normal file
310
resources/js/components/upload/__tests__/UploadWizard.test.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act } from 'react'
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UploadWizard from '../UploadWizard'
|
||||
|
||||
function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false } = {}) {
|
||||
window.axios = {
|
||||
post: vi.fn((url, payload, config = {}) => {
|
||||
if (url === '/api/uploads/init') {
|
||||
if (initError) return Promise.reject(initError)
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
session_id: 'session-1',
|
||||
upload_token: 'token-1',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/uploads/chunk') {
|
||||
if (holdChunk) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (config?.signal?.aborted) {
|
||||
reject({ name: 'CanceledError', code: 'ERR_CANCELED' })
|
||||
return
|
||||
}
|
||||
config?.signal?.addEventListener?.('abort', () => reject({ name: 'CanceledError', code: 'ERR_CANCELED' }))
|
||||
setTimeout(() => resolve({ data: { received_bytes: 1024, progress: 55 } }), 20)
|
||||
})
|
||||
}
|
||||
|
||||
const offset = Number(payload?.get?.('offset') || 0)
|
||||
const chunkSize = Number(payload?.get?.('chunk_size') || 0)
|
||||
const totalSize = Number(payload?.get?.('total_size') || 1)
|
||||
const received = Math.min(totalSize, offset + chunkSize)
|
||||
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
received_bytes: received,
|
||||
progress: Math.round((received / totalSize) * 100),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/uploads/finish') {
|
||||
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
|
||||
}
|
||||
|
||||
if (url === '/api/uploads/session-1/publish') {
|
||||
return Promise.resolve({ data: { success: true, status: 'published' } })
|
||||
}
|
||||
|
||||
if (url === '/api/uploads/cancel') {
|
||||
return Promise.resolve({ data: { success: true, status: 'cancelled' } })
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unhandled POST ${url}`))
|
||||
}),
|
||||
get: vi.fn((url) => {
|
||||
if (url === '/api/uploads/status/session-1') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
id: 'session-1',
|
||||
processing_state: statusValue,
|
||||
status: statusValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error(`Unhandled GET ${url}`))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function flushUi() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
})
|
||||
}
|
||||
|
||||
async function renderWizard(props = {}) {
|
||||
await act(async () => {
|
||||
render(<UploadWizard {...props} />)
|
||||
})
|
||||
await flushUi()
|
||||
}
|
||||
|
||||
async function uploadPrimary(file) {
|
||||
await act(async () => {
|
||||
const input = screen.getByLabelText('Upload file input')
|
||||
await userEvent.upload(input, file)
|
||||
})
|
||||
await flushUi()
|
||||
}
|
||||
|
||||
async function uploadScreenshot(file) {
|
||||
await act(async () => {
|
||||
const input = await screen.findByLabelText('Screenshot file input')
|
||||
await userEvent.upload(input, file)
|
||||
})
|
||||
await flushUi()
|
||||
}
|
||||
|
||||
async function completeStep1ToReady() {
|
||||
await uploadPrimary(new File(['img'], 'ready.png', { type: 'image/png' }))
|
||||
await act(async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /continue to publish/i })).not.toBeNull()
|
||||
})
|
||||
}
|
||||
|
||||
describe('UploadWizard step flow', () => {
|
||||
let originalImage
|
||||
let originalScrollIntoView
|
||||
let consoleErrorSpy
|
||||
|
||||
beforeEach(() => {
|
||||
window.URL.createObjectURL = vi.fn(() => `blob:${Math.random().toString(16).slice(2)}`)
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
originalImage = global.Image
|
||||
originalScrollIntoView = Element.prototype.scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const text = args.map((arg) => String(arg)).join(' ')
|
||||
if (text.includes('not configured to support act')) return
|
||||
if (text.includes('not wrapped in act')) return
|
||||
console.warn(...args)
|
||||
})
|
||||
global.Image = class MockImage {
|
||||
set src(_value) {
|
||||
this.naturalWidth = 1920
|
||||
this.naturalHeight = 1080
|
||||
setTimeout(() => {
|
||||
if (typeof this.onload === 'function') this.onload()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.Image = originalImage
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView
|
||||
consoleErrorSpy?.mockRestore()
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders 3-step stepper', () => {
|
||||
installAxiosStubs()
|
||||
return renderWizard({ initialDraftId: 301 }).then(() => {
|
||||
expect(screen.getByRole('navigation', { name: /upload steps/i })).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: /1 upload/i })).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: /2 details/i })).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: /3 publish/i })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('marks locked steps with aria-disabled and blocks click', async () => {
|
||||
installAxiosStubs()
|
||||
await renderWizard({ initialDraftId: 307 })
|
||||
|
||||
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
|
||||
const detailsStep = within(stepper).getByRole('button', { name: /2 details/i })
|
||||
const publishStep = within(stepper).getByRole('button', { name: /3 publish/i })
|
||||
|
||||
expect(detailsStep.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(publishStep.getAttribute('aria-disabled')).toBe('true')
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(detailsStep)
|
||||
})
|
||||
expect(screen.getByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
|
||||
expect(screen.queryByText(/add details/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps step 2 hidden until step 1 upload is ready', async () => {
|
||||
installAxiosStubs({ statusValue: 'processing' })
|
||||
await renderWizard({ initialDraftId: 302 })
|
||||
|
||||
expect(screen.queryByText(/artwork details/i)).toBeNull()
|
||||
|
||||
await uploadPrimary(new File(['img'], 'x.png', { type: 'image/png' }))
|
||||
await act(async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /continue to publish/i })).toBeNull()
|
||||
})
|
||||
expect(screen.queryByText(/artwork details/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('requires archive screenshot before start upload enables', async () => {
|
||||
installAxiosStubs()
|
||||
await renderWizard({ initialDraftId: 303 })
|
||||
|
||||
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
|
||||
|
||||
const start = await screen.findByRole('button', { name: /start upload/i })
|
||||
await waitFor(() => {
|
||||
expect(start.disabled).toBe(true)
|
||||
})
|
||||
|
||||
await uploadScreenshot(new File(['shot'], 'screen.png', { type: 'image/png' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(start.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows navigation back to completed previous step', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 304 })
|
||||
|
||||
await completeStep1ToReady()
|
||||
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
|
||||
|
||||
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
|
||||
await act(async () => {
|
||||
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
|
||||
})
|
||||
expect(await screen.findByText(/upload your artwork file/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('triggers scroll-to-top behavior on step change', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 308 })
|
||||
|
||||
const scrollSpy = Element.prototype.scrollIntoView
|
||||
const initialCalls = scrollSpy.mock.calls.length
|
||||
|
||||
await completeStep1ToReady()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollSpy.mock.calls.length).toBeGreaterThan(initialCalls)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows publish only on step 3 and only after ready_to_publish path', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 305, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
|
||||
|
||||
await completeStep1ToReady()
|
||||
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
|
||||
|
||||
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'My Art')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const publish = screen.getByRole('button', { name: /^publish$/i })
|
||||
expect(publish.disabled).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/your artwork is live/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it('keeps mobile sticky action bar visible class', async () => {
|
||||
installAxiosStubs()
|
||||
await renderWizard({ initialDraftId: 306 })
|
||||
|
||||
const bar = screen.getByTestId('wizard-action-bar')
|
||||
expect((bar.className || '').includes('sticky')).toBe(true)
|
||||
expect((bar.className || '').includes('bottom-0')).toBe(true)
|
||||
})
|
||||
|
||||
it('locks step 1 file input after upload and unlocks after reset', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 309 })
|
||||
|
||||
await completeStep1ToReady()
|
||||
|
||||
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
|
||||
await act(async () => {
|
||||
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const dropzoneButton = screen.getByTestId('upload-dropzone')
|
||||
expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
expect(screen.getByText(/file is locked after upload\. reset to change\./i)).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /reset upload/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const unlockedDropzone = screen.getByTestId('upload-dropzone')
|
||||
expect(unlockedDropzone.getAttribute('aria-disabled')).toBe('false')
|
||||
})
|
||||
})
|
||||
})
|
||||
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