165 lines
6.9 KiB
JavaScript
165 lines
6.9 KiB
JavaScript
import React, { useEffect, useMemo, useRef } from 'react'
|
||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||
|
||
export default function ScreenshotUploader({
|
||
title = 'Archive screenshots',
|
||
description = 'Screenshot requirement placeholder for archive uploads',
|
||
visible = false,
|
||
files = [],
|
||
perFileErrors = [],
|
||
errors = [],
|
||
invalid = false,
|
||
showLooksGood = false,
|
||
looksGoodText = 'Looks good',
|
||
onFilesChange,
|
||
min = 1,
|
||
max = 5,
|
||
}) {
|
||
const inputRef = useRef(null)
|
||
const prefersReducedMotion = useReducedMotion()
|
||
|
||
const quickTransition = prefersReducedMotion
|
||
? { duration: 0 }
|
||
: { duration: 0.2, ease: 'easeOut' }
|
||
|
||
const previewItems = useMemo(() => files.map((file) => ({
|
||
file,
|
||
url: URL.createObjectURL(file),
|
||
})), [files])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
previewItems.forEach((item) => URL.revokeObjectURL(item.url))
|
||
}
|
||
}, [previewItems])
|
||
|
||
if (!visible) return null
|
||
|
||
const emitFiles = (fileList, merge = false) => {
|
||
const incoming = Array.from(fileList || [])
|
||
const next = merge ? [...files, ...incoming] : incoming
|
||
if (typeof onFilesChange === 'function') {
|
||
onFilesChange(next.slice(0, max))
|
||
}
|
||
}
|
||
|
||
const removeAt = (index) => {
|
||
const next = files.filter((_, idx) => idx !== index)
|
||
if (typeof onFilesChange === 'function') {
|
||
onFilesChange(next)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_28px_rgba(0,0,0,0.3)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/40' : 'border-amber-300/25 from-amber-500/10 to-slate-900/40'}`}>
|
||
{/* Intended props: screenshots, minResolution, maxFileSizeMb, required, onChange, onRemove, error */}
|
||
<div className={`rounded-lg border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-amber-300/30 bg-black/20'}`}>
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<h3 className="text-lg font-semibold text-amber-100">{title} <span className="text-red-200">(Required)</span></h3>
|
||
<span className="rounded-full border border-amber-200/35 bg-amber-500/15 px-2.5 py-1 text-xs text-amber-100">{Math.min(files.length, max)}/{max} screenshots</span>
|
||
</div>
|
||
<p className="mt-1 text-sm text-amber-100/85">{description}</p>
|
||
|
||
<div className="mt-3 rounded-lg border border-amber-200/20 bg-amber-500/10 px-3 py-3 text-xs text-amber-50/90">
|
||
<p className="font-semibold">Why we need screenshots</p>
|
||
<p className="mt-1">Screenshots provide a visual thumbnail and help AI analysis/moderation before archive contents are published.</p>
|
||
<p className="mt-2 text-amber-100/85">Rules: JPG/PNG/WEBP · 1280×720 minimum · 10MB max each · {min} to {max} files.</p>
|
||
</div>
|
||
|
||
<div
|
||
className={`mt-3 rounded-lg border-2 border-dashed p-4 text-center transition-colors ${invalid ? 'border-red-300/45 bg-red-500/10' : 'border-white/20 bg-white/5 hover:border-amber-300/45 hover:bg-amber-500/5'}`}
|
||
onDragOver={(event) => event.preventDefault()}
|
||
onDrop={(event) => {
|
||
event.preventDefault()
|
||
emitFiles(event.dataTransfer?.files, true)
|
||
}}
|
||
>
|
||
<p className="text-sm text-white/85">Drop screenshots here or click to browse</p>
|
||
<button
|
||
type="button"
|
||
className="btn-secondary mt-2 text-xs"
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
Browse screenshots
|
||
</button>
|
||
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
className="hidden"
|
||
aria-label="Screenshot file input"
|
||
multiple
|
||
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
|
||
onChange={(event) => emitFiles(event.target.files, true)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-3 text-xs text-white/70">
|
||
{files.length} selected · minimum {min}, maximum {max}
|
||
</div>
|
||
|
||
{showLooksGood && (
|
||
<div className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
|
||
<span aria-hidden="true">✓</span>
|
||
<span>{looksGoodText}</span>
|
||
</div>
|
||
)}
|
||
|
||
{previewItems.length > 0 && (
|
||
<ul className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<AnimatePresence initial={false}>
|
||
{previewItems.map((item, index) => (
|
||
<motion.li
|
||
layout={!prefersReducedMotion}
|
||
key={`${item.file.name}-${index}`}
|
||
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.96 }}
|
||
animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }}
|
||
exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }}
|
||
transition={quickTransition}
|
||
className="rounded-lg border border-white/50 bg-white/5 p-2 text-xs"
|
||
>
|
||
<div className="flex h-40 w-40 items-center justify-center overflow-hidden rounded-md border border-white/50 bg-black/25">
|
||
<img
|
||
src={item.url}
|
||
alt={`Screenshot ${index + 1}`}
|
||
className="max-h-full max-w-full object-contain"
|
||
loading="lazy"
|
||
decoding="async"
|
||
width="160"
|
||
height="160"
|
||
/>
|
||
</div>
|
||
<div className="mt-2 truncate text-white/90">{item.file.name}</div>
|
||
<div className="mt-1 text-white/55">{Math.round(item.file.size / 1024)} KB</div>
|
||
{perFileErrors[index] && <div className="mt-1 rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1 text-red-200">{perFileErrors[index]}</div>}
|
||
<button
|
||
type="button"
|
||
onClick={() => removeAt(index)}
|
||
className="mt-2 rounded-md border border-white/20 bg-white/5 px-2.5 py-1 text-[11px] text-white/80 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
|
||
>
|
||
Remove
|
||
</button>
|
||
</motion.li>
|
||
))}
|
||
</AnimatePresence>
|
||
</ul>
|
||
)}
|
||
|
||
{errors.length > 0 && (
|
||
<ul className="mt-3 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
|
||
{errors.map((error, index) => (
|
||
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
|
||
{error}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{invalid && (
|
||
<p className="mt-3 text-xs text-red-200">Continue is blocked until screenshot requirements are valid.</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|