Upload beautify
This commit is contained in:
130
resources/js/components/uploads/ScreenshotUploader.jsx
Normal file
130
resources/js/components/uploads/ScreenshotUploader.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
function toImageFiles(files) {
|
||||
return Array.from(files || []).filter((file) => String(file.type || '').startsWith('image/'))
|
||||
}
|
||||
|
||||
export default function ScreenshotUploader({
|
||||
files = [],
|
||||
onChange,
|
||||
min = 1,
|
||||
max = 5,
|
||||
required = false,
|
||||
error = '',
|
||||
}) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const previews = useMemo(
|
||||
() => files.map((file) => ({ file, url: window.URL.createObjectURL(file) })),
|
||||
[files]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previews.forEach((preview) => window.URL.revokeObjectURL(preview.url))
|
||||
}
|
||||
}, [previews])
|
||||
|
||||
const mergeFiles = (incomingFiles) => {
|
||||
const next = [...files, ...toImageFiles(incomingFiles)].slice(0, max)
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
const replaceFiles = (incomingFiles) => {
|
||||
const next = toImageFiles(incomingFiles).slice(0, max)
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAt = (index) => {
|
||||
const next = files.filter((_, idx) => idx !== index)
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
const move = (from, to) => {
|
||||
if (to < 0 || to >= files.length) return
|
||||
const next = [...files]
|
||||
const [picked] = next.splice(from, 1)
|
||||
next.splice(to, 0, picked)
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<label className="mb-2 block text-sm text-white/80">
|
||||
Archive screenshots {required ? <span className="text-rose-200">(required)</span> : null}
|
||||
</label>
|
||||
|
||||
<div
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
mergeFiles(event.dataTransfer?.files)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
className={`rounded-xl border-2 border-dashed p-3 text-center text-xs transition ${dragging ? 'border-sky-300 bg-sky-500/10' : 'border-white/20 bg-white/5'}`}
|
||||
>
|
||||
<p className="text-white/80">Drag & drop screenshots here</p>
|
||||
<p className="mt-1 text-white/55">Minimum {min}, maximum {max}</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
aria-label="Archive screenshots input"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="mt-3 block w-full text-xs text-white/80"
|
||||
onChange={(event) => replaceFiles(event.target.files)}
|
||||
/>
|
||||
|
||||
{error ? <p className="mt-2 text-xs text-rose-200">{error}</p> : null}
|
||||
|
||||
{previews.length > 0 ? (
|
||||
<ul className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{previews.map((preview, index) => (
|
||||
<li key={`${preview.file.name}-${index}`} className="rounded-lg border border-white/10 bg-black/20 p-2">
|
||||
<img src={preview.url} alt={`Screenshot ${index + 1}`} className="h-20 w-full rounded object-cover" />
|
||||
<div className="mt-2 truncate text-[11px] text-white/70">{preview.file.name}</div>
|
||||
<div className="mt-2 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
className="rounded border border-white/20 px-2 py-1 text-[11px] text-white disabled:opacity-40"
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === previews.length - 1}
|
||||
className="rounded border border-white/20 px-2 py-1 text-[11px] text-white disabled:opacity-40"
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(index)}
|
||||
className="rounded border border-rose-300/40 px-2 py-1 text-[11px] text-rose-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user