Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View File

@@ -0,0 +1,156 @@
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="mt-2 rounded-md border border-white/20 bg-white/10 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
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="overflow-hidden rounded-md border border-white/50 bg-black/25">
<img src={item.url} alt={`Screenshot ${index + 1}`} className="h-24 w-full object-cover" />
</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>
)
}