Save workspace changes
This commit is contained in:
231
resources/js/components/worlds/editor/WorldMediaUploadField.jsx
Normal file
231
resources/js/components/worlds/editor/WorldMediaUploadField.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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(
|
||||
() => 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user