234 lines
7.9 KiB
JavaScript
234 lines
7.9 KiB
JavaScript
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>
|
||
)
|
||
} |