Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
209 lines
8.7 KiB
JavaScript
209 lines
8.7 KiB
JavaScript
import React, { useRef, useState } from 'react'
|
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
|
|
|
function getExtension(fileName = '') {
|
|
const parts = String(fileName).toLowerCase().split('.')
|
|
return parts.length > 1 ? parts.pop() : ''
|
|
}
|
|
|
|
function detectPrimaryType(file) {
|
|
if (!file) return 'unknown'
|
|
|
|
const extension = getExtension(file.name)
|
|
const mime = String(file.type || '').toLowerCase()
|
|
|
|
const imageExt = new Set(['jpg', 'jpeg', 'png', 'webp'])
|
|
const archiveExt = new Set(['zip', 'rar', '7z', 'tar', 'gz'])
|
|
|
|
const imageMime = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
|
const archiveMime = new Set([
|
|
'application/zip',
|
|
'application/x-zip-compressed',
|
|
'application/x-rar-compressed',
|
|
'application/vnd.rar',
|
|
'application/x-7z-compressed',
|
|
'application/x-tar',
|
|
'application/gzip',
|
|
'application/x-gzip',
|
|
'application/octet-stream',
|
|
])
|
|
|
|
if (imageMime.has(mime) || imageExt.has(extension)) return 'image'
|
|
if (archiveMime.has(mime) || archiveExt.has(extension)) return 'archive'
|
|
return 'unsupported'
|
|
}
|
|
|
|
export default function UploadDropzone({
|
|
title = 'Upload file',
|
|
description = 'Drop file here or click to browse',
|
|
fileName = '',
|
|
fileHint = 'No file selected yet',
|
|
previewUrl = '',
|
|
fileMeta = null,
|
|
errors = [],
|
|
invalid = false,
|
|
showLooksGood = false,
|
|
looksGoodText = 'Looks good',
|
|
locked = false,
|
|
onPrimaryFileChange,
|
|
onValidationResult,
|
|
}) {
|
|
const [dragging, setDragging] = useState(false)
|
|
const inputRef = useRef(null)
|
|
const prefersReducedMotion = useReducedMotion()
|
|
|
|
const dragTransition = prefersReducedMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.2, ease: 'easeOut' }
|
|
|
|
const emitFile = (file) => {
|
|
const detectedType = detectPrimaryType(file)
|
|
if (typeof onPrimaryFileChange === 'function') {
|
|
onPrimaryFileChange(file, { detectedType })
|
|
}
|
|
if (typeof onValidationResult === 'function') {
|
|
onValidationResult({ file, detectedType })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className={`rounded-[28px] bg-gradient-to-br p-0 shadow-[0_20px_60px_rgba(0,0,0,0.30)] transition-colors ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/50'}`}>
|
|
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
|
|
<motion.div
|
|
data-testid="upload-dropzone"
|
|
role="button"
|
|
aria-disabled={locked ? 'true' : 'false'}
|
|
tabIndex={locked ? -1 : 0}
|
|
onClick={() => {
|
|
if (locked) return
|
|
inputRef.current?.click()
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (locked) return
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
inputRef.current?.click()
|
|
}
|
|
}}
|
|
onDragOver={(event) => {
|
|
if (locked) return
|
|
event.preventDefault()
|
|
setDragging(true)
|
|
}}
|
|
onDragLeave={() => setDragging(false)}
|
|
onDrop={(event) => {
|
|
if (locked) return
|
|
event.preventDefault()
|
|
setDragging(false)
|
|
const droppedFile = event.dataTransfer?.files?.[0]
|
|
if (droppedFile) emitFile(droppedFile)
|
|
}}
|
|
animate={prefersReducedMotion ? undefined : { scale: dragging ? 1.01 : 1 }}
|
|
transition={dragTransition}
|
|
className={`group rounded-[26px] border-2 border-dashed border-white/15 px-5 py-7 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 sm:px-6 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.02))] hover:bg-sky-500/12'}`}
|
|
>
|
|
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
|
|
<p className="mt-1 text-xs text-soft">{description}</p>
|
|
|
|
{previewUrl ? (
|
|
<div className="mt-2 w-full flex flex-col items-center gap-2">
|
|
<div className="flex h-52 w-64 items-center justify-center overflow-hidden rounded-lg bg-black/40 ring-1 ring-white/10">
|
|
<img
|
|
src={previewUrl}
|
|
alt="Selected preview"
|
|
className="h-full w-full object-contain object-center"
|
|
loading="lazy"
|
|
decoding="async"
|
|
width="250"
|
|
height="208"
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-white/70">Click to replace</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl border border-sky-400/40 bg-sky-500/12 text-sky-100 shadow-[0_14px_40px_rgba(14,165,233,0.18)]">
|
|
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
|
|
<path d="M7 10l5-5 5 5" />
|
|
<path d="M12 5v10" />
|
|
</svg>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 text-[11px] text-white/65">
|
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">JPG, PNG, WEBP</span>
|
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">ZIP, RAR, 7Z</span>
|
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">50MB images</span>
|
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">200MB archives</span>
|
|
</div>
|
|
|
|
<span className={`btn-secondary mt-4 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
|
|
Click to browse files
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
className="hidden"
|
|
aria-label="Upload file input"
|
|
disabled={locked}
|
|
accept=".jpg,.jpeg,.png,.webp,.zip,.rar,.7z,.tar,.gz,image/jpeg,image/png,image/webp"
|
|
onChange={(event) => {
|
|
const selectedFile = event.target.files?.[0]
|
|
if (selectedFile) {
|
|
emitFile(selectedFile)
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
|
|
<div className="mt-4 rounded-2xl ring-1 ring-white/10 bg-black/25 px-4 py-3 text-left text-xs text-white/80">
|
|
<div className="font-medium text-white/85">Selected file</div>
|
|
<div className="mt-1 truncate">{fileName || fileHint}</div>
|
|
{fileMeta && (
|
|
<div className="mt-1 flex flex-wrap gap-2 text-xs text-white/60">
|
|
<span>Type: <span className="text-white/80">{fileMeta.type || '—'}</span></span>
|
|
<span>·</span>
|
|
<span>Size: <span className="text-white/80">{fileMeta.size || '—'}</span></span>
|
|
<span>·</span>
|
|
<span>Resolution: <span className="text-white/80">{fileMeta.resolution || '—'}</span></span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showLooksGood && (
|
|
<div className="mt-3 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>
|
|
)}
|
|
|
|
<AnimatePresence initial={false}>
|
|
{errors.length > 0 && (
|
|
<motion.div
|
|
key="dropzone-errors"
|
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
|
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
|
|
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
|
transition={dragTransition}
|
|
className="mt-4 rounded-lg border border-red-300/40 bg-red-500/10 p-3 text-left"
|
|
>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-red-100">Please fix the following</p>
|
|
<ul className="mt-2 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>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
</section>
|
|
)
|
|
}
|