Files
SkinbaseNova/resources/js/components/upload/ScreenshotUploader.jsx
Gregor Klevze 1266f81d35 feat: upload wizard refactor + vision AI tags + artwork versioning
Upload wizard:
- Refactored UploadWizard into modular steps (Step1FileUpload, Step2Details, Step3Publish)
- Extracted reusable hooks: useUploadMachine, useFileValidation, useVisionTags
- Extracted reusable components: CategorySelector, ContentTypeSelector
- Added TagPicker component (studio-style list picker with AI badge + new-tag insertion)
- Fixed TagInput auto-open bug (hasFocusedRef guard)
- Replaced TagInput with TagPicker in UploadSidebar

Vision AI tag suggestions:
- Add UploadVisionSuggestController: sync POST /api/uploads/{id}/vision-suggest
- Calls vision.klevze.net/analyze/all on upload completion (before step 2 opens)
- Two-phase useVisionTags: immediate gateway call + background DB polling
- Trigger fires on uploadReady (not step change) so tags arrive before user sees step 2
- Added vision.gateway config block with VISION_GATEWAY_URL env

Artwork versioning system:
- ArtworkVersion / ArtworkVersionEvent models
- ArtworkVersioningService: createNewVersion, restoreVersion, rate limiting, ranking decay
- Migrations: artwork_versions, artwork_version_events, versioning columns on artworks
- Studio API routes: GET versions, POST restore/{version_id}
- Feature tests: ArtworkVersioningTest (13 cases)
2026-03-01 14:56:46 +01:00

165 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ring-1 ring-white/10 bg-white/5 p-2 text-xs"
>
<div className="flex h-40 w-40 items-center justify-center overflow-hidden rounded-md ring-1 ring-white/10 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>
)
}