Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
1064 lines
42 KiB
JavaScript
1064 lines
42 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
|
import { usePage } from '@inertiajs/react'
|
|
import TagInput from '../../components/tags/TagInput'
|
|
import UploadWizard from '../../components/upload/UploadWizard'
|
|
import Checkbox from '../../Components/ui/Checkbox'
|
|
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
|
|
|
|
const phases = {
|
|
idle: 'idle',
|
|
initializing: 'initializing',
|
|
ready: 'ready',
|
|
uploading: 'uploading',
|
|
finishing: 'finishing',
|
|
polling: 'polling',
|
|
cancelling: 'cancelling',
|
|
success: 'success',
|
|
error: 'error',
|
|
}
|
|
|
|
const initialState = {
|
|
phase: phases.idle,
|
|
sessionId: null,
|
|
uploadToken: null,
|
|
file: null,
|
|
filePreviewUrl: null,
|
|
previewUrl: null,
|
|
progress: 0,
|
|
pipelineStatus: null,
|
|
failureReason: null,
|
|
error: null,
|
|
cancelledAt: null,
|
|
notices: [],
|
|
artworkId: null,
|
|
draftId: null,
|
|
metadata: {
|
|
title: '',
|
|
type: '',
|
|
category: '',
|
|
tags: '',
|
|
description: '',
|
|
licenseAccepted: false,
|
|
},
|
|
}
|
|
|
|
const STORAGE_VERSION = 1
|
|
|
|
function storageKey(userId) {
|
|
return `sb.upload.session.${userId}.v${STORAGE_VERSION}`
|
|
}
|
|
|
|
function readStoredSession(userId) {
|
|
try {
|
|
const raw = window.localStorage.getItem(storageKey(userId))
|
|
if (!raw) return null
|
|
const parsed = JSON.parse(raw)
|
|
if (!parsed || typeof parsed !== 'object') return null
|
|
return parsed
|
|
} catch (error) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function writeStoredSession(userId, payload) {
|
|
try {
|
|
window.localStorage.setItem(storageKey(userId), JSON.stringify(payload))
|
|
} catch (error) {
|
|
// ignore write failures
|
|
}
|
|
}
|
|
|
|
function clearStoredSession(userId) {
|
|
try {
|
|
window.localStorage.removeItem(storageKey(userId))
|
|
} catch (error) {
|
|
// ignore removal failures
|
|
}
|
|
}
|
|
|
|
function reducer(state, action) {
|
|
switch (action.type) {
|
|
case 'SET_DRAFT':
|
|
return { ...state, draftId: action.draftId, artworkId: action.artworkId ?? state.artworkId }
|
|
case 'SET_FILE':
|
|
return { ...state, file: action.file, filePreviewUrl: action.previewUrl, error: null, cancelledAt: null }
|
|
case 'SET_METADATA':
|
|
return { ...state, metadata: { ...state.metadata, ...action.payload } }
|
|
case 'INIT_START':
|
|
return { ...state, phase: phases.initializing, error: null, cancelledAt: null }
|
|
case 'INIT_SUCCESS':
|
|
return { ...state, phase: phases.ready, sessionId: action.sessionId, uploadToken: action.uploadToken, pipelineStatus: action.status }
|
|
case 'RESTORE_SESSION':
|
|
return { ...state, phase: phases.ready, sessionId: action.sessionId, uploadToken: action.uploadToken, pipelineStatus: action.status ?? state.pipelineStatus }
|
|
case 'RESUME_SESSION':
|
|
return { ...state, phase: phases.polling, sessionId: action.sessionId, pipelineStatus: action.status ?? state.pipelineStatus }
|
|
case 'INIT_ERROR':
|
|
return { ...state, phase: phases.error, error: action.error }
|
|
case 'UPLOAD_START':
|
|
return { ...state, phase: phases.uploading, progress: 0, error: null }
|
|
case 'UPLOAD_PROGRESS':
|
|
return { ...state, progress: action.progress }
|
|
case 'UPLOAD_ERROR':
|
|
return { ...state, phase: phases.error, error: action.error }
|
|
case 'FINISH_START':
|
|
return { ...state, phase: phases.finishing, error: null }
|
|
case 'FINISH_SUCCESS':
|
|
return { ...state, phase: phases.polling, pipelineStatus: action.status, previewUrl: action.previewUrl ?? null }
|
|
case 'FINISH_ERROR':
|
|
return { ...state, phase: phases.error, error: action.error }
|
|
case 'CANCEL_START':
|
|
return { ...state, phase: phases.cancelling, error: null }
|
|
case 'CANCEL_SUCCESS':
|
|
return { ...state, phase: phases.idle, cancelledAt: Date.now() }
|
|
case 'CLEAR_CANCELLED':
|
|
return { ...state, cancelledAt: null }
|
|
case 'CANCEL_ERROR':
|
|
return { ...state, phase: phases.error, error: action.error }
|
|
case 'STATUS_UPDATE':
|
|
return { ...state, pipelineStatus: action.status, progress: action.progress ?? state.progress, failureReason: action.failureReason ?? null }
|
|
case 'STATUS_ERROR':
|
|
return { ...state, phase: phases.error, error: action.error }
|
|
case 'SUCCESS':
|
|
return { ...state, phase: phases.success }
|
|
case 'RESET':
|
|
return { ...initialState, draftId: state.draftId, cancelledAt: state.cancelledAt }
|
|
case 'PUSH_NOTICE':
|
|
return { ...state, notices: [...state.notices, action.notice] }
|
|
case 'REMOVE_NOTICE':
|
|
return { ...state, notices: state.notices.filter((notice) => notice.id !== action.id) }
|
|
default:
|
|
return state
|
|
}
|
|
}
|
|
|
|
const MAX_CHUNK_RETRIES = 3
|
|
const RETRY_DELAY_MS = 900
|
|
|
|
function normalizeUiTag(rawTag) {
|
|
const raw = String(rawTag ?? '').trim().toLowerCase()
|
|
if (!raw) return ''
|
|
|
|
return raw
|
|
.replace(/\s+/g, '-')
|
|
.replace(/[^a-z0-9_-]/g, '')
|
|
.replace(/-+/g, '-')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^[-_]+|[-_]+$/g, '')
|
|
.slice(0, 32)
|
|
}
|
|
|
|
function parseUiTags(csvValue) {
|
|
return String(csvValue ?? '')
|
|
.split(/[\n,]+/)
|
|
.map((item) => normalizeUiTag(item))
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function getTypeKey(ct) {
|
|
if (!ct) return 'default'
|
|
if (ct.slug && typeof ct.slug === 'string') {
|
|
return ct.slug.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
|
}
|
|
return String(ct.name || '').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
|
}
|
|
|
|
function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
|
const [state, dispatch] = useReducer(reducer, { ...initialState, draftId })
|
|
const pollRef = useRef(null)
|
|
|
|
const extractErrorMessage = useCallback((error, fallback) => {
|
|
const message = error?.response?.data?.message
|
|
if (message && typeof message === 'string') return message
|
|
const errors = error?.response?.data?.errors
|
|
if (errors && typeof errors === 'object') {
|
|
const firstKey = Object.keys(errors)[0]
|
|
if (firstKey && Array.isArray(errors[firstKey]) && errors[firstKey][0]) {
|
|
return errors[firstKey][0]
|
|
}
|
|
}
|
|
return fallback
|
|
}, [])
|
|
|
|
const pushNotice = useCallback((type, message) => {
|
|
const normalizedType = ['success', 'warning', 'error'].includes(String(type || '').toLowerCase())
|
|
? String(type).toLowerCase()
|
|
: 'error'
|
|
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
dispatch({ type: 'PUSH_NOTICE', notice: { id, type: normalizedType, message } })
|
|
window.setTimeout(() => {
|
|
dispatch({ type: 'REMOVE_NOTICE', id })
|
|
}, 4500)
|
|
}, [])
|
|
|
|
const pushMappedNotice = useCallback((notice) => {
|
|
if (!notice?.message) return
|
|
pushNotice(notice.type || 'error', notice.message)
|
|
}, [pushNotice])
|
|
|
|
const previewUrl = useMemo(() => {
|
|
if (state.previewUrl) return state.previewUrl
|
|
if (!state.filePreviewUrl) return null
|
|
return state.filePreviewUrl
|
|
}, [state.previewUrl, state.filePreviewUrl])
|
|
|
|
const stopPolling = useCallback(() => {
|
|
if (pollRef.current) {
|
|
window.clearInterval(pollRef.current)
|
|
pollRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const startPolling = useCallback(() => {
|
|
if (!state.sessionId) return
|
|
stopPolling()
|
|
pollRef.current = window.setInterval(async () => {
|
|
try {
|
|
const res = await window.axios.get(`/api/uploads/status/${state.sessionId}`, {
|
|
headers: state.uploadToken ? { 'X-Upload-Token': state.uploadToken } : undefined,
|
|
params: state.uploadToken ? { upload_token: state.uploadToken } : undefined,
|
|
})
|
|
const data = res.data || {}
|
|
dispatch({
|
|
type: 'STATUS_UPDATE',
|
|
status: data.status,
|
|
progress: typeof data.progress === 'number' ? data.progress : state.progress,
|
|
failureReason: data.failure_reason,
|
|
})
|
|
|
|
if (data.status === 'processed') {
|
|
dispatch({ type: 'SUCCESS' })
|
|
stopPolling()
|
|
}
|
|
|
|
if (data.failure_reason) {
|
|
dispatch({ type: 'STATUS_ERROR', error: data.failure_reason })
|
|
stopPolling()
|
|
}
|
|
} catch (error) {
|
|
dispatch({ type: 'STATUS_ERROR', error: 'Status check failed.' })
|
|
stopPolling()
|
|
}
|
|
}, 2500)
|
|
}, [state.sessionId, state.uploadToken, state.progress, stopPolling])
|
|
|
|
useEffect(() => {
|
|
if (draftId && !state.sessionId) {
|
|
dispatch({ type: 'RESUME_SESSION', sessionId: draftId, status: 'resuming' })
|
|
}
|
|
}, [draftId, state.sessionId])
|
|
|
|
useEffect(() => {
|
|
if (state.phase === phases.polling) {
|
|
startPolling()
|
|
}
|
|
|
|
return () => stopPolling()
|
|
}, [state.phase, startPolling, stopPolling])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (state.filePreviewUrl) {
|
|
URL.revokeObjectURL(state.filePreviewUrl)
|
|
}
|
|
}
|
|
}, [state.filePreviewUrl])
|
|
|
|
const initSession = useCallback(async () => {
|
|
dispatch({ type: 'INIT_START' })
|
|
try {
|
|
const res = await window.axios.post('/api/uploads/init', { client: 'web' })
|
|
const data = res.data || {}
|
|
dispatch({
|
|
type: 'INIT_SUCCESS',
|
|
sessionId: data.session_id,
|
|
uploadToken: data.upload_token,
|
|
status: data.status,
|
|
})
|
|
if (userId && data.session_id && data.upload_token && state.file) {
|
|
writeStoredSession(userId, {
|
|
session_id: data.session_id,
|
|
upload_token: data.upload_token,
|
|
file_name: state.file.name,
|
|
file_size: state.file.size,
|
|
updated_at: Date.now(),
|
|
})
|
|
}
|
|
return { sessionId: data.session_id, uploadToken: data.upload_token }
|
|
} catch (error) {
|
|
const notice = mapUploadErrorNotice(error, 'Failed to initialize upload session.')
|
|
dispatch({ type: 'INIT_ERROR', error: notice.message })
|
|
pushMappedNotice(notice)
|
|
return null
|
|
}
|
|
}, [state.file, userId, pushMappedNotice])
|
|
|
|
const createDraft = useCallback(async () => {
|
|
if (state.artworkId) return state.artworkId
|
|
try {
|
|
const res = await window.axios.post('/api/artworks', {
|
|
title: state.metadata.title,
|
|
category: state.metadata.category,
|
|
tags: state.metadata.tags,
|
|
description: state.metadata.description,
|
|
license: state.metadata.licenseAccepted,
|
|
})
|
|
|
|
const data = res.data || {}
|
|
const artworkId = data.artwork_id ?? data.id
|
|
if (artworkId) {
|
|
dispatch({ type: 'SET_DRAFT', draftId: state.draftId, artworkId })
|
|
return artworkId
|
|
}
|
|
throw new Error('missing_artwork_id')
|
|
} catch (error) {
|
|
const notice = mapUploadErrorNotice(error, 'Unable to create draft metadata.')
|
|
dispatch({ type: 'FINISH_ERROR', error: notice.message })
|
|
pushMappedNotice(notice)
|
|
return null
|
|
}
|
|
}, [state.artworkId, state.metadata, state.draftId, pushMappedNotice])
|
|
|
|
const syncArtworkTags = useCallback(async (artworkId) => {
|
|
const tags = Array.from(new Set(parseUiTags(state.metadata.tags)))
|
|
if (tags.length === 0) {
|
|
return true
|
|
}
|
|
|
|
try {
|
|
await window.axios.put(`/api/artworks/${artworkId}/tags`, { tags })
|
|
return true
|
|
} catch (error) {
|
|
const notice = mapUploadErrorNotice(error, 'Tag sync failed. Upload will continue.')
|
|
pushMappedNotice({ ...notice, type: 'warning' })
|
|
return false
|
|
}
|
|
}, [state.metadata.tags, pushMappedNotice])
|
|
|
|
const fetchStatus = useCallback(async (sessionId, uploadToken) => {
|
|
const res = await window.axios.get(`/api/uploads/status/${sessionId}`, {
|
|
headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined,
|
|
params: uploadToken ? { upload_token: uploadToken } : undefined,
|
|
})
|
|
return res.data || {}
|
|
}, [])
|
|
|
|
const validateStoredSession = useCallback(async (file) => {
|
|
if (!userId) return null
|
|
const stored = readStoredSession(userId)
|
|
if (!stored) return null
|
|
|
|
if (stored.file_name !== file.name || stored.file_size !== file.size) {
|
|
clearStoredSession(userId)
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const data = await fetchStatus(stored.session_id, stored.upload_token)
|
|
if (!data || !data.session_id) {
|
|
clearStoredSession(userId)
|
|
return null
|
|
}
|
|
dispatch({ type: 'RESTORE_SESSION', sessionId: stored.session_id, uploadToken: stored.upload_token, status: data.status })
|
|
return { sessionId: stored.session_id, uploadToken: stored.upload_token }
|
|
} catch (error) {
|
|
clearStoredSession(userId)
|
|
return null
|
|
}
|
|
}, [fetchStatus, userId])
|
|
|
|
const uploadChunk = useCallback(async (sessionId, uploadToken, blob, offset, totalSize, attempt) => {
|
|
const payload = new FormData()
|
|
payload.append('session_id', sessionId)
|
|
payload.append('offset', String(offset))
|
|
payload.append('chunk_size', String(blob.size))
|
|
payload.append('total_size', String(totalSize))
|
|
payload.append('chunk', blob)
|
|
payload.append('upload_token', uploadToken)
|
|
|
|
try {
|
|
const res = await window.axios.post('/api/uploads/chunk', payload, {
|
|
headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined,
|
|
})
|
|
|
|
const data = res.data || {}
|
|
if (typeof data.progress === 'number') {
|
|
dispatch({ type: 'UPLOAD_PROGRESS', progress: data.progress })
|
|
}
|
|
|
|
return data
|
|
} catch (error) {
|
|
if (attempt < MAX_CHUNK_RETRIES) {
|
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1)))
|
|
return uploadChunk(sessionId, uploadToken, blob, offset, totalSize, attempt + 1)
|
|
}
|
|
throw error
|
|
}
|
|
}, [])
|
|
|
|
const uploadFile = useCallback(async (sessionId, uploadToken, file) => {
|
|
dispatch({ type: 'UPLOAD_START' })
|
|
|
|
let status
|
|
try {
|
|
status = await fetchStatus(sessionId, uploadToken)
|
|
} catch (error) {
|
|
const notice = mapUploadErrorNotice(error, 'Unable to resume upload.')
|
|
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
|
|
pushMappedNotice(notice)
|
|
return false
|
|
}
|
|
|
|
let offset = Number(status.received_bytes || 0)
|
|
const totalSize = file.size
|
|
if (offset > totalSize) offset = 0
|
|
|
|
while (offset < totalSize) {
|
|
const nextOffset = Math.min(offset + chunkSize, totalSize)
|
|
const chunk = file.slice(offset, nextOffset)
|
|
|
|
try {
|
|
const data = await uploadChunk(sessionId, uploadToken, chunk, offset, totalSize, 0)
|
|
offset = Number(data.received_bytes ?? nextOffset)
|
|
if (offset < nextOffset) {
|
|
offset = nextOffset
|
|
}
|
|
} catch (error) {
|
|
const notice = mapUploadErrorNotice(error, 'File upload failed. Please retry.')
|
|
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
|
|
pushMappedNotice(notice)
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}, [chunkSize, fetchStatus, uploadChunk, pushMappedNotice])
|
|
|
|
const finishUpload = useCallback(async (sessionId, uploadToken, artworkId) => {
|
|
dispatch({ type: 'FINISH_START' })
|
|
try {
|
|
const res = await window.axios.post(
|
|
'/api/uploads/finish',
|
|
{
|
|
session_id: sessionId,
|
|
artwork_id: artworkId,
|
|
upload_token: uploadToken,
|
|
file_name: String(state.file?.name || ''),
|
|
},
|
|
{ headers: { 'X-Upload-Token': uploadToken } }
|
|
)
|
|
const data = res.data || {}
|
|
const previewPath = data.preview_path
|
|
const previewUrl = previewPath ? `${filesCdnUrl}/${previewPath}` : null
|
|
dispatch({ type: 'FINISH_SUCCESS', status: data.status, previewUrl })
|
|
|
|
const finishNotice = mapUploadResultNotice(data, {
|
|
fallbackType: String(data.status || '').toLowerCase() === 'queued' ? 'warning' : 'success',
|
|
fallbackMessage: String(data.status || '').toLowerCase() === 'queued'
|
|
? 'Upload received. Processing is queued.'
|
|
: 'Upload finalized successfully.',
|
|
})
|
|
pushMappedNotice(finishNotice)
|
|
|
|
if (userId) {
|
|
clearStoredSession(userId)
|
|
}
|
|
return true
|
|
} catch (error) {
|
|
const notice = mapUploadErrorNotice(error, 'Upload finalization failed.')
|
|
dispatch({ type: 'FINISH_ERROR', error: notice.message })
|
|
pushMappedNotice(notice)
|
|
return false
|
|
}
|
|
}, [filesCdnUrl, userId, pushMappedNotice])
|
|
|
|
const startUpload = useCallback(async () => {
|
|
if (!state.file) {
|
|
const message = 'Please select a file first.'
|
|
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
|
pushNotice('error', message)
|
|
return
|
|
}
|
|
|
|
if (!state.metadata.title.trim()) {
|
|
const message = 'Title is required to start the upload.'
|
|
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
|
pushNotice('error', message)
|
|
return
|
|
}
|
|
|
|
if (!state.metadata.type) {
|
|
const message = 'Please select a Type.'
|
|
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
|
pushNotice('error', message)
|
|
return
|
|
}
|
|
|
|
if (!state.metadata.category) {
|
|
const message = 'Please select a Category.'
|
|
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
|
pushNotice('error', message)
|
|
return
|
|
}
|
|
|
|
if (parseUiTags(state.metadata.tags).length === 0) {
|
|
const message = 'Please add at least one tag.'
|
|
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
|
pushNotice('error', message)
|
|
return
|
|
}
|
|
|
|
if (!state.metadata.description.trim()) {
|
|
const message = 'Please provide a description.'
|
|
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
|
pushNotice('error', message)
|
|
return
|
|
}
|
|
|
|
if (!state.metadata.licenseAccepted) {
|
|
const message = 'You must confirm ownership of the artwork.'
|
|
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
|
pushNotice('error', message)
|
|
return
|
|
}
|
|
|
|
let init = null
|
|
if (userId) {
|
|
init = await validateStoredSession(state.file)
|
|
}
|
|
|
|
if (!init) {
|
|
init = await initSession()
|
|
}
|
|
|
|
if (!init) return
|
|
|
|
const artworkId = await createDraft()
|
|
if (!artworkId) return
|
|
|
|
await syncArtworkTags(artworkId)
|
|
|
|
const ok = await uploadFile(init.sessionId, init.uploadToken, state.file)
|
|
if (!ok) return
|
|
|
|
await finishUpload(init.sessionId, init.uploadToken, artworkId)
|
|
}, [state.file, state.metadata, initSession, createDraft, syncArtworkTags, uploadFile, finishUpload, validateStoredSession, userId, pushNotice])
|
|
|
|
const cancelUpload = useCallback(async () => {
|
|
dispatch({ type: 'CANCEL_START' })
|
|
|
|
if (!state.sessionId || !state.uploadToken) {
|
|
if (userId) {
|
|
clearStoredSession(userId)
|
|
}
|
|
dispatch({ type: 'CANCEL_SUCCESS' })
|
|
dispatch({ type: 'RESET' })
|
|
pushNotice('warning', 'Upload cancelled.')
|
|
return
|
|
}
|
|
|
|
try {
|
|
await window.axios.post(
|
|
'/api/uploads/cancel',
|
|
{ session_id: state.sessionId, upload_token: state.uploadToken },
|
|
{ headers: { 'X-Upload-Token': state.uploadToken } }
|
|
)
|
|
} catch (error) {
|
|
// ignore error, always reset local state to avoid leaking session info
|
|
}
|
|
|
|
if (userId) {
|
|
clearStoredSession(userId)
|
|
}
|
|
dispatch({ type: 'CANCEL_SUCCESS' })
|
|
dispatch({ type: 'RESET' })
|
|
pushNotice('warning', 'Upload cancelled.')
|
|
}, [state.sessionId, state.uploadToken, userId, pushNotice])
|
|
|
|
return {
|
|
state,
|
|
dispatch,
|
|
previewUrl,
|
|
startUpload,
|
|
initSession,
|
|
cancelUpload,
|
|
pushNotice,
|
|
}
|
|
}
|
|
|
|
export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
|
|
const { props } = usePage()
|
|
|
|
const windowFlags = window?.SKINBASE_FLAGS || {}
|
|
const propFlagRaw = props?.feature_flags?.uploads_v2
|
|
const windowFlagRaw = (windowFlags?.uploads && windowFlags.uploads.v2) ?? windowFlags?.uploads_v2
|
|
|
|
const toBooleanFlag = (value) => {
|
|
if (typeof value === 'boolean') return value
|
|
if (typeof value === 'number') return value === 1
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase()
|
|
return ['1', 'true', 'yes', 'on'].includes(normalized)
|
|
}
|
|
return false
|
|
}
|
|
|
|
const uploadsV2Enabled = toBooleanFlag(propFlagRaw) || toBooleanFlag(windowFlagRaw)
|
|
|
|
if (uploadsV2Enabled) {
|
|
return (
|
|
<section className="px-4 py-1">
|
|
<div className="max-w-6xl mx-auto">
|
|
<UploadWizard
|
|
initialDraftId={draftId ?? null}
|
|
chunkSize={chunkSize}
|
|
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
|
|
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
|
|
/>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const userId = props?.auth?.user?.id ?? null
|
|
const suggestedTags = Array.isArray(props?.suggested_tags) ? props.suggested_tags : []
|
|
const safeChunkSize = Math.max(1, Number(chunkSize || 0))
|
|
const { state, dispatch, previewUrl, startUpload, cancelUpload } = useUploadMachine({ draftId, filesCdnUrl, chunkSize: safeChunkSize, userId })
|
|
const fileInputRef = useRef(null)
|
|
const [confirmCancel, setConfirmCancel] = useState(false)
|
|
const [contentTypes, setContentTypes] = useState([])
|
|
const [selectedParentCategory, setSelectedParentCategory] = useState(null)
|
|
|
|
const availableTypes = useMemo(() => (props?.content_types && Array.isArray(props.content_types)) ? props.content_types : contentTypes, [props, contentTypes])
|
|
const selectedType = useMemo(
|
|
() => availableTypes.find((t) => String(t.id) === String(state.metadata.type)),
|
|
[availableTypes, state.metadata.type]
|
|
)
|
|
const categoryOptions = useMemo(() => selectedType?.categories || [], [selectedType])
|
|
const hasAtLeastOneTag = useMemo(() => parseUiTags(state.metadata.tags).length > 0, [state.metadata.tags])
|
|
|
|
useEffect(() => {
|
|
// Prefer server-provided props, else try fetching from API endpoints
|
|
if (props?.content_types && Array.isArray(props.content_types)) {
|
|
setContentTypes(props.content_types)
|
|
return
|
|
}
|
|
|
|
let mounted = true
|
|
;(async () => {
|
|
try {
|
|
const res = await window.axios.get('/api/content-types')
|
|
if (!mounted) return
|
|
setContentTypes(res.data || [])
|
|
} catch (e) {
|
|
// ignore, content types optional here
|
|
}
|
|
})()
|
|
|
|
return () => { mounted = false }
|
|
}, [props])
|
|
|
|
const onFileSelect = useCallback((file) => {
|
|
if (!file) return
|
|
const preview = URL.createObjectURL(file)
|
|
dispatch({ type: 'SET_FILE', file, previewUrl: preview })
|
|
}, [dispatch])
|
|
|
|
const onDrop = useCallback((event) => {
|
|
event.preventDefault()
|
|
const file = event.dataTransfer.files?.[0]
|
|
onFileSelect(file)
|
|
}, [onFileSelect])
|
|
|
|
const onBrowse = useCallback(() => {
|
|
fileInputRef.current?.click()
|
|
}, [])
|
|
|
|
const onChange = useCallback((event) => {
|
|
const file = event.target.files?.[0]
|
|
onFileSelect(file)
|
|
}, [onFileSelect])
|
|
|
|
const statusLabel = state.pipelineStatus || state.phase
|
|
|
|
useEffect(() => {
|
|
if (!confirmCancel) return
|
|
const timer = window.setTimeout(() => {
|
|
setConfirmCancel(false)
|
|
}, 5000)
|
|
return () => window.clearTimeout(timer)
|
|
}, [confirmCancel])
|
|
|
|
useEffect(() => {
|
|
if (!state.cancelledAt) return
|
|
const timer = window.setTimeout(() => {
|
|
dispatch({ type: 'CLEAR_CANCELLED' })
|
|
}, 3500)
|
|
return () => window.clearTimeout(timer)
|
|
}, [state.cancelledAt, dispatch])
|
|
|
|
return (
|
|
<section className="min-h-[calc(100vh-4rem)] bg-gradient-to-b from-[#0d0f1b] via-[#111827] to-[#0b0c10] py-10 px-4">
|
|
<div className="max-w-6xl mx-auto">
|
|
{state.notices.length > 0 && (
|
|
<div className="mb-6 space-y-2">
|
|
{state.notices.map((notice) => (
|
|
<div
|
|
key={notice.id}
|
|
role="alert"
|
|
aria-live="polite"
|
|
className={`rounded-xl border px-4 py-3 text-sm ${notice.type === 'error'
|
|
? 'border-red-500/40 bg-red-500/10 text-red-100'
|
|
: notice.type === 'warning'
|
|
? 'border-amber-400/40 bg-amber-400/10 text-amber-100'
|
|
: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'}`}
|
|
>
|
|
{notice.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="grid gap-8 lg:grid-cols-[1.1fr,0.9fr]">
|
|
<div className="space-y-6">
|
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_0_50px_rgba(59,130,246,0.15)]">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-3xl font-semibold tracking-tight">Upload artwork</h1>
|
|
<span className="text-xs uppercase tracking-[0.25em] text-sky-300">Secure pipeline</span>
|
|
</div>
|
|
<p className="mt-2 text-sm text-white/70">All uploads are scanned, re-encoded, and published through the Skinbase pipeline.</p>
|
|
|
|
<div
|
|
className="mt-6 rounded-2xl border border-dashed border-white/30 bg-white/5 p-6 text-center transition hover:border-sky-400/60"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={onDrop}
|
|
>
|
|
<div className="mx-auto h-14 w-14 rounded-full bg-sky-500/20 grid place-items-center text-sky-200">
|
|
<i className="fa-solid fa-cloud-arrow-up text-xl" aria-hidden="true"></i>
|
|
</div>
|
|
<h2 className="mt-3 text-lg font-medium">Drag & drop your file</h2>
|
|
<p className="text-sm text-white/60">JPG, PNG, or WebP. Up to 50MB.</p>
|
|
<button type="button" onClick={onBrowse} className="mt-4 inline-flex items-center gap-2 rounded-full bg-sky-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/30">
|
|
Browse files
|
|
</button>
|
|
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={onChange} />
|
|
</div>
|
|
|
|
{state.file && (
|
|
<div className="mt-6 flex items-center gap-4">
|
|
<div className="h-20 w-20 overflow-hidden rounded-xl border border-white/10">
|
|
<img src={previewUrl} alt="Preview" className="h-full w-full object-cover" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-semibold">{state.file.name}</div>
|
|
<div className="text-xs text-white/60">{(state.file.size / 1024 / 1024).toFixed(2)} MB</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
|
<label className="text-sm sm:col-span-2">
|
|
<span className="text-white/80">Title</span>
|
|
<input
|
|
value={state.metadata.title}
|
|
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { title: e.target.value } })}
|
|
className="mt-2 w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
|
placeholder="Name your artwork"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-6 rounded-2xl border border-white/10 bg-gradient-to-br from-white/5 via-white/0 to-white/5 p-4 sm:p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<div className="text-sm font-semibold text-white">Choose a type</div>
|
|
<div className="text-xs text-white/60">Step 1: Pick what kind of artwork this is.</div>
|
|
</div>
|
|
<span className="rounded-full border border-sky-400/40 bg-sky-400/10 px-3 py-1 text-[11px] uppercase tracking-[0.2em] text-sky-200">Type</span>
|
|
</div>
|
|
|
|
{availableTypes.length === 0 ? (
|
|
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
|
|
No content types available.
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{availableTypes.map((ct) => {
|
|
const active = String(ct.id) === String(state.metadata.type)
|
|
const iconKey = getTypeKey(ct)
|
|
const iconPath = `/gfx/mascot_${iconKey}.webp`
|
|
return (
|
|
<button
|
|
key={ct.id}
|
|
type="button"
|
|
onClick={() => dispatch({ type: 'SET_METADATA', payload: { type: String(ct.id), category: '' } })}
|
|
className={`group rounded-2xl border px-4 py-3 text-left transition ${active
|
|
? 'border-emerald-400/60 bg-emerald-500/10 shadow-[0_0_25px_rgba(16,185,129,0.25)]'
|
|
: 'border-white/10 bg-white/5 hover:border-sky-400/40 hover:bg-white/10'}`}
|
|
aria-pressed={active}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`h-14 w-10 rounded-xl text-sm font-semibold overflow-hidden relative ${active ? '' : ''}`}>
|
|
<img
|
|
src={iconPath}
|
|
alt={`${ct.name} icon`}
|
|
className="absolute inset-0 h-full w-full object-contain object-center z-0"
|
|
onLoad={(e) => {
|
|
const letter = document.getElementById(`type-letter-${ct.id}`)
|
|
if (letter) letter.style.display = 'none'
|
|
}}
|
|
onError={(e) => {
|
|
e.currentTarget.style.display = 'none'
|
|
const letter = document.getElementById(`type-letter-${ct.id}`)
|
|
if (letter) letter.style.display = 'grid'
|
|
}}
|
|
/>
|
|
<div id={`type-letter-${ct.id}`} className={`relative z-10 grid h-14 w-10 place-items-center ${active ? 'text-emerald-200' : 'text-white/70'}`}>
|
|
{ct.name?.slice(0, 1)?.toUpperCase() || '?'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-semibold text-white">{ct.name}</div>
|
|
<div className="text-xs text-white/60">Select to reveal categories</div>
|
|
</div>
|
|
</div>
|
|
<div className={`mt-3 h-1 w-full rounded-full ${active ? 'bg-emerald-400/80' : 'bg-white/10 group-hover:bg-sky-400/40'}`}></div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4 sm:p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<div className="text-sm font-semibold text-white">Choose a category</div>
|
|
<div className="text-xs text-white/60">Step 2: Pick a subcategory inside the type.</div>
|
|
</div>
|
|
<span className="rounded-full border border-purple-400/40 bg-purple-400/10 px-3 py-1 text-[11px] uppercase tracking-[0.2em] text-purple-200">Category</span>
|
|
</div>
|
|
|
|
{!selectedType ? (
|
|
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
|
|
Select a type first to see categories.
|
|
</div>
|
|
) : categoryOptions.length === 0 ? (
|
|
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
|
|
No categories available for {selectedType.name}.
|
|
</div>
|
|
) : (
|
|
<div className="mt-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
{categoryOptions.map((cat) => {
|
|
const isSelected = String(cat.id) === String(state.metadata.category)
|
|
const isExpanded = String(cat.id) === String(selectedParentCategory)
|
|
const hasChildren = Array.isArray(cat.children) && cat.children.length > 0
|
|
return (
|
|
<button
|
|
key={cat.id}
|
|
type="button"
|
|
onClick={() => {
|
|
if (hasChildren) {
|
|
setSelectedParentCategory(String(cat.id))
|
|
dispatch({ type: 'SET_METADATA', payload: { category: '' } })
|
|
return
|
|
}
|
|
setSelectedParentCategory(null)
|
|
dispatch({ type: 'SET_METADATA', payload: { category: String(cat.id) } })
|
|
}}
|
|
className={`rounded-full border px-4 py-2 text-sm transition ${isSelected || isExpanded
|
|
? 'border-purple-300/60 bg-purple-400/20 text-purple-100'
|
|
: 'border-white/10 bg-white/5 text-white/70 hover:border-purple-300/50 hover:bg-purple-400/10'}`}
|
|
aria-pressed={isSelected || isExpanded}
|
|
>
|
|
{cat.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{selectedParentCategory && (() => {
|
|
const parent = categoryOptions.find((c) => String(c.id) === String(selectedParentCategory))
|
|
if (!parent || !Array.isArray(parent.children) || parent.children.length === 0) return null
|
|
return (
|
|
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-semibold text-white">Subcategories for {parent.name}</div>
|
|
<div className="text-xs text-white/60">Choose a subcategory below</div>
|
|
</div>
|
|
<button type="button" onClick={() => setSelectedParentCategory(null)} className="text-sm text-white/60">Back</button>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{parent.children.map((child) => {
|
|
const activeChild = String(child.id) === String(state.metadata.category)
|
|
return (
|
|
<button
|
|
key={child.id}
|
|
type="button"
|
|
onClick={() => {
|
|
// Select child but keep parent expanded so the panel remains visible
|
|
dispatch({ type: 'SET_METADATA', payload: { category: String(child.id) } })
|
|
}}
|
|
className={`rounded-full border px-4 py-2 text-sm transition ${activeChild
|
|
? 'border-purple-300/60 bg-purple-400/20 text-purple-100'
|
|
: 'border-white/10 bg-white/5 text-white/70 hover:border-purple-300/50 hover:bg-purple-400/10'}`}
|
|
aria-pressed={activeChild}
|
|
>
|
|
{child.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<span className="text-sm text-white/80">Tags</span>
|
|
<div className="mt-2">
|
|
<TagInput
|
|
value={state.metadata.tags}
|
|
onChange={(nextTags) => {
|
|
dispatch({ type: 'SET_METADATA', payload: { tags: nextTags.join(', ') } })
|
|
}}
|
|
suggestedTags={suggestedTags}
|
|
maxTags={15}
|
|
minLength={2}
|
|
maxLength={32}
|
|
searchEndpoint="/api/tags/search"
|
|
popularEndpoint="/api/tags/popular"
|
|
placeholder="Type tags (e.g. cyberpunk, city)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="mt-4 block text-sm">
|
|
<span className="text-white/80">Description</span>
|
|
<textarea
|
|
value={state.metadata.description}
|
|
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { description: e.target.value } })}
|
|
className="mt-2 w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
|
rows={4}
|
|
placeholder="Tell the story behind this artwork."
|
|
/>
|
|
</label>
|
|
|
|
<div className="mt-4">
|
|
<Checkbox
|
|
checked={state.metadata.licenseAccepted}
|
|
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { licenseAccepted: e.target.checked } })}
|
|
size={16}
|
|
variant="emerald"
|
|
label="I confirm I own the rights to this artwork."
|
|
/>
|
|
</div>
|
|
|
|
{state.cancelledAt && (
|
|
<div role="alert" aria-live="polite" className="mt-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
|
Upload cancelled.
|
|
</div>
|
|
)}
|
|
|
|
{state.error && (
|
|
<div className="mt-4 rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-100">
|
|
{state.error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={startUpload}
|
|
disabled={
|
|
!state.file ||
|
|
!state.metadata.title.trim() ||
|
|
!state.metadata.type ||
|
|
!state.metadata.category ||
|
|
!hasAtLeastOneTag ||
|
|
!state.metadata.description.trim() ||
|
|
!state.metadata.licenseAccepted
|
|
}
|
|
className={`inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-semibold text-white ${(!state.file || !state.metadata.title.trim() || !state.metadata.type || !state.metadata.category || !hasAtLeastOneTag || !state.metadata.description.trim() || !state.metadata.licenseAccepted) ? 'bg-white/10 cursor-not-allowed' : 'bg-emerald-500 shadow-lg shadow-emerald-500/30'}`}
|
|
>
|
|
<i className="fa-solid fa-rocket" aria-hidden="true"></i>
|
|
Start upload
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!confirmCancel) {
|
|
setConfirmCancel(true)
|
|
return
|
|
}
|
|
setConfirmCancel(false)
|
|
cancelUpload()
|
|
}}
|
|
className="inline-flex items-center gap-2 rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-100"
|
|
disabled={!state.sessionId || state.phase === phases.success}
|
|
aria-pressed={confirmCancel}
|
|
>
|
|
<i className="fa-solid fa-ban" aria-hidden="true"></i>
|
|
{confirmCancel ? 'Confirm cancel' : 'Cancel'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (userId) {
|
|
clearStoredSession(userId)
|
|
}
|
|
dispatch({ type: 'RESET' })
|
|
}}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-sm text-white/70"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
|
|
<h3 className="text-lg font-semibold">Pipeline status</h3>
|
|
<p className="mt-2 text-sm text-white/60">Stage: <span className="text-white">{statusLabel}</span></p>
|
|
|
|
<div className="mt-4 h-2 w-full overflow-hidden rounded-full bg-white/10">
|
|
<div className="h-full bg-sky-400 transition-all" style={{ width: `${state.progress}%` }}></div>
|
|
</div>
|
|
|
|
{state.failureReason && (
|
|
<div className="mt-3 text-sm text-red-200">Failure: {state.failureReason}</div>
|
|
)}
|
|
|
|
{state.previewUrl && state.phase === phases.success && (
|
|
<div className="mt-6">
|
|
<h4 className="text-sm font-semibold text-white/80">CDN preview</h4>
|
|
<div className="mt-2 overflow-hidden rounded-xl border border-white/10">
|
|
<img src={state.previewUrl} alt="CDN preview" className="h-56 w-full object-cover" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-xs text-white/60">
|
|
Session: {state.sessionId ?? '—'}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
|
|
<h3 className="text-lg font-semibold">Draft resume</h3>
|
|
<p className="mt-2 text-sm text-white/60">Use the draft link to resume an interrupted upload.</p>
|
|
{draftId ? (
|
|
<div className="mt-4 text-sm text-white/80">Draft ID: {draftId}</div>
|
|
) : (
|
|
<div className="mt-4 text-sm text-white/50">No draft loaded.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|