Files
SkinbaseNova/resources/js/components/worlds/editor/WorldMediaUploadField.jsx

234 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo, useRef, useState } from 'react'
function formatBytes(bytes) {
const value = Number(bytes || 0)
if (!Number.isFinite(value) || value <= 0) return null
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
return `${(value / (1024 * 1024)).toFixed(1)} MB`
}
export default function WorldMediaUploadField({
label,
slot,
value,
previewUrl,
emptyLabel,
helperText,
uploadUrl,
deleteUrl,
worldId = null,
onChange,
isTemporaryValue = false,
accept = 'image/jpeg,image/png,image/webp',
maxFileSizeMb = 6,
}) {
const inputRef = useRef(null)
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [meta, setMeta] = useState(null)
const csrfToken = useMemo(() => {
if (typeof document === 'undefined') {
return ''
}
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}, [])
const deleteTemporaryUpload = async (path) => {
if (!deleteUrl || !path) return
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
path,
world_id: worldId || undefined,
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Could not remove uploaded image.')
}
}
const handleFile = async (file) => {
if (!file || uploading) return
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (!allowed.includes(String(file.type || '').toLowerCase())) {
setError('Use a JPG, PNG, or WEBP image.')
return
}
if (file.size > maxFileSizeMb * 1024 * 1024) {
setError(`Image is too large. Maximum allowed size is ${maxFileSizeMb} MB.`)
return
}
setUploading(true)
setError('')
try {
if (value && isTemporaryValue) {
await deleteTemporaryUpload(value)
}
const body = new FormData()
body.append('slot', slot)
body.append('image', file)
if (worldId) {
body.append('world_id', String(worldId))
}
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
credentials: 'same-origin',
body,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Upload failed.')
}
setMeta({
width: payload?.width || null,
height: payload?.height || null,
size: formatBytes(payload?.size_bytes),
})
onChange?.({ path: payload?.path || '', url: payload?.url || '' })
} catch (uploadError) {
setError(uploadError?.message || 'Upload failed.')
} finally {
setUploading(false)
if (inputRef.current) {
inputRef.current.value = ''
}
}
}
return (
<div className="grid gap-3 text-sm text-slate-300">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
{value ? (
<button
type="button"
onClick={async (event) => {
event.stopPropagation()
setError('')
setMeta(null)
try {
if (value && isTemporaryValue) {
setUploading(true)
await deleteTemporaryUpload(value)
}
onChange?.({ path: '', url: '' })
} catch (deleteError) {
setError(deleteError?.message || 'Could not remove uploaded image.')
} finally {
setUploading(false)
}
}}
disabled={uploading}
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white"
>
Clear
</button>
) : null}
</div>
<div
role="button"
tabIndex={0}
onClick={() => !uploading && inputRef.current?.click()}
onKeyDown={(event) => {
if (uploading) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
inputRef.current?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
if (!uploading) setDragging(true)
}}
onDragEnter={(event) => {
event.preventDefault()
if (!uploading) setDragging(true)
}}
onDragLeave={(event) => {
event.preventDefault()
setDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
void handleFile(event.dataTransfer?.files?.[0])
}}
className={[
'rounded-[24px] border border-dashed px-5 py-5 transition outline-none',
uploading
? 'cursor-progress border-sky-300/35 bg-sky-400/10'
: dragging
? 'cursor-pointer border-sky-300/50 bg-sky-400/12'
: 'cursor-pointer border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
].join(' ')}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
<i className={`fa-solid ${uploading ? 'fa-circle-notch fa-spin' : 'fa-cloud-arrow-up'}`} />
</div>
<div>
<div className="text-sm font-semibold text-white">{uploading ? 'Uploading image…' : 'Drop image here or browse'}</div>
<div className="mt-1 text-xs leading-5 text-slate-400">{helperText}</div>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max {maxFileSizeMb} MB</span>
</div>
</div>
</div>
<div className="h-28 w-full overflow-hidden rounded-[20px] border border-white/10 bg-slate-950 lg:w-44">
{previewUrl ? (
<img src={previewUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-500">{emptyLabel}</div>
)}
</div>
</div>
{value ? <div className="mt-4 truncate rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-400">Stored path: <span className="text-slate-200">{value}</span></div> : null}
{meta ? <div className="mt-3 text-xs text-slate-400">Optimized to {meta.width}×{meta.height}{meta.size ? `${meta.size}` : ''}</div> : null}
{error ? <div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
disabled={uploading}
onChange={(event) => {
void handleFile(event.target.files?.[0])
}}
/>
</div>
</div>
)
}