Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View 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>
)
}