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)
This commit is contained in:
2026-03-01 14:56:46 +01:00
parent a875203482
commit 1266f81d35
33 changed files with 3710 additions and 1298 deletions

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\TagNormalizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Synchronous Vision tag suggestions for the upload wizard.
*
* POST /api/uploads/{id}/vision-suggest
*
* Calls the Vision gateway (/analyze/all) synchronously and returns
* normalised tag suggestions immediately without going through the queue.
* The queue-based AutoTagArtworkJob still runs in the background and writes
* to the DB; this endpoint gives the user instant pre-fill on Step 2.
*/
final class UploadVisionSuggestController extends Controller
{
public function __construct(
private readonly TagNormalizer $normalizer,
) {}
public function __invoke(int $id, Request $request): JsonResponse
{
if (! (bool) config('vision.enabled', true)) {
return response()->json(['tags' => [], 'vision_enabled' => false]);
}
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork);
$imageUrl = $this->buildImageUrl((string) $artwork->hash);
if ($imageUrl === null) {
return response()->json([
'tags' => [],
'vision_enabled' => true,
'reason' => 'image_url_unavailable',
]);
}
$gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', '')));
if ($gatewayBase === '') {
return response()->json([
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_not_configured',
]);
}
$url = rtrim($gatewayBase, '/') . '/analyze/all';
$limit = min(20, max(5, (int) ($request->query('limit', 10))));
$timeout = (int) config('vision.gateway.timeout_seconds', 10);
$cTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
$ref = (string) Str::uuid();
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = Http::acceptJson()
->connectTimeout(max(1, $cTimeout))
->timeout(max(1, $timeout))
->withHeaders(['X-Request-ID' => $ref])
->post($url, [
'url' => $imageUrl,
'limit' => $limit,
]);
if (! $response->ok()) {
Log::warning('vision-suggest: non-ok response', [
'ref' => $ref,
'artwork_id' => $id,
'status' => $response->status(),
'body' => Str::limit($response->body(), 400),
]);
return response()->json([
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_error_' . $response->status(),
]);
}
$tags = $this->parseGatewayResponse($response->json());
return response()->json([
'tags' => $tags,
'vision_enabled' => true,
'source' => 'gateway_sync',
]);
} catch (\Throwable $e) {
Log::warning('vision-suggest: request failed', [
'ref' => $ref,
'artwork_id' => $id,
'error' => $e->getMessage(),
]);
return response()->json([
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_exception',
]);
}
}
// ── helpers ──────────────────────────────────────────────────────────────
private function buildImageUrl(string $hash): ?string
{
$base = rtrim((string) config('cdn.files_url', ''), '/');
if ($base === '') {
return null;
}
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
$clean = str_pad($clean, 6, '0');
$seg = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
$variant = (string) config('vision.image_variant', 'md');
return $base . '/img/' . implode('/', $seg) . '/' . $variant . '.webp';
}
/**
* Parse the /analyze/all gateway response.
*
* The gateway returns a unified object:
* { clip: [{tag, confidence}], blip: ["caption1"], yolo: [{tag, confidence}] }
* or a flat list of tags directly.
*
* @param mixed $json
* @return array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>
*/
private function parseGatewayResponse(mixed $json): array
{
$raw = [];
if (! is_array($json)) {
return [];
}
// Unified gateway response
if (isset($json['clip']) && is_array($json['clip'])) {
foreach ($json['clip'] as $item) {
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'clip'];
}
}
if (isset($json['yolo']) && is_array($json['yolo'])) {
foreach ($json['yolo'] as $item) {
$raw[] = ['tag' => $item['tag'] ?? $item['label'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'yolo'];
}
}
// Flat lists
if ($raw === []) {
$list = $json['tags'] ?? $json['data'] ?? $json;
if (is_array($list)) {
foreach ($list as $item) {
if (is_array($item)) {
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? $item['label'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'vision'];
} elseif (is_string($item)) {
$raw[] = ['tag' => $item, 'confidence' => null, 'source' => 'vision'];
}
}
}
}
// Deduplicate by slug, keep highest confidence
$bySlug = [];
foreach ($raw as $r) {
$slug = $this->normalizer->normalize((string) ($r['tag'] ?? ''));
if ($slug === '') {
continue;
}
$conf = isset($r['confidence']) && is_numeric($r['confidence']) ? (float) $r['confidence'] : null;
if (! isset($bySlug[$slug]) || ($conf !== null && $conf > (float) ($bySlug[$slug]['confidence'] ?? 0))) {
$bySlug[$slug] = [
'name' => ucwords(str_replace(['-', '_'], ' ', $slug)),
'slug' => $slug,
'confidence' => $conf,
'source' => $r['source'] ?? 'vision',
'is_ai' => true,
];
}
}
// Sort by confidence desc
$sorted = array_values($bySlug);
usort($sorted, static fn ($a, $b) => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0));
return $sorted;
}
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
{
if (! $user) {
abort(404);
}
if ((int) $artwork->user_id !== (int) $user->id) {
abort(404);
}
}
}

View File

@@ -5,11 +5,16 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio; namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ArtworkVersion;
use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService; use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService; use App\Services\Studio\StudioBulkActionService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/** /**
* JSON API endpoints for the Studio artwork manager. * JSON API endpoints for the Studio artwork manager.
@@ -19,6 +24,8 @@ final class StudioArtworksApiController extends Controller
public function __construct( public function __construct(
private readonly StudioArtworkQueryService $queryService, private readonly StudioArtworkQueryService $queryService,
private readonly StudioBulkActionService $bulkService, private readonly StudioBulkActionService $bulkService,
private readonly ArtworkVersioningService $versioningService,
private readonly ArtworkSearchIndexer $searchIndexer,
) {} ) {}
/** /**
@@ -274,7 +281,9 @@ final class StudioArtworksApiController extends Controller
/** /**
* POST /api/studio/artworks/{id}/replace-file * POST /api/studio/artworks/{id}/replace-file
* Replace the artwork's primary image file and regenerate derivatives. * Replace the artwork's primary image file creates a new immutable version.
*
* Accepts an optional `change_note` text field alongside the file.
*/ */
public function replaceFile(Request $request, int $id): JsonResponse public function replaceFile(Request $request, int $id): JsonResponse
{ {
@@ -282,42 +291,56 @@ final class StudioArtworksApiController extends Controller
$request->validate([ $request->validate([
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB 'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
'change_note' => 'sometimes|nullable|string|max:500',
]); ]);
// ── Rate-limit gate (before expensive file processing) ────────────
try {
$this->versioningService->rateLimitCheck($request->user()->id, $artwork->id);
} catch (TooManyRequestsHttpException $e) {
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
}
$file = $request->file('file'); $file = $request->file('file');
$tempPath = $file->getRealPath(); $tempPath = $file->getRealPath();
// Compute SHA-256 hash
$hash = hash_file('sha256', $tempPath); $hash = hash_file('sha256', $tempPath);
// Reject identical files early (before any disk writes)
if ($artwork->hash === $hash) {
return response()->json([
'success' => false,
'error' => 'The uploaded file is identical to the current version.',
], 422);
}
try { try {
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class); $derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class); $storage = app(\App\Services\Uploads\UploadStorageService::class);
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class); $artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
// Store original // 1. Store original on disk
$originalPath = $derivatives->storeOriginal($tempPath, $hash); $originalPath = $derivatives->storeOriginal($tempPath, $hash);
$originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp'); $originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp');
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath)); $artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
// Generate public derivatives // 2. Generate public derivatives (thumbnails)
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash); $publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAbsolute as $variant => $absolutePath) { foreach ($publicAbsolute as $variant => $absolutePath) {
$filename = $variant . '.webp'; $relativePath = $storage->publicRelativePath($hash, $variant . '.webp');
$relativePath = $storage->publicRelativePath($hash, $filename);
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath)); $artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
} }
// Get dimensions // 3. Get new dimensions
$dimensions = @getimagesize($tempPath); $dims = @getimagesize($tempPath);
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : $artwork->width; $width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : $artwork->height; $height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
$size = (int) filesize($originalPath);
// Update artwork record // 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
$artwork->update([ $artwork->update([
'file_name' => 'orig.webp', 'file_name' => 'orig.webp',
'file_path' => '', 'file_path' => '',
'file_size' => (int) filesize($originalPath), 'file_size' => $size,
'mime_type' => 'image/webp', 'mime_type' => 'image/webp',
'hash' => $hash, 'hash' => $hash,
'file_ext' => 'webp', 'file_ext' => 'webp',
@@ -326,10 +349,30 @@ final class StudioArtworksApiController extends Controller
'height' => max(1, $height), 'height' => max(1, $height),
]); ]);
// Reindex // 5. Create version record, apply ranking protection, audit log
$version = $this->versioningService->createNewVersion(
$artwork,
$originalRelative,
$hash,
max(1, $width),
max(1, $height),
$size,
$request->user()->id,
$request->input('change_note'),
);
// 6. Reindex in Meilisearch (non-blocking)
try { try {
$artwork->searchable(); $this->searchIndexer->update($artwork);
} catch (\Throwable) {} } catch (\Throwable $e) {
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
}
// 7. CDN cache bust — purge thumbnail paths for the old hash
$this->purgeCdnCache($artwork, $hash);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@@ -338,12 +381,113 @@ final class StudioArtworksApiController extends Controller
'width' => $artwork->width, 'width' => $artwork->width,
'height' => $artwork->height, 'height' => $artwork->height,
'file_size' => $artwork->file_size, 'file_size' => $artwork->file_size,
'version_number' => $version->version_number,
'requires_reapproval' => (bool) $artwork->requires_reapproval,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('replaceFile: processing error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
}
}
/**
* GET /api/studio/artworks/{id}/versions
* Return version history for an artwork (newest first).
*/
public function versions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
return response()->json([ return response()->json([
'success' => false, 'artwork' => [
'error' => 'File processing failed: ' . $e->getMessage(), 'id' => $artwork->id,
], 500); 'title' => $artwork->title,
'version_count' => (int) ($artwork->version_count ?? 1),
],
'versions' => $versions->map(fn (ArtworkVersion $v) => [
'id' => $v->id,
'version_number' => $v->version_number,
'file_path' => $v->file_path,
'file_hash' => $v->file_hash,
'width' => $v->width,
'height' => $v->height,
'file_size' => $v->file_size,
'change_note' => $v->change_note,
'is_current' => $v->is_current,
'created_at' => $v->created_at?->toIso8601String(),
])->values(),
]);
}
/**
* POST /api/studio/artworks/{id}/restore/{version_id}
* Restore an earlier version (cloned as a new current version).
*/
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$version = ArtworkVersion::where('artwork_id', $artwork->id)->findOrFail($versionId);
if ($version->is_current) {
return response()->json(['success' => false, 'error' => 'This version is already the current version.'], 422);
}
try {
$newVersion = $this->versioningService->restoreVersion($version, $artwork, $request->user()->id);
// Sync artwork file fields back to restored version dimensions
$artwork->update([
'width' => max(1, (int) $version->width),
'height' => max(1, (int) $version->height),
'file_size' => (int) $version->file_size,
]);
$artwork->refresh();
// Reindex
try {
$this->searchIndexer->update($artwork);
} catch (\Throwable) {}
return response()->json([
'success' => true,
'version_number' => $newVersion->version_number,
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
]);
} catch (TooManyRequestsHttpException $e) {
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
}
}
/**
* Purge CDN thumbnail cache for the artwork.
*
* This is best-effort; failures are logged but never fatal.
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
*/
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
{
try {
$purgeUrl = config('cdn.purge_url');
if (empty($purgeUrl)) {
Log::debug('CDN purge skipped — cdn.purge_url not configured', ['artwork_id' => $artwork->id]);
return;
}
$paths = array_map(
fn (string $size) => "/thumbs/{$oldHash}/{$size}.webp",
['sm', 'md', 'lg', 'xl']
);
\Illuminate\Support\Facades\Http::timeout(5)->post($purgeUrl, ['paths' => $paths]);
} catch (\Throwable $e) {
Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]);
} }
} }
} }

View File

@@ -97,6 +97,9 @@ final class StudioController extends Controller
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null, 'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(), 'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
// Versioning
'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval,
], ],
'contentTypes' => $this->getCategories(), 'contentTypes' => $this->getCategories(),
]); ]);

View File

@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use App\Services\ThumbnailService; use App\Services\ThumbnailService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
@@ -47,13 +48,20 @@ class Artwork extends Model
'published_at', 'published_at',
'hash', 'hash',
'thumb_ext', 'thumb_ext',
'file_ext' 'file_ext',
// Versioning
'current_version_id',
'version_count',
'version_updated_at',
'requires_reapproval',
]; ];
protected $casts = [ protected $casts = [
'is_public' => 'boolean', 'is_public' => 'boolean',
'is_approved' => 'boolean', 'is_approved' => 'boolean',
'published_at' => 'datetime', 'published_at' => 'datetime',
'version_updated_at' => 'datetime',
'requires_reapproval' => 'boolean',
]; ];
/** /**
@@ -193,6 +201,18 @@ class Artwork extends Model
return $this->hasMany(ArtworkAward::class); return $this->hasMany(ArtworkAward::class);
} }
/** All file versions for this artwork (oldest first). */
public function versions(): HasMany
{
return $this->hasMany(ArtworkVersion::class)->orderBy('version_number');
}
/** The currently active version record. */
public function currentVersion(): BelongsTo
{
return $this->belongsTo(ArtworkVersion::class, 'current_version_id');
}
public function awardStat(): HasOne public function awardStat(): HasOne
{ {
return $this->hasOne(ArtworkAwardStat::class); return $this->hasOne(ArtworkAwardStat::class);

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkVersion
*
* Represents a single immutable snapshot of an artwork file.
* Only one version per artwork is marked is_current = true at a time.
*/
class ArtworkVersion extends Model
{
protected $table = 'artwork_versions';
protected $fillable = [
'artwork_id',
'version_number',
'file_path',
'file_hash',
'width',
'height',
'file_size',
'change_note',
'is_current',
];
protected $casts = [
'is_current' => 'boolean',
'version_number' => 'integer',
'width' => 'integer',
'height' => 'integer',
'file_size' => 'integer',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkVersionEvent
*
* Audit log for all version-related actions (create_version, restore_version).
*/
class ArtworkVersionEvent extends Model
{
protected $table = 'artwork_version_events';
protected $fillable = [
'artwork_id',
'user_id',
'action',
'version_id',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkVersion;
use App\Models\ArtworkVersionEvent;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/**
* ArtworkVersioningService
*
* Manages non-destructive file replacement for artworks.
*
* Guarantees:
* - All replacements create a new version row; originals are never deleted.
* - Engagement (views, favourites, downloads) is never reset.
* - Ranking scores receive a small protective decay on each replacement.
* - Abusive rapid replacement is blocked by rate limits.
* - Major visual changes trigger requires_reapproval flag.
*/
final class ArtworkVersioningService
{
// ── Rate-limit thresholds ─────────────────────────────────────────────
private const MAX_PER_HOUR = 3;
private const MAX_PER_DAY = 10;
// ── Reapproval: flag when dimension changes beyond this fraction ──────
private const DIMENSION_CHANGE_THRESHOLD = 0.5; // 50 % change triggers re-approval
// ── Ranking decay applied per replacement ─────────────────────────────
private const RANKING_DECAY_FACTOR = 0.93; // 7 % decay
// ──────────────────────────────────────────────────────────────────────
/**
* Create a new version for an artwork after a file replacement.
*
* This is the primary entry-point called by the controller.
*
* @param Artwork $artwork The artwork being updated.
* @param string $filePath Relative path stored for this version.
* @param string $fileHash SHA-256 hex hash of the new file.
* @param int $width New file width in pixels.
* @param int $height New file height in pixels.
* @param int $fileSize New file size in bytes.
* @param int $userId ID of the acting user (for audit log).
* @param string|null $changeNote Optional user-supplied change note.
*
* @throws TooManyRequestsHttpException When rate limit is exceeded.
* @throws \RuntimeException When the hash is identical to the current file.
*/
public function createNewVersion(
Artwork $artwork,
string $filePath,
string $fileHash,
int $width,
int $height,
int $fileSize,
int $userId,
?string $changeNote = null,
): ArtworkVersion {
// 1. Rate limit check
$this->rateLimitCheck($userId, $artwork->id);
// 2. Reject identical file
if ($artwork->hash === $fileHash) {
throw new \RuntimeException('The uploaded file is identical to the current version. No new version created.');
}
return DB::transaction(function () use (
$artwork, $filePath, $fileHash, $width, $height, $fileSize, $userId, $changeNote
): ArtworkVersion {
// 3. Determine next version number
$nextNumber = ($artwork->version_count ?? 1) + 1;
// 4. Mark all previous versions as not current
$artwork->versions()->update(['is_current' => false]);
// 5. Insert new version row
$version = ArtworkVersion::create([
'artwork_id' => $artwork->id,
'version_number' => $nextNumber,
'file_path' => $filePath,
'file_hash' => $fileHash,
'width' => $width,
'height' => $height,
'file_size' => $fileSize,
'change_note' => $changeNote,
'is_current' => true,
]);
// 6. Check whether moderation re-review is required
$needsReapproval = $this->shouldRequireReapproval($artwork, $width, $height);
// 7. Update artwork metadata (no engagement data touched)
$artwork->update([
'current_version_id' => $version->id,
'version_count' => $nextNumber,
'version_updated_at' => now(),
'requires_reapproval' => $needsReapproval,
]);
// 8. Ranking protection — apply small decay
$this->applyRankingProtection($artwork);
// 9. Audit log
ArtworkVersionEvent::create([
'artwork_id' => $artwork->id,
'user_id' => $userId,
'action' => 'create_version',
'version_id' => $version->id,
]);
// 10. Increment hourly/daily counters for rate limiting
$this->incrementRateLimitCounters($userId, $artwork->id);
return $version;
});
}
/**
* Restore a previous version by cloning it as a new (current) version.
*
* The restored file is treated as a brand-new version so the history
* remains strictly append-only and the version counter always increases.
*
* @throws TooManyRequestsHttpException When rate limit is exceeded.
*/
public function restoreVersion(
ArtworkVersion $version,
Artwork $artwork,
int $userId,
): ArtworkVersion {
return $this->createNewVersion(
$artwork,
$version->file_path,
$version->file_hash,
(int) $version->width,
(int) $version->height,
(int) $version->file_size,
$userId,
"Restored from version {$version->version_number}",
);
}
/**
* Decide whether the new file warrants a moderation re-check.
*
* Triggers when either dimension changes by more than the threshold.
*/
public function shouldRequireReapproval(Artwork $artwork, int $newWidth, int $newHeight): bool
{
// First version upload — no existing dimensions to compare
if (!$artwork->width || !$artwork->height) {
return false;
}
$widthChange = abs($newWidth - $artwork->width) / max($artwork->width, 1);
$heightChange = abs($newHeight - $artwork->height) / max($artwork->height, 1);
return $widthChange > self::DIMENSION_CHANGE_THRESHOLD
|| $heightChange > self::DIMENSION_CHANGE_THRESHOLD;
}
/**
* Apply a small protective decay (7 %) to ranking and heat scores.
*
* This prevents creators from gaming the ranking algorithm by rapidly
* cycling file versions to refresh discovery signals.
* Engagement totals (views, favourites, downloads) are NOT touched.
*/
public function applyRankingProtection(Artwork $artwork): void
{
try {
DB::table('artwork_stats')
->where('artwork_id', $artwork->id)
->update([
'ranking_score' => DB::raw('ranking_score * ' . self::RANKING_DECAY_FACTOR),
'heat_score' => DB::raw('heat_score * ' . self::RANKING_DECAY_FACTOR),
'engagement_velocity' => DB::raw('engagement_velocity * ' . self::RANKING_DECAY_FACTOR),
]);
} catch (\Throwable $e) {
// Non-fatal — log and continue so the version is still saved.
Log::warning('ArtworkVersioningService: ranking protection failed', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Throw TooManyRequestsHttpException when the user has exceeded either:
* 3 replacements per hour for this artwork
* 10 replacements per day for this user (across all artworks)
*/
public function rateLimitCheck(int $userId, int $artworkId): void
{
$hourKey = "artwork_version:hour:{$userId}:{$artworkId}";
$dayKey = "artwork_version:day:{$userId}";
$hourCount = (int) Cache::get($hourKey, 0);
$dayCount = (int) Cache::get($dayKey, 0);
if ($hourCount >= self::MAX_PER_HOUR) {
throw new TooManyRequestsHttpException(
3600,
'You have replaced this artwork too many times in the last hour. Please wait before trying again.'
);
}
if ($dayCount >= self::MAX_PER_DAY) {
throw new TooManyRequestsHttpException(
86400,
'You have reached the daily replacement limit. Please wait until tomorrow.'
);
}
}
// ── Private helpers ────────────────────────────────────────────────────
private function incrementRateLimitCounters(int $userId, int $artworkId): void
{
$hourKey = "artwork_version:hour:{$userId}:{$artworkId}";
$dayKey = "artwork_version:day:{$userId}";
// Hourly counter — expires in 1 hour
if (Cache::has($hourKey)) {
Cache::increment($hourKey);
} else {
Cache::put($hourKey, 1, 3600);
}
// Daily counter — expires at midnight (or 24 hours from first hit)
if (Cache::has($dayKey)) {
Cache::increment($dayKey);
} else {
Cache::put($dayKey, 1, 86400);
}
}
}

View File

@@ -5,4 +5,13 @@ declare(strict_types=1);
return [ return [
'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'), 'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'),
'avatar_url' => env('AVATAR_CDN_URL', 'https://files.skinbase.org'), 'avatar_url' => env('AVATAR_CDN_URL', 'https://files.skinbase.org'),
/**
* Optional CDN purge webhook URL.
* When set, the artwork versioning system will POST { paths: [...] }
* after every file replacement to bust stale thumbnails.
*
* Example: https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache
*/
'purge_url' => env('CDN_PURGE_URL', null),
]; ];

View File

@@ -32,6 +32,18 @@ return [
// Which derivative variant to send to vision services. // Which derivative variant to send to vision services.
'image_variant' => env('VISION_IMAGE_VARIANT', 'md'), 'image_variant' => env('VISION_IMAGE_VARIANT', 'md'),
/*
|--------------------------------------------------------------------------
| Vision Gateway (aggregates CLIP + BLIP + YOLO via /analyze/all)
|--------------------------------------------------------------------------
| Falls back to CLIP base_url when VISION_GATEWAY_URL is not set.
*/
'gateway' => [
'base_url' => env('VISION_GATEWAY_URL', env('CLIP_BASE_URL', '')),
'timeout_seconds' => (int) env('VISION_GATEWAY_TIMEOUT', 10),
'connect_timeout_seconds'=> (int) env('VISION_GATEWAY_CONNECT_TIMEOUT', 3),
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| LM Studio local multimodal inference (tag generation) | LM Studio local multimodal inference (tag generation)

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('artwork_versions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('artwork_id')->index();
$table->unsignedInteger('version_number');
$table->string('file_path');
$table->string('file_hash', 64);
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->unsignedBigInteger('file_size')->nullable();
$table->text('change_note')->nullable();
$table->boolean('is_current')->default(false);
$table->timestamps();
$table->foreign('artwork_id', 'fk_artwork_versions_artwork')
->references('id')->on('artworks')->cascadeOnDelete();
$table->unique(['artwork_id', 'version_number'], 'uq_artwork_version');
$table->index(['artwork_id', 'is_current'], 'idx_artwork_version_current');
});
}
public function down(): void
{
Schema::dropIfExists('artwork_versions');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('artworks', function (Blueprint $table) {
// FK to the active version row (nullable set after first version is created)
$table->unsignedBigInteger('current_version_id')->nullable()->index()->after('hash');
// Running count of how many versions exist
$table->unsignedInteger('version_count')->default(1)->after('current_version_id');
// Last time the file was replaced
$table->timestamp('version_updated_at')->nullable()->after('version_count');
// Signals that a moderator must approve the new file
$table->boolean('requires_reapproval')->default(false)->after('version_updated_at');
});
}
public function down(): void
{
Schema::table('artworks', function (Blueprint $table) {
$table->dropIndex(['current_version_id']);
$table->dropColumn([
'current_version_id',
'version_count',
'version_updated_at',
'requires_reapproval',
]);
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('artwork_version_events', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('artwork_id')->index();
$table->unsignedBigInteger('user_id')->index();
$table->string('action', 50); // create_version | restore_version
$table->unsignedBigInteger('version_id')->nullable();
$table->timestamps();
$table->foreign('artwork_id', 'fk_avevt_artwork')
->references('id')->on('artworks')->cascadeOnDelete();
$table->foreign('user_id', 'fk_avevt_user')
->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('artwork_version_events');
}
};

View File

@@ -63,6 +63,16 @@ export default function StudioArtworkEdit() {
width: artwork?.width || 0, width: artwork?.width || 0,
height: artwork?.height || 0, height: artwork?.height || 0,
}) })
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false)
const [changeNote, setChangeNote] = useState('')
const [showChangeNote, setShowChangeNote] = useState(false)
// Version history modal state
const [showHistory, setShowHistory] = useState(false)
const [historyData, setHistoryData] = useState(null)
const [historyLoading, setHistoryLoading] = useState(false)
const [restoring, setRestoring] = useState(null) // version id being restored
// --- Tag search --- // --- Tag search ---
const searchTags = useCallback(async (q) => { const searchTags = useCallback(async (q) => {
@@ -158,6 +168,7 @@ export default function StudioArtworkEdit() {
try { try {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
if (changeNote.trim()) fd.append('change_note', changeNote.trim())
const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, { const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, {
method: 'POST', method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
@@ -168,8 +179,12 @@ export default function StudioArtworkEdit() {
if (res.ok && data.thumb_url) { if (res.ok && data.thumb_url) {
setThumbUrl(data.thumb_url) setThumbUrl(data.thumb_url)
setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 }) setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 })
if (data.version_number) setVersionCount(data.version_number)
if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval)
setChangeNote('')
setShowChangeNote(false)
} else { } else {
console.error('File replace failed:', data) alert(data.error || 'File replacement failed.')
} }
} catch (err) { } catch (err) {
console.error('File replace failed:', err) console.error('File replace failed:', err)
@@ -179,6 +194,47 @@ export default function StudioArtworkEdit() {
} }
} }
const loadVersionHistory = async () => {
setHistoryLoading(true)
setShowHistory(true)
try {
const res = await fetch(`/api/studio/artworks/${artwork.id}/versions`, {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setHistoryData(data)
} catch (err) {
console.error('Failed to load version history:', err)
} finally {
setHistoryLoading(false)
}
}
const handleRestoreVersion = async (versionId) => {
if (!window.confirm('Restore this version? It will be cloned as the new current version.')) return
setRestoring(versionId)
try {
const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
if (res.ok && data.success) {
alert(data.message)
setVersionCount((n) => n + 1)
setShowHistory(false)
} else {
alert(data.error || 'Restore failed.')
}
} catch (err) {
console.error('Restore failed:', err)
} finally {
setRestoring(null)
}
}
// --- Render --- // --- Render ---
return ( return (
<StudioLayout title="Edit Artwork"> <StudioLayout title="Edit Artwork">
@@ -193,7 +249,28 @@ export default function StudioArtworkEdit() {
<div className="max-w-3xl space-y-8"> <div className="max-w-3xl space-y-8">
{/* ── Uploaded Asset ── */} {/* ── Uploaded Asset ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6"> <section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Uploaded Asset</h3> <div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Uploaded Asset</h3>
<div className="flex items-center gap-2">
{requiresReapproval && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
<i className="fa-solid fa-triangle-exclamation" /> Under Review
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-accent/20 text-accent border border-accent/30">
v{versionCount}
</span>
{versionCount > 1 && (
<button
type="button"
onClick={loadVersionHistory}
className="text-xs text-slate-400 hover:text-white transition-colors flex items-center gap-1"
>
<i className="fa-solid fa-clock-rotate-left text-[10px]" /> History
</button>
)}
</div>
</div>
<div className="flex items-start gap-5"> <div className="flex items-start gap-5">
{thumbUrl ? ( {thumbUrl ? (
<img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" /> <img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" />
@@ -208,16 +285,41 @@ export default function StudioArtworkEdit() {
{fileMeta.width > 0 && ( {fileMeta.width > 0 && (
<p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p> <p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p>
)} )}
{showChangeNote && (
<textarea
value={changeNote}
onChange={(e) => setChangeNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="What changed? (optional)"
className="mt-2 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50 resize-none"
/>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} /> <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
<div className="flex items-center gap-3 mt-2">
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => {
setShowChangeNote((s) => !s)
if (!showChangeNote) fileInputRef.current?.click()
}}
disabled={replacing} disabled={replacing}
className="mt-2 inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50" className="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
> >
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} /> <i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
{replacing ? 'Replacing…' : 'Replace file'} {replacing ? 'Replacing…' : 'Replace file'}
</button> </button>
{showChangeNote && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={replacing}
className="inline-flex items-center gap-1.5 text-xs bg-accent/20 hover:bg-accent/30 text-accent px-2.5 py-1 rounded-lg transition-colors disabled:opacity-50"
>
<i className="fa-solid fa-upload" /> Choose file
</button>
)}
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -450,6 +552,94 @@ export default function StudioArtworkEdit() {
</Link> </Link>
</div> </div>
</div> </div>
{/* ── Version History Modal ── */}
{showHistory && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) setShowHistory(false) }}
>
<div className="bg-nova-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
<i className="fa-solid fa-clock-rotate-left text-accent" />
Version History
</h2>
<button
onClick={() => setShowHistory(false)}
className="w-7 h-7 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
>
<i className="fa-solid fa-xmark text-xs" />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 sb-scrollbar p-4 space-y-3">
{historyLoading && (
<div className="flex items-center justify-center py-10">
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!historyLoading && historyData && historyData.versions.map((v) => (
<div
key={v.id}
className={`rounded-xl border p-4 transition-all ${
v.is_current
? 'border-accent/40 bg-accent/10'
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-white">v{v.version_number}</span>
{v.is_current && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">Current</span>
)}
</div>
<p className="text-[11px] text-slate-400">
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
</p>
{v.width && (
<p className="text-[11px] text-slate-400">{v.width} × {v.height} px &middot; {formatBytes(v.file_size)}</p>
)}
{v.change_note && (
<p className="text-xs text-slate-300 mt-1 italic">&ldquo;{v.change_note}&rdquo;</p>
)}
</div>
{!v.is_current && (
<button
type="button"
disabled={restoring === v.id}
onClick={() => handleRestoreVersion(v.id)}
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white/5 hover:bg-accent/20 text-slate-300 hover:text-accent border border-white/10 hover:border-accent/30 transition-all disabled:opacity-50"
>
{restoring === v.id
? <><i className="fa-solid fa-spinner fa-spin" /> Restoring</>
: <><i className="fa-solid fa-rotate-left" /> Restore</>
}
</button>
)}
</div>
</div>
))}
{!historyLoading && historyData && historyData.versions.length === 0 && (
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/10">
<p className="text-xs text-slate-500">
Older versions are preserved. Restoring creates a new versionnothing is deleted.
</p>
</div>
</div>
</div>
)}
</StudioLayout> </StudioLayout>
) )
} }

View File

@@ -157,7 +157,7 @@ function SuggestionDropdown({
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="rounded-xl border border-white/10 bg-slate-950/95"> <div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-slate-950/98 shadow-xl shadow-black/50">
<ul id={listboxId} role="listbox" className="max-h-56 overflow-auto py-1"> <ul id={listboxId} role="listbox" className="max-h-56 overflow-auto py-1">
{loading && ( {loading && (
<li className="px-3 py-2 text-xs text-white/60">Searching tags</li> <li className="px-3 py-2 text-xs text-white/60">Searching tags</li>
@@ -268,6 +268,7 @@ export default function TagInput({
const queryCacheRef = useRef(new Map()) const queryCacheRef = useRef(new Map())
const abortControllerRef = useRef(null) const abortControllerRef = useRef(null)
const debounceTimerRef = useRef(null) const debounceTimerRef = useRef(null)
const hasFocusedRef = useRef(false)
const listboxId = useMemo(() => `tag-input-listbox-${Math.random().toString(16).slice(2)}`, []) const listboxId = useMemo(() => `tag-input-listbox-${Math.random().toString(16).slice(2)}`, [])
const aiSuggestedItems = useMemo(() => toSuggestionItems(suggestedTags), [suggestedTags]) const aiSuggestedItems = useMemo(() => toSuggestionItems(suggestedTags), [suggestedTags])
@@ -366,6 +367,9 @@ export default function TagInput({
}, [searchEndpoint, popularEndpoint, selectedTags]) }, [searchEndpoint, popularEndpoint, selectedTags])
useEffect(() => { useEffect(() => {
// Don't fire on initial mount — wait for the user to focus or type.
if (!hasFocusedRef.current) return
const query = inputValue.trim() const query = inputValue.trim()
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
@@ -451,6 +455,7 @@ export default function TagInput({
}, [applyPastedTags]) }, [applyPastedTags])
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
hasFocusedRef.current = true
if (inputValue.trim() !== '') return if (inputValue.trim() !== '') return
runSearch('') runSearch('')
}, [inputValue, runSearch]) }, [inputValue, runSearch])

View File

@@ -0,0 +1,405 @@
/**
* TagPicker studio-style list-based tag selector
*
* - Loads popular tags on mount
* - Debounced live search; shows "Add '<query>'" row for custom tags
* - Enter / comma / Tab commits a custom tag from the input
* - Scrollable list with circle / circle-check toggles
* - Selected chips shown above the list
* - AI-suggested tags shown with a purple badge
* - Search icon on the right side of the input
* - Counter footer: X/15 tags selected
*
* Value format: string[] of tag slugs
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const MAX_TAGS = 15
const DEBOUNCE_MS = 250
const MAX_RESULTS = 30
const MIN_LENGTH = 2
const MAX_LENGTH = 32
// ─── helpers ─────────────────────────────────────────────────────────────────
function normalizeSlug(raw) {
return String(raw ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9_-]/g, '')
.replace(/-+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, MAX_LENGTH)
}
function toListItem(item) {
if (!item) return null
if (typeof item === 'string') {
const slug = normalizeSlug(item)
return slug ? { key: slug, slug, name: slug, usageCount: null, isAi: false } : null
}
const slug = normalizeSlug(item.slug || item.tag || item.name || '')
if (!slug) return null
return {
key: String(item.id ?? slug),
slug,
name: item.name || item.tag || item.slug || slug,
usageCount: typeof item.usage_count === 'number' ? item.usage_count : null,
isAi: Boolean(item.is_ai || item.source === 'ai'),
}
}
// ─── sub-components ───────────────────────────────────────────────────────────
function SearchInput({ value, onChange, onKeyDown, inputRef, disabled, hint }) {
return (
<div className="relative">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
disabled={disabled}
className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 pl-3 pr-9 text-sm text-white placeholder:text-white/40 focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
placeholder={hint || 'Search or add tags…'}
aria-label="Search or add tags"
autoComplete="off"
spellCheck={false}
/>
{/* Search icon right side */}
<svg
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/35"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
>
<path fillRule="evenodd" d="M12.9 14.32a8 8 0 111.414-1.414l4.387 4.387-1.414 1.414-4.387-4.387zM8 14A6 6 0 108 2a6 6 0 000 12z" clipRule="evenodd" />
</svg>
</div>
)
}
function SelectedChips({ slugs, names, onRemove, disabled }) {
if (slugs.length === 0) return null
return (
<div className="flex flex-wrap gap-1.5">
{slugs.map((slug) => (
<span
key={slug}
className="inline-flex items-center gap-1 rounded-lg bg-accent/20 px-2.5 py-1 text-xs font-medium text-accent"
>
{names[slug] || slug}
<button
type="button"
onClick={() => onRemove(slug)}
disabled={disabled}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full hover:bg-white/15 disabled:cursor-not-allowed"
aria-label={`Remove tag ${names[slug] || slug}`}
>
<svg className="h-2.5 w-2.5" viewBox="0 0 10 10" fill="currentColor" aria-hidden="true">
<path d="M6.414 5l2.293-2.293a1 1 0 00-1.414-1.414L5 3.586 2.707 1.293A1 1 0 001.293 2.707L3.586 5 1.293 7.293a1 1 0 101.414 1.414L5 6.414l2.293 2.293a1 1 0 001.414-1.414L6.414 5z" />
</svg>
</button>
</span>
))}
</div>
)
}
function AddNewRow({ label, onAdd, disabled }) {
return (
<button
type="button"
onClick={onAdd}
disabled={disabled}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-sky-300 transition-all hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" d="M8 3v10M3 8h10" />
</svg>
<span>Add <span className="font-semibold">"{label}"</span></span>
</button>
)
}
function ListRow({ item, isSelected, onToggle, disabled }) {
return (
<button
type="button"
onClick={() => onToggle(item)}
disabled={disabled}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all disabled:cursor-not-allowed ${
isSelected
? 'bg-accent/12 text-accent'
: 'text-slate-300 hover:bg-white/6 hover:text-white'
}`}
>
<span className="flex min-w-0 items-center gap-2.5">
{isSelected ? (
<svg className="h-3.5 w-3.5 shrink-0 text-accent" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-3.5 w-3.5 shrink-0 text-white/30" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="8" cy="8" r="6.25" />
</svg>
)}
<span className="truncate">{item.name}</span>
{item.isAi && (
<span className="shrink-0 rounded-full border border-purple-400/40 bg-purple-400/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-purple-200">
AI
</span>
)}
</span>
{typeof item.usageCount === 'number' && (
<span className="ml-3 shrink-0 text-[11px] text-white/40">
{item.usageCount.toLocaleString()} uses
</span>
)}
</button>
)
}
// ─── main component ───────────────────────────────────────────────────────────
export default function TagPicker({
value = [],
onChange,
suggestedTags = [],
disabled = false,
maxTags = MAX_TAGS,
searchEndpoint = '/api/tags/search',
popularEndpoint = '/api/tags/popular',
placeholder,
error,
}) {
const selectedSlugs = useMemo(
() => Array.from(new Set((Array.isArray(value) ? value : []).map(normalizeSlug).filter(Boolean))),
[value]
)
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [fetchError, setFetchError] = useState(false)
const [inputError, setInputError] = useState('')
// slug → display name (for chips)
const [nameMap, setNameMap] = useState({})
const updateNameMap = useCallback((items) => {
setNameMap((prev) => {
const next = { ...prev }
items.forEach((item) => {
if (item?.slug && item.name) next[item.slug] = item.name
})
return next
})
}, [])
const abortRef = useRef(null)
const timerRef = useRef(null)
const inputRef = useRef(null)
const cacheRef = useRef(new Map())
const aiItems = useMemo(() => {
return (Array.isArray(suggestedTags) ? suggestedTags : [])
.map(toListItem)
.filter(Boolean)
.map((item) => ({ ...item, isAi: true }))
}, [suggestedTags])
const fetchTags = useCallback(async (q) => {
const cacheKey = q.trim() || '__popular__'
if (cacheRef.current.has(cacheKey)) {
setResults(cacheRef.current.get(cacheKey))
setFetchError(false)
return
}
if (abortRef.current) abortRef.current.abort()
abortRef.current = new AbortController()
setLoading(true)
setFetchError(false)
try {
const url = q.trim()
? `${searchEndpoint}?q=${encodeURIComponent(q.trim())}`
: popularEndpoint
const res = await window.axios.get(url, { signal: abortRef.current.signal })
const raw = res?.data?.data || res?.data || []
const items = (Array.isArray(raw) ? raw : []).map(toListItem).filter(Boolean).slice(0, MAX_RESULTS)
cacheRef.current.set(cacheKey, items)
setResults(items)
updateNameMap(items)
} catch (err) {
if (err?.code === 'ERR_CANCELED' || abortRef.current?.signal?.aborted) return
setFetchError(true)
setResults([])
} finally {
if (!abortRef.current?.signal?.aborted) setLoading(false)
}
}, [searchEndpoint, popularEndpoint, updateNameMap])
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => fetchTags(query), query ? DEBOUNCE_MS : 0)
return () => clearTimeout(timerRef.current)
}, [query, fetchTags])
useEffect(() => () => { abortRef.current?.abort() }, [])
useEffect(() => { updateNameMap(aiItems) }, [aiItems, updateNameMap])
// ── tag actions ──────────────────────────────────────────────────────────
const addTag = useCallback((rawSlug, displayName) => {
const slug = normalizeSlug(rawSlug)
if (!slug) return
if (slug.length < MIN_LENGTH) { setInputError(`Too short (min ${MIN_LENGTH} chars)`); return }
if (selectedSlugs.includes(slug)) { setInputError('Already added'); return }
if (selectedSlugs.length >= maxTags) { setInputError('Maximum tags reached'); return }
setInputError('')
updateNameMap([{ slug, name: displayName || rawSlug }])
onChange?.([...selectedSlugs, slug])
setQuery('')
}, [selectedSlugs, maxTags, onChange, updateNameMap])
const toggleTag = useCallback((item) => {
if (selectedSlugs.includes(item.slug)) {
onChange?.(selectedSlugs.filter((s) => s !== item.slug))
} else {
addTag(item.slug, item.name)
}
}, [selectedSlugs, addTag, onChange])
const removeTag = useCallback((slug) => {
setInputError('')
onChange?.(selectedSlugs.filter((s) => s !== slug))
}, [selectedSlugs, onChange])
// Commit on Enter / comma / Tab
const handleKeyDown = useCallback((e) => {
const commit = e.key === 'Enter' || e.key === ',' || e.key === 'Tab'
if (!commit) return
// Backspace on empty → remove last chip
if (e.key === 'Backspace' && query === '' && selectedSlugs.length > 0) {
removeTag(selectedSlugs[selectedSlugs.length - 1])
return
}
const candidate = query.trim().replace(/,$/, '')
if (!candidate) return
e.preventDefault()
addTag(candidate, candidate)
}, [query, selectedSlugs, addTag, removeTag])
// Show "Add 'query'" row when the query doesn't exactly match any result
const querySlug = normalizeSlug(query)
const showAddNew = Boolean(
query.trim() &&
querySlug.length >= MIN_LENGTH &&
!results.some((r) => r.slug === querySlug) &&
!selectedSlugs.includes(querySlug)
)
const displayList = useMemo(() => {
const seen = new Set()
const merged = []
if (!query.trim()) {
aiItems.forEach((item) => {
if (!seen.has(item.slug)) { seen.add(item.slug); merged.push(item) }
})
}
results.forEach((item) => {
if (!seen.has(item.slug)) { seen.add(item.slug); merged.push(item) }
})
return merged
}, [aiItems, results, query])
const atMax = selectedSlugs.length >= maxTags
return (
<div className="space-y-3" data-testid="tag-picker-root">
{/* Search / add input */}
<SearchInput
value={query}
onChange={(v) => { setQuery(v); setInputError('') }}
onKeyDown={handleKeyDown}
inputRef={inputRef}
disabled={disabled}
hint={placeholder}
/>
{/* Inline input validation */}
{inputError && (
<p className="text-xs text-red-300" role="alert">{inputError}</p>
)}
{/* Selected chips */}
<SelectedChips
slugs={selectedSlugs}
names={nameMap}
onRemove={removeTag}
disabled={disabled}
/>
{/* Scrollable results list */}
<div className="max-h-52 overflow-y-auto rounded-xl border border-white/8 bg-white/[0.02] p-1 sb-scrollbar">
{loading && (
<div className="flex items-center justify-center py-5">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent/30 border-t-accent" />
</div>
)}
{!loading && fetchError && (
<p className="py-4 text-center text-xs text-amber-300/80">Tag search unavailable</p>
)}
{/* "Add new" row — always shown when query doesn't match */}
{!loading && showAddNew && (
<AddNewRow
label={query.trim()}
onAdd={() => addTag(query.trim(), query.trim())}
disabled={disabled || atMax}
/>
)}
{!loading && !fetchError && displayList.length === 0 && !showAddNew && (
<p className="py-4 text-center text-sm text-white/40">
{query ? 'No tags found — press Enter to add' : 'Type to search or add tags'}
</p>
)}
{!loading && !fetchError && displayList.map((item) => (
<ListRow
key={item.key}
item={item}
isSelected={selectedSlugs.includes(item.slug)}
onToggle={toggleTag}
disabled={disabled || (atMax && !selectedSlugs.includes(item.slug))}
/>
))}
</div>
{/* Footer */}
<div className="flex items-center justify-between text-xs text-white/45">
<span>{selectedSlugs.length}/{maxTags} tags selected</span>
{atMax
? <span className="text-amber-300/80">Maximum tags reached</span>
: <span className="text-white/30">Enter, comma or Tab to add</span>
}
</div>
{error && <p className="text-xs text-red-300">{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,152 @@
import React from 'react'
/**
* CategorySelector
*
* Reusable pill-based category + subcategory selector.
* Renders root categories as pills; when a root with children is selected,
* subcategory pills appear in an animated block below.
*
* @param {object} props
* @param {Array} props.categories Flat list of root-category objects { id, name, children[] }
* @param {string} props.rootCategoryId Currently selected root id
* @param {string} props.subCategoryId Currently selected sub id
* @param {boolean} props.hasContentType Whether a content type is selected (gate)
* @param {string} [props.error] Validation error message
* @param {function} props.onRootChange Called with (rootId: string)
* @param {function} props.onSubChange Called with (subId: string)
* @param {Array} [props.allRoots] All root options (for the hidden accessible select)
* @param {function} [props.onRootChangeAll] Fallback handler with full cross-type info
*/
export default function CategorySelector({
categories = [],
rootCategoryId = '',
subCategoryId = '',
hasContentType = false,
error = '',
onRootChange,
onSubChange,
allRoots = [],
onRootChangeAll,
}) {
const selectedRoot = categories.find((c) => String(c.id) === String(rootCategoryId || '')) ?? null
const hasSubcategories = Boolean(
selectedRoot && Array.isArray(selectedRoot.children) && selectedRoot.children.length > 0
)
if (!hasContentType) {
return (
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
Select a content type to load categories.
</div>
)
}
if (categories.length === 0) {
return (
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
No categories available for this content type.
</div>
)
}
return (
<div className="space-y-3">
{/* Root categories */}
<div className="flex flex-wrap gap-2" role="group" aria-label="Category">
{categories.map((root) => {
const active = String(root.id) === String(rootCategoryId || '')
return (
<button
key={root.id}
type="button"
aria-pressed={active}
onClick={() => onRootChange?.(String(root.id))}
className={[
'rounded-full border px-3.5 py-1.5 text-sm transition-all',
active
? 'border-violet-500/70 bg-violet-600/25 text-white shadow-sm'
: 'border-white/10 bg-white/5 text-white/65 hover:border-violet-300/40 hover:bg-violet-400/10 hover:text-white/90',
].join(' ')}
>
{root.name}
</button>
)
})}
</div>
{/* Subcategories (shown when root has children) */}
{hasSubcategories && (
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-3">
<p className="mb-2 text-[11px] uppercase tracking-wide text-white/45">
Subcategory for <span className="text-white/70">{selectedRoot.name}</span>
</p>
<div className="flex flex-wrap gap-2" role="group" aria-label="Subcategory">
{selectedRoot.children.map((sub) => {
const active = String(sub.id) === String(subCategoryId || '')
return (
<button
key={sub.id}
type="button"
aria-pressed={active}
onClick={() => onSubChange?.(String(sub.id))}
className={[
'rounded-full border px-3 py-1 text-sm transition-all',
active
? 'border-cyan-500/70 bg-cyan-600/20 text-white shadow-sm'
: 'border-white/10 bg-white/5 text-white/60 hover:border-cyan-300/40 hover:bg-cyan-400/10 hover:text-white/85',
].join(' ')}
>
{sub.name}
</button>
)
})}
</div>
</div>
)}
{/* Accessible hidden select (screen readers / fallback) */}
<div className="sr-only">
<label htmlFor="category-root-select">Root category</label>
<select
id="category-root-select"
value={String(rootCategoryId || '')}
onChange={(e) => {
const nextRootId = String(e.target.value || '')
if (onRootChangeAll) {
const matched = allRoots.find((r) => String(r.id) === nextRootId)
onRootChangeAll(nextRootId, matched?.contentTypeValue ?? null)
} else {
onRootChange?.(nextRootId)
}
}}
>
<option value="">Select root category</option>
{allRoots.map((root) => (
<option key={root.id} value={String(root.id)}>{root.name}</option>
))}
</select>
{hasSubcategories && (
<>
<label htmlFor="category-sub-select">Subcategory</label>
<select
id="category-sub-select"
value={String(subCategoryId || '')}
onChange={(e) => onSubChange?.(String(e.target.value || ''))}
>
<option value="">Select subcategory</option>
{selectedRoot.children.map((sub) => (
<option key={sub.id} value={String(sub.id)}>{sub.name}</option>
))}
</select>
</>
)}
</div>
{error && (
<p className="mt-1 text-xs text-red-300" role="alert">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
import React from 'react'
import { getContentTypeValue, getContentTypeVisualKey } from '../../lib/uploadUtils'
/**
* ContentTypeSelector
*
* Reusable mascot-icon content-type picker.
* Displays each content type as a card with a mascot icon.
*
* @param {object} props
* @param {Array} props.contentTypes List of content type objects from API
* @param {string} props.selected Currently selected type value
* @param {string} [props.error] Validation error message
* @param {function} props.onChange Called with new type value string
*/
export default function ContentTypeSelector({ contentTypes = [], selected = '', error = '', onChange }) {
if (!Array.isArray(contentTypes) || contentTypes.length === 0) {
return (
<div className="rounded-xl ring-1 ring-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
No content types available.
</div>
)
}
return (
<div>
<div className="flex gap-3 overflow-x-auto py-2 no-scrollbar">
{contentTypes.map((ct) => {
const typeValue = getContentTypeValue(ct)
const active = String(typeValue) === String(selected || '')
const visualKey = getContentTypeVisualKey(ct)
const iconPath = `/gfx/mascot_${visualKey}.webp`
return (
<button
key={typeValue || ct.name}
type="button"
onClick={() => onChange?.(String(typeValue))}
aria-pressed={active}
className={[
'group flex flex-col items-center gap-2 min-w-[96px] rounded-xl border px-3 py-2.5',
'transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/50',
active
? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-900/30 scale-105'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06] hover:-translate-y-0.5 hover:scale-[1.03]',
].join(' ')}
>
{/* Mascot icon */}
<div className="h-16 w-16 rounded-full overflow-hidden flex items-center justify-center">
<img
src={iconPath}
alt={`${ct.name || 'Content type'} icon`}
loading="lazy"
decoding="async"
width={64}
height={64}
className={[
'h-full w-full object-contain transition-all duration-200',
active
? 'grayscale-0 opacity-100'
: 'grayscale opacity-40 group-hover:grayscale-0 group-hover:opacity-85',
].join(' ')}
onError={(e) => {
if (!e.currentTarget.src.includes('mascot_other.webp')) {
e.currentTarget.src = '/gfx/mascot_other.webp'
}
}}
/>
</div>
{/* Label */}
<span className={`text-xs font-semibold text-center leading-snug ${active ? 'text-white' : 'text-white/60 group-hover:text-white/85'}`}>
{ct.name || 'Type'}
</span>
{/* Active indicator bar */}
<div className={`h-0.5 w-8 rounded-full transition-all ${active ? 'bg-emerald-400/80' : 'bg-white/10 group-hover:bg-white/25'}`} />
</button>
)
})}
</div>
{error && (
<p className="mt-2 text-xs text-red-300" role="alert">{error}</p>
)}
</div>
)
}

View File

@@ -116,9 +116,9 @@ export default function ScreenshotUploader({
animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }} animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }} exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }}
transition={quickTransition} transition={quickTransition}
className="rounded-lg border border-white/50 bg-white/5 p-2 text-xs" 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 border border-white/50 bg-black/25"> <div className="flex h-40 w-40 items-center justify-center overflow-hidden rounded-md ring-1 ring-white/10 bg-black/25">
<img <img
src={item.url} src={item.url}
alt={`Screenshot ${index + 1}`} alt={`Screenshot ${index + 1}`}

View File

@@ -67,7 +67,7 @@ export default function UploadDropzone({
} }
return ( return (
<section className={`rounded-xl border bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/50'}`}> <section className={`rounded-xl bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] 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 */} {/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
<motion.div <motion.div
data-testid="upload-dropzone" data-testid="upload-dropzone"
@@ -100,7 +100,7 @@ export default function UploadDropzone({
}} }}
animate={prefersReducedMotion ? undefined : { scale: dragging ? 1.01 : 1 }} animate={prefersReducedMotion ? undefined : { scale: dragging ? 1.01 : 1 }}
transition={dragTransition} transition={dragTransition}
className={`group rounded-xl border-2 border-dashed border-white/50 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${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-sky-500/5 hover:bg-sky-500/12'}`} className={`group rounded-xl border-2 border-dashed border-white/15 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${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-sky-500/5 hover:bg-sky-500/12'}`}
> >
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3> <h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
<p className="mt-1 text-xs text-soft">{description}</p> <p className="mt-1 text-xs text-soft">{description}</p>
@@ -155,7 +155,7 @@ export default function UploadDropzone({
/> />
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && ( {(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
<div className="mt-3 rounded-lg border border-white/50 bg-black/25 px-3 py-2 text-left text-xs text-white/80"> <div className="mt-3 rounded-lg ring-1 ring-white/10 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
<div className="font-medium text-white/85">Selected file</div> <div className="font-medium text-white/85">Selected file</div>
<div className="mt-1 truncate">{fileName || fileHint}</div> <div className="mt-1 truncate">{fileName || fileHint}</div>
{fileMeta && ( {fileMeta && (

View File

@@ -15,9 +15,9 @@ export default function UploadPreview({
invalid = false, invalid = false,
}) { }) {
return ( return (
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/45'}`}> <section className={`rounded-xl bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${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/45'}`}>
{/* Intended props: file, previewUrl, isArchive, dimensions, fileSize, format, warning */} {/* Intended props: file, previewUrl, isArchive, dimensions, fileSize, format, warning */}
<div className={`rounded-xl border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-white/50 bg-black/25'}`}> <div className={`rounded-xl ring-1 p-4 transition-colors ${invalid ? 'ring-red-300/40 bg-red-500/5' : 'ring-white/8 bg-black/25'}`}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{title}</h3> <h3 className="text-lg font-semibold text-white">{title}</h3>
<span className="rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-[11px] text-white/65"> <span className="rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-[11px] text-white/65">

View File

@@ -60,7 +60,7 @@ export default function UploadProgress({
const progressValue = Math.max(0, Math.min(100, Number(progress) || 0)) const progressValue = Math.max(0, Math.min(100, Number(progress) || 0))
return ( return (
<header className="rounded-xl border border-white/50 bg-gradient-to-br from-slate-900/80 to-slate-900/50 p-5 shadow-[0_12px_40px_rgba(0,0,0,0.35)] sm:p-6"> <header className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-slate-900/80 to-slate-900/50 p-5 shadow-[0_12px_40px_rgba(0,0,0,0.35)] sm:p-6">
{/* Intended props: step, steps, phase, badge, progress, statusMessage */} {/* Intended props: step, steps, phase, badge, progress, statusMessage */}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>

View File

@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import TagInput from '../tags/TagInput' import TagPicker from '../tags/TagPicker'
import Checkbox from '../../Components/ui/Checkbox' import Checkbox from '../../Components/ui/Checkbox'
export default function UploadSidebar({ export default function UploadSidebar({
@@ -62,16 +62,14 @@ export default function UploadSidebar({
<h4 className="text-sm font-semibold text-white">Tags</h4> <h4 className="text-sm font-semibold text-white">Tags</h4>
<p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p> <p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p>
</div> </div>
<TagInput <TagPicker
value={metadata.tags} value={metadata.tags}
onChange={(nextTags) => onChangeTags?.(nextTags)} onChange={(nextTags) => onChangeTags?.(nextTags)}
suggestedTags={suggestedTags} suggestedTags={suggestedTags}
maxTags={15} maxTags={15}
minLength={2}
maxLength={32}
searchEndpoint="/api/tags/search" searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular" popularEndpoint="/api/tags/popular"
placeholder="Type tags (e.g. cyberpunk, city)" error={errors.tags}
/> />
</section> </section>

View File

@@ -4,7 +4,7 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc
const safeActive = Math.max(1, Math.min(steps.length || 1, activeStep)) const safeActive = Math.max(1, Math.min(steps.length || 1, activeStep))
return ( return (
<nav aria-label="Upload steps" className="rounded-xl border border-white/50 bg-slate-900/70 px-3 py-3 sm:px-4"> <nav aria-label="Upload steps" className="rounded-xl ring-1 ring-white/10 bg-slate-900/70 px-3 py-3 sm:px-4">
<ol className="flex flex-nowrap items-center gap-3 overflow-x-auto sm:gap-4"> <ol className="flex flex-nowrap items-center gap-3 overflow-x-auto sm:gap-4">
{steps.map((step, index) => { {steps.map((step, index) => {
const number = index + 1 const number = index + 1
@@ -19,7 +19,7 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc
: isComplete : isComplete
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25' ? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25'
: isLocked : isLocked
? 'cursor-default border-white/50 bg-white/5 text-white/40' ? 'cursor-default border-white/10 bg-white/5 text-white/40'
: 'border-white/10 bg-white/5 text-white/80 hover:bg-white/10' : 'border-white/10 bg-white/5 text-white/80 hover:bg-white/10'
const circleClass = isComplete const circleClass = isComplete

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import React from 'react'
import UploadDropzone from '../UploadDropzone'
import ScreenshotUploader from '../ScreenshotUploader'
import UploadProgress from '../UploadProgress'
import { machineStates } from '../../../hooks/upload/useUploadMachine'
import { getProcessingTransparencyLabel } from '../../../lib/uploadUtils'
/**
* Step1FileUpload
*
* Step 1 of the upload wizard: file selection + live upload progress.
* Shows the dropzone, optional screenshot uploader (archives),
* and the progress panel once an upload is in flight.
*/
export default function Step1FileUpload({
headingRef,
// File state
primaryFile,
primaryPreviewUrl,
primaryErrors,
primaryWarnings,
fileMetadata,
fileSelectionLocked,
onPrimaryFileChange,
// Archive screenshots
isArchive,
screenshots,
screenshotErrors,
screenshotPerFileErrors,
onScreenshotsChange,
// Machine state
machine,
showProgress,
onRetry,
onReset,
}) {
const processingTransparencyLabel = getProcessingTransparencyLabel(
machine.processingStatus,
machine.state
)
const progressStatus = (() => {
if (machine.state === machineStates.ready_to_publish) return 'Ready'
if (machine.state === machineStates.uploading) return 'Uploading'
if (machine.state === machineStates.processing || machine.state === machineStates.finishing) return 'Processing'
return 'Idle'
})()
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-white focus:outline-none"
>
Upload your artwork
</h2>
<p className="mt-1 text-sm text-white/60">
Drop or browse a file. Validation runs immediately. Upload starts when you click&nbsp;
<span className="text-white/80">Start upload</span>.
</p>
</div>
{/* Locked notice */}
{fileSelectionLocked && (
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-100 ring-1 ring-amber-300/30">
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
</svg>
File is locked after upload. Reset to change.
</div>
)}
{/* Primary dropzone */}
<UploadDropzone
title="Upload your artwork file"
description="Drag & drop or click to browse. Accepted: JPG, PNG, WEBP, ZIP, RAR, 7Z."
fileName={primaryFile?.name || ''}
previewUrl={primaryPreviewUrl}
fileMeta={fileMetadata}
fileHint="No file selected"
invalid={primaryErrors.length > 0}
errors={primaryErrors}
showLooksGood={Boolean(primaryFile) && primaryErrors.length === 0}
looksGoodText="Looks good"
locked={fileSelectionLocked}
onPrimaryFileChange={(file) => {
if (fileSelectionLocked) return
onPrimaryFileChange(file || null)
}}
/>
{/* Screenshots (archives only) */}
<ScreenshotUploader
title="Archive screenshots"
description="We need at least 1 screenshot to generate thumbnails and analyze content."
visible={isArchive}
files={screenshots}
min={1}
max={5}
perFileErrors={screenshotPerFileErrors}
errors={screenshotErrors}
invalid={isArchive && screenshotErrors.length > 0}
showLooksGood={isArchive && screenshots.length > 0 && screenshotErrors.length === 0}
looksGoodText="Looks good"
onFilesChange={onScreenshotsChange}
/>
{/* Progress panel */}
{showProgress && (
<UploadProgress
title="Upload progress"
description="Upload and processing status"
status={progressStatus}
progress={machine.progress}
state={machine.state}
processingStatus={machine.processingStatus}
isCancelling={machine.isCancelling}
error={machine.error}
processingLabel={processingTransparencyLabel}
onRetry={onRetry}
onReset={onReset}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,163 @@
import React from 'react'
import ContentTypeSelector from '../ContentTypeSelector'
import CategorySelector from '../CategorySelector'
import UploadSidebar from '../UploadSidebar'
/**
* Step2Details
*
* Step 2 of the upload wizard: artwork metadata.
* Shows uploaded-asset summary, content type selector,
* category/subcategory selectors, tags, description, and rights.
*/
export default function Step2Details({
headingRef,
// Asset summary
primaryFile,
primaryPreviewUrl,
isArchive,
fileMetadata,
screenshots,
// Content type + category
contentTypes,
metadata,
metadataErrors,
filteredCategoryTree,
allRootCategoryOptions,
requiresSubCategory,
onContentTypeChange,
onRootCategoryChange,
onSubCategoryChange,
// Sidebar (title / tags / description / rights)
suggestedTags,
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleRights,
}) {
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-white focus:outline-none"
>
Artwork details
</h2>
<p className="mt-1 text-sm text-white/60">
Complete required metadata and rights confirmation before publishing.
</p>
</div>
{/* Uploaded asset summary */}
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
<p className="mb-3 text-[11px] uppercase tracking-wide text-white/45">Uploaded asset</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* Thumbnail / Archive icon */}
{primaryPreviewUrl && !isArchive ? (
<div className="flex h-[120px] w-[120px] items-center justify-center overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 shrink-0">
<img
src={primaryPreviewUrl}
alt="Uploaded artwork thumbnail"
className="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
width={120}
height={120}
/>
</div>
) : (
<div className="grid h-[120px] w-[120px] place-items-center rounded-xl ring-1 ring-white/10 bg-white/5 shrink-0">
<svg className="h-8 w-8 text-white/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
)}
{/* File metadata */}
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-medium text-white">
{primaryFile?.name || 'Primary file'}
</p>
<p className="text-xs text-white/50">
{isArchive
? `Archive · ${screenshots.length} screenshot${screenshots.length !== 1 ? 's' : ''}`
: fileMetadata.resolution !== '—'
? `${fileMetadata.resolution} · ${fileMetadata.size}`
: fileMetadata.size}
</p>
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] ${isArchive ? 'border-amber-400/40 bg-amber-400/10 text-amber-200' : 'border-sky-400/40 bg-sky-400/10 text-sky-200'}`}>
{isArchive ? 'Archive' : 'Image'}
</span>
</div>
</div>
</div>
{/* Content type selector */}
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Content type</h3>
<p className="mt-0.5 text-xs text-white/55">Choose what kind of artwork this is.</p>
</div>
<span className="rounded-full border border-sky-400/35 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">
Step 2a
</span>
</div>
<ContentTypeSelector
contentTypes={contentTypes}
selected={metadata.contentType}
error={metadataErrors.contentType}
onChange={onContentTypeChange}
/>
</section>
{/* Category selector */}
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Category</h3>
<p className="mt-0.5 text-xs text-white/55">
{requiresSubCategory ? 'Select a category, then a subcategory.' : 'Select a category.'}
</p>
</div>
<span className="rounded-full border border-violet-400/35 bg-violet-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-violet-300">
Step 2b
</span>
</div>
<CategorySelector
categories={filteredCategoryTree}
rootCategoryId={metadata.rootCategoryId}
subCategoryId={metadata.subCategoryId}
hasContentType={Boolean(metadata.contentType)}
error={metadataErrors.category}
onRootChange={onRootCategoryChange}
onSubChange={onSubCategoryChange}
allRoots={allRootCategoryOptions}
onRootChangeAll={(rootId, contentTypeValue) => {
if (contentTypeValue) {
onContentTypeChange(contentTypeValue)
}
onRootCategoryChange(rootId)
}}
/>
</section>
{/* Title, tags, description, rights */}
<UploadSidebar
showHeader={false}
metadata={metadata}
suggestedTags={suggestedTags}
errors={metadataErrors}
onChangeTitle={onChangeTitle}
onChangeTags={onChangeTags}
onChangeDescription={onChangeDescription}
onToggleRights={onToggleRights}
/>
</div>
)
}

View File

@@ -0,0 +1,159 @@
import React from 'react'
import { motion, useReducedMotion } from 'framer-motion'
/**
* PublishCheckBadge a single status item for the review section
*/
function PublishCheckBadge({ label, ok }) {
return (
<span
className={[
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs',
ok
? 'border-emerald-300/40 bg-emerald-500/12 text-emerald-100'
: 'border-white/15 bg-white/5 text-white/55',
].join(' ')}
>
<span aria-hidden="true">{ok ? '✓' : '○'}</span>
{label}
</span>
)
}
/**
* Step3Publish
*
* Step 3 of the upload wizard: review summary and publish action.
* Shows a compact artwork preview, metadata summary, and readiness badges.
*/
export default function Step3Publish({
headingRef,
// Asset
primaryFile,
primaryPreviewUrl,
isArchive,
screenshots,
fileMetadata,
// Metadata
metadata,
// Readiness
canPublish,
uploadReady,
}) {
const prefersReducedMotion = useReducedMotion()
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
const checks = [
{ label: 'File uploaded', ok: uploadReady },
{ label: 'Scan passed', ok: uploadReady },
{ label: 'Preview ready', ok: hasPreview || (isArchive && screenshots.length > 0) },
{ label: 'Rights confirmed', ok: Boolean(metadata.rightsAccepted) },
]
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-white focus:outline-none"
>
Review & publish
</h2>
<p className="mt-1 text-sm text-white/60">
Everything looks good? Hit <span className="text-white/85">Publish</span> to make your artwork live.
</p>
</div>
{/* Preview + summary */}
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
<div className="flex flex-col gap-4 sm:flex-row">
{/* Artwork thumbnail */}
<div className="shrink-0">
{hasPreview ? (
<div className="flex h-[140px] w-[140px] items-center justify-center overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30">
<img
src={primaryPreviewUrl}
alt="Artwork preview"
className="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
width={140}
height={140}
/>
</div>
) : (
<div className="grid h-[140px] w-[140px] place-items-center rounded-xl ring-1 ring-white/10 bg-white/5 text-white/40">
<svg className="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
)}
</div>
{/* Summary */}
<div className="min-w-0 flex-1 space-y-2.5">
<p className="text-base font-semibold text-white leading-snug">
{metadata.title || <span className="text-white/45 italic">Untitled artwork</span>}
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/55">
{metadata.contentType && (
<span className="capitalize">Type: <span className="text-white/75">{metadata.contentType}</span></span>
)}
{metadata.rootCategoryId && (
<span>Category: <span className="text-white/75">{metadata.rootCategoryId}</span></span>
)}
{metadata.subCategoryId && (
<span>Sub: <span className="text-white/75">{metadata.subCategoryId}</span></span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/55">
<span>Tags: <span className="text-white/75">{(metadata.tags || []).length}</span></span>
{!isArchive && fileMetadata?.resolution && fileMetadata.resolution !== '—' && (
<span>Resolution: <span className="text-white/75">{fileMetadata.resolution}</span></span>
)}
{isArchive && (
<span>Screenshots: <span className="text-white/75">{screenshots.length}</span></span>
)}
</div>
{metadata.description && (
<p className="line-clamp-2 text-xs text-white/50">{metadata.description}</p>
)}
</div>
</div>
</div>
{/* Readiness badges */}
<div>
<p className="mb-2.5 text-xs uppercase tracking-wide text-white/40">Readiness checks</p>
<div className="flex flex-wrap gap-2">
{checks.map((check) => (
<PublishCheckBadge key={check.label} label={check.label} ok={check.ok} />
))}
</div>
</div>
{/* Not-ready notice */}
{!canPublish && (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={quickTransition}
className="rounded-lg ring-1 ring-amber-300/25 bg-amber-500/8 px-4 py-3 text-sm text-amber-100/85"
>
{!uploadReady
? 'Waiting for upload processing to complete…'
: !metadata.rightsAccepted
? 'Please confirm rights in the Details step to enable publishing.'
: 'Complete all required fields to enable publishing.'}
</motion.div>
)}
</div>
)
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from 'react'
import {
detectFileType,
formatBytes,
readImageDimensions,
getExtension,
IMAGE_MIME,
IMAGE_EXTENSIONS,
PRIMARY_IMAGE_MAX_BYTES,
PRIMARY_ARCHIVE_MAX_BYTES,
SCREENSHOT_MAX_BYTES,
} from '../../lib/uploadUtils'
// ─── Primary file validation ──────────────────────────────────────────────────
async function validatePrimaryFile(file) {
const errors = []
const warnings = []
const type = detectFileType(file)
if (!file) {
return {
type: 'unknown',
errors: [],
warnings,
metadata: { resolution: '—', size: '—', type: '—' },
previewUrl: '',
}
}
const metadata = {
resolution: '—',
size: formatBytes(file.size),
type: file.type || getExtension(file.name) || 'unknown',
}
if (type === 'unsupported') {
errors.push('Unsupported file type. Use image (jpg/jpeg/png/webp) or archive (zip/rar/7z/tar/gz).')
}
if (type === 'image') {
if (file.size > PRIMARY_IMAGE_MAX_BYTES) {
errors.push('Image exceeds 50MB maximum size.')
}
try {
const dimensions = await readImageDimensions(file)
metadata.resolution = `${dimensions.width} × ${dimensions.height}`
if (dimensions.width < 800 || dimensions.height < 600) {
errors.push('Image resolution must be at least 800×600.')
}
} catch {
errors.push('Unable to read image resolution.')
}
}
if (type === 'archive') {
metadata.resolution = 'n/a'
if (file.size > PRIMARY_ARCHIVE_MAX_BYTES) {
errors.push('Archive exceeds 200MB maximum size.')
}
warnings.push('Archive upload requires at least one valid screenshot.')
}
const previewUrl = type === 'image' ? URL.createObjectURL(file) : ''
return { type, errors, warnings, metadata, previewUrl }
}
// ─── Screenshot validation ────────────────────────────────────────────────────
async function validateScreenshots(files, isArchive) {
if (!isArchive) return { errors: [], perFileErrors: [] }
const errors = []
const perFileErrors = Array.from({ length: files.length }, () => '')
if (files.length < 1) errors.push('At least one screenshot is required for archives.')
if (files.length > 5) errors.push('Maximum 5 screenshots are allowed.')
await Promise.all(
files.map(async (file, index) => {
const typeErrors = []
const extension = getExtension(file.name)
const mime = String(file.type || '').toLowerCase()
if (!IMAGE_MIME.has(mime) && !IMAGE_EXTENSIONS.has(extension)) {
typeErrors.push('Must be JPG, PNG, or WEBP.')
}
if (file.size > SCREENSHOT_MAX_BYTES) {
typeErrors.push('Must be 10MB or less.')
}
if (typeErrors.length === 0) {
try {
const dimensions = await readImageDimensions(file)
if (dimensions.width < 1280 || dimensions.height < 720) {
typeErrors.push('Minimum resolution is 1280×720.')
}
} catch {
typeErrors.push('Could not read screenshot resolution.')
}
}
if (typeErrors.length > 0) {
perFileErrors[index] = typeErrors.join(' ')
}
})
)
if (perFileErrors.some(Boolean)) {
errors.push('One or more screenshots are invalid.')
}
return { errors, perFileErrors }
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* useFileValidation
*
* Runs async validation on the primary file and screenshots,
* maintains preview URL lifecycle (revokes on change/unmount),
* and exposes derived state for the upload UI.
*
* @param {File|null} primaryFile
* @param {File[]} screenshots
* @param {boolean} isArchive - derived from primaryType === 'archive'
*/
export default function useFileValidation(primaryFile, screenshots, isArchive) {
const [primaryType, setPrimaryType] = useState('unknown')
const [primaryErrors, setPrimaryErrors] = useState([])
const [primaryWarnings, setPrimaryWarnings] = useState([])
const [fileMetadata, setFileMetadata] = useState({ resolution: '—', size: '—', type: '—' })
const [primaryPreviewUrl, setPrimaryPreviewUrl] = useState('')
const [screenshotErrors, setScreenshotErrors] = useState([])
const [screenshotPerFileErrors, setScreenshotPerFileErrors] = useState([])
const primaryRunRef = useRef(0)
const screenshotRunRef = useRef(0)
// Primary file validation
useEffect(() => {
primaryRunRef.current += 1
const runId = primaryRunRef.current
let cancelled = false
;(async () => {
const result = await validatePrimaryFile(primaryFile)
if (cancelled || runId !== primaryRunRef.current) return
setPrimaryType(result.type)
setPrimaryWarnings(result.warnings)
setPrimaryErrors(result.errors)
setFileMetadata(result.metadata)
setPrimaryPreviewUrl((current) => {
if (current) URL.revokeObjectURL(current)
return result.previewUrl
})
})()
return () => {
cancelled = true
}
}, [primaryFile])
// Screenshot validation
useEffect(() => {
screenshotRunRef.current += 1
const runId = screenshotRunRef.current
let cancelled = false
;(async () => {
const result = await validateScreenshots(screenshots, isArchive)
if (cancelled || runId !== screenshotRunRef.current) return
setScreenshotErrors(result.errors)
setScreenshotPerFileErrors(result.perFileErrors)
})()
return () => {
cancelled = true
}
}, [screenshots, isArchive])
// Clear screenshots when file changes to a non-archive
useEffect(() => {
if (!isArchive) {
setScreenshotErrors([])
setScreenshotPerFileErrors([])
}
}, [isArchive])
// Revoke preview URL on unmount
useEffect(() => {
return () => {
if (primaryPreviewUrl) URL.revokeObjectURL(primaryPreviewUrl)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return {
primaryType,
primaryErrors,
primaryWarnings,
fileMetadata,
primaryPreviewUrl,
screenshotErrors,
screenshotPerFileErrors,
}
}
/** Standalone for use outside the hook if needed */
export { validatePrimaryFile, validateScreenshots }

View File

@@ -0,0 +1,433 @@
import { useCallback, useReducer, useRef } from 'react'
import { emitUploadEvent } from '../../lib/uploadAnalytics'
import * as uploadEndpoints from '../../lib/uploadEndpoints'
// ─── Constants ──────────────────────────────────────────────────────────────
const DEFAULT_CHUNK_SIZE_BYTES = 5 * 1024 * 1024
const POLL_INTERVAL_MS = 2000
// ─── State machine ───────────────────────────────────────────────────────────
export const machineStates = {
idle: 'idle',
initializing: 'initializing',
uploading: 'uploading',
finishing: 'finishing',
processing: 'processing',
ready_to_publish: 'ready_to_publish',
publishing: 'publishing',
complete: 'complete',
error: 'error',
cancelled: 'cancelled',
}
const initialMachineState = {
state: machineStates.idle,
progress: 0,
sessionId: null,
uploadToken: null,
processingStatus: null,
isCancelling: false,
error: '',
lastAction: null,
}
function machineReducer(state, action) {
switch (action.type) {
case 'INIT_START':
return { ...state, state: machineStates.initializing, progress: 0, error: '', isCancelling: false, lastAction: 'start' }
case 'INIT_SUCCESS':
return { ...state, sessionId: action.sessionId, uploadToken: action.uploadToken, error: '' }
case 'UPLOAD_START':
return { ...state, state: machineStates.uploading, progress: 1, error: '' }
case 'UPLOAD_PROGRESS':
return { ...state, progress: Math.max(1, Math.min(95, action.progress)), error: '' }
case 'FINISH_START':
return { ...state, state: machineStates.finishing, progress: Math.max(state.progress, 96), error: '' }
case 'FINISH_SUCCESS':
return { ...state, state: machineStates.processing, progress: 100, processingStatus: action.processingStatus ?? 'processing', error: '' }
case 'PROCESSING_STATUS':
return { ...state, processingStatus: action.processingStatus ?? state.processingStatus, error: '' }
case 'READY_TO_PUBLISH':
return { ...state, state: machineStates.ready_to_publish, processingStatus: 'ready', error: '' }
case 'PUBLISH_START':
return { ...state, state: machineStates.publishing, error: '', lastAction: 'publish' }
case 'PUBLISH_SUCCESS':
return { ...state, state: machineStates.complete, error: '' }
case 'CANCEL_START':
return { ...state, isCancelling: true, error: '', lastAction: 'cancel' }
case 'CANCELLED':
return { ...state, state: machineStates.cancelled, isCancelling: false, error: '' }
case 'ERROR':
return { ...state, state: machineStates.error, isCancelling: false, error: action.error || 'Upload failed.' }
case 'RESET_MACHINE':
return { ...initialMachineState }
default:
return state
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function toPercent(loaded, total) {
if (!Number.isFinite(total) || total <= 0) return 0
return Math.max(0, Math.min(100, Math.round((loaded / total) * 100)))
}
function getProcessingValue(payload) {
const direct = String(payload?.processing_state || payload?.status || '').toLowerCase()
return direct || 'processing'
}
export function isReadyToPublishStatus(status) {
const normalized = String(status || '').toLowerCase()
return ['ready', 'processed', 'publish_ready', 'published', 'complete'].includes(normalized)
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* useUploadMachine
*
* Manages the full upload state machine lifecycle:
* init → chunk → finish → poll → publish
*
* @param {object} opts
* @param {File|null} opts.primaryFile
* @param {boolean} opts.canStartUpload
* @param {string} opts.primaryType 'image' | 'archive' | 'unknown'
* @param {boolean} opts.isArchive
* @param {number|null} opts.initialDraftId
* @param {object} opts.metadata { title, description, tags, rightsAccepted, ... }
* @param {number} [opts.chunkSize]
* @param {function} [opts.onArtworkCreated] called with artworkId after draft creation
*/
export default function useUploadMachine({
primaryFile,
canStartUpload,
primaryType,
isArchive,
initialDraftId = null,
metadata,
chunkSize = DEFAULT_CHUNK_SIZE_BYTES,
onArtworkCreated,
}) {
const [machine, dispatchMachine] = useReducer(machineReducer, initialMachineState)
const pollingTimerRef = useRef(null)
const requestControllersRef = useRef(new Set())
const publishLockRef = useRef(false)
// Resolved artwork id (draft) created at the start of the upload
const resolvedArtworkIdRef = useRef(
(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})()
)
const effectiveChunkSize = (() => {
const parsed = Number(chunkSize)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_CHUNK_SIZE_BYTES
})()
// ── Controller registry ────────────────────────────────────────────────────
const registerController = useCallback(() => {
const controller = new AbortController()
requestControllersRef.current.add(controller)
return controller
}, [])
const unregisterController = useCallback((controller) => {
if (!controller) return
requestControllersRef.current.delete(controller)
}, [])
const abortAllRequests = useCallback(() => {
requestControllersRef.current.forEach((c) => c.abort())
requestControllersRef.current.clear()
}, [])
// ── Polling ────────────────────────────────────────────────────────────────
const clearPolling = useCallback(() => {
if (pollingTimerRef.current) {
window.clearInterval(pollingTimerRef.current)
pollingTimerRef.current = null
}
}, [])
const fetchProcessingStatus = useCallback(async (sessionId, uploadToken, signal) => {
const response = await window.axios.get(uploadEndpoints.status(sessionId), {
signal,
headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined,
params: uploadToken ? { upload_token: uploadToken } : undefined,
})
return response.data || {}
}, [])
const pollProcessing = useCallback(async (sessionId, uploadToken) => {
if (!sessionId) return
try {
const statusController = registerController()
const payload = await fetchProcessingStatus(sessionId, uploadToken, statusController.signal)
unregisterController(statusController)
const processingValue = getProcessingValue(payload)
dispatchMachine({ type: 'PROCESSING_STATUS', processingStatus: processingValue })
if (isReadyToPublishStatus(processingValue)) {
dispatchMachine({ type: 'READY_TO_PUBLISH' })
clearPolling()
} else if (processingValue === 'rejected' || processingValue === 'error' || payload?.failure_reason) {
const failureMessage = payload?.failure_reason || payload?.message || `Processing ended with status: ${processingValue}`
dispatchMachine({ type: 'ERROR', error: failureMessage })
clearPolling()
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
const message = error?.response?.data?.message || 'Processing status check failed.'
dispatchMachine({ type: 'ERROR', error: message })
emitUploadEvent('upload_error', { stage: 'processing_poll', message })
clearPolling()
}
}, [fetchProcessingStatus, registerController, unregisterController, clearPolling])
const startPolling = useCallback((sessionId, uploadToken) => {
clearPolling()
pollProcessing(sessionId, uploadToken)
pollingTimerRef.current = window.setInterval(() => {
pollProcessing(sessionId, uploadToken)
}, POLL_INTERVAL_MS)
}, [clearPolling, pollProcessing])
// ── Core upload flow ───────────────────────────────────────────────────────
const runUploadFlow = useCallback(async () => {
if (!primaryFile || !canStartUpload) return
clearPolling()
dispatchMachine({ type: 'INIT_START' })
emitUploadEvent('upload_start', {
file_name: primaryFile.name,
file_size: primaryFile.size,
file_type: primaryType,
is_archive: isArchive,
})
try {
// 1. Create or reuse the artwork draft
let artworkIdForUpload = resolvedArtworkIdRef.current
if (!artworkIdForUpload) {
const derivedTitle =
String(metadata.title || '').trim() ||
String(primaryFile.name || '').replace(/\.[^.]+$/, '') ||
'Untitled upload'
const draftResponse = await window.axios.post('/api/artworks', {
title: derivedTitle,
description: String(metadata.description || '').trim() || null,
category: metadata.subCategoryId || metadata.rootCategoryId || null,
tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '',
license: Boolean(metadata.rightsAccepted),
})
const draftIdCandidate = Number(draftResponse?.data?.artwork_id ?? draftResponse?.data?.id)
if (!Number.isFinite(draftIdCandidate) || draftIdCandidate <= 0) {
throw new Error('Unable to create upload draft before finishing upload.')
}
artworkIdForUpload = Math.floor(draftIdCandidate)
resolvedArtworkIdRef.current = artworkIdForUpload
onArtworkCreated?.(artworkIdForUpload)
}
// 2. Init upload session
const initController = registerController()
const initResponse = await window.axios.post(
uploadEndpoints.init(),
{ client: 'web' },
{ signal: initController.signal }
)
unregisterController(initController)
const sessionId = initResponse?.data?.session_id
const uploadToken = initResponse?.data?.upload_token
if (!sessionId || !uploadToken) {
throw new Error('Upload session initialization returned an invalid payload.')
}
dispatchMachine({ type: 'INIT_SUCCESS', sessionId, uploadToken })
dispatchMachine({ type: 'UPLOAD_START' })
// 3. Chunked upload
let uploaded = 0
const totalSize = primaryFile.size
while (uploaded < totalSize) {
const nextOffset = Math.min(uploaded + effectiveChunkSize, totalSize)
const blob = primaryFile.slice(uploaded, nextOffset)
const payload = new FormData()
payload.append('session_id', sessionId)
payload.append('offset', String(uploaded))
payload.append('chunk_size', String(blob.size))
payload.append('total_size', String(totalSize))
payload.append('upload_token', uploadToken)
payload.append('chunk', blob)
const chunkController = registerController()
const chunkResponse = await window.axios.post(uploadEndpoints.chunk(), payload, {
signal: chunkController.signal,
headers: { 'X-Upload-Token': uploadToken },
})
unregisterController(chunkController)
const receivedBytes = Number(chunkResponse?.data?.received_bytes ?? nextOffset)
uploaded = Math.max(nextOffset, Number.isFinite(receivedBytes) ? receivedBytes : nextOffset)
const progress = chunkResponse?.data?.progress ?? toPercent(uploaded, totalSize)
dispatchMachine({ type: 'UPLOAD_PROGRESS', progress })
}
// 4. Finish + start processing
dispatchMachine({ type: 'FINISH_START' })
const finishController = registerController()
const finishResponse = await window.axios.post(
uploadEndpoints.finish(),
{ session_id: sessionId, upload_token: uploadToken, artwork_id: artworkIdForUpload },
{ signal: finishController.signal, headers: { 'X-Upload-Token': uploadToken } }
)
unregisterController(finishController)
const finishStatus = getProcessingValue(finishResponse?.data || {})
dispatchMachine({ type: 'FINISH_SUCCESS', processingStatus: finishStatus })
if (isReadyToPublishStatus(finishStatus)) {
dispatchMachine({ type: 'READY_TO_PUBLISH' })
} else {
startPolling(sessionId, uploadToken)
}
emitUploadEvent('upload_complete', { session_id: sessionId, artwork_id: artworkIdForUpload })
} catch (error) {
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
const message = error?.response?.data?.message || error?.message || 'Upload failed.'
dispatchMachine({ type: 'ERROR', error: message })
emitUploadEvent('upload_error', { stage: 'upload_flow', message })
}
}, [
primaryFile,
canStartUpload,
primaryType,
isArchive,
metadata,
effectiveChunkSize,
registerController,
unregisterController,
clearPolling,
startPolling,
onArtworkCreated,
])
// ── Cancel ─────────────────────────────────────────────────────────────────
const handleCancel = useCallback(async () => {
dispatchMachine({ type: 'CANCEL_START' })
clearPolling()
abortAllRequests()
try {
const { sessionId, uploadToken } = machine
if (sessionId) {
await window.axios.post(
uploadEndpoints.cancel(),
{ session_id: sessionId, upload_token: uploadToken },
{ headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined }
)
}
dispatchMachine({ type: 'CANCELLED' })
emitUploadEvent('upload_cancel', { session_id: machine.sessionId || null })
} catch (error) {
const message = error?.response?.data?.message || 'Cancel failed.'
dispatchMachine({ type: 'ERROR', error: message })
emitUploadEvent('upload_error', { stage: 'cancel', message })
}
}, [machine, abortAllRequests, clearPolling])
// ── Publish ────────────────────────────────────────────────────────────────
const handlePublish = useCallback(async (canPublish) => {
if (!canPublish || publishLockRef.current) return
publishLockRef.current = true
dispatchMachine({ type: 'PUBLISH_START' })
try {
const publishTargetId =
resolvedArtworkIdRef.current || initialDraftId || machine.sessionId
if (resolvedArtworkIdRef.current && resolvedArtworkIdRef.current > 0) {
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
emitUploadEvent('upload_publish', { id: publishTargetId })
return
}
if (!publishTargetId) throw new Error('Missing publish id.')
const publishController = registerController()
await window.axios.post(
uploadEndpoints.publish(publishTargetId),
{
title: String(metadata.title || '').trim() || undefined,
description: String(metadata.description || '').trim() || null,
},
{ signal: publishController.signal }
)
unregisterController(publishController)
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
emitUploadEvent('upload_publish', { id: publishTargetId })
} catch (error) {
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
const message = error?.response?.data?.message || error?.message || 'Publish failed.'
dispatchMachine({ type: 'ERROR', error: message })
emitUploadEvent('upload_error', { stage: 'publish', message })
} finally {
publishLockRef.current = false
}
}, [machine, initialDraftId, metadata.title, metadata.description, registerController, unregisterController])
// ── Reset ──────────────────────────────────────────────────────────────────
const resetMachine = useCallback(() => {
clearPolling()
abortAllRequests()
resolvedArtworkIdRef.current = (() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})()
publishLockRef.current = false
dispatchMachine({ type: 'RESET_MACHINE' })
}, [clearPolling, abortAllRequests, initialDraftId])
// ── Retry ──────────────────────────────────────────────────────────────────
const handleRetry = useCallback((canPublish) => {
clearPolling()
abortAllRequests()
if (machine.lastAction === 'publish') {
handlePublish(canPublish)
return
}
runUploadFlow()
}, [machine.lastAction, handlePublish, runUploadFlow, clearPolling, abortAllRequests])
// ── Cleanup on unmount ─────────────────────────────────────────────────────
// (callers should call resetMachine or abortAllRequests on unmount if needed)
return {
machine,
dispatchMachine,
resolvedArtworkId: resolvedArtworkIdRef.current,
runUploadFlow,
handleCancel,
handlePublish,
handleRetry,
resetMachine,
clearPolling,
abortAllRequests,
startPolling,
}
}

View File

@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from 'react'
const VISION_POLL_INTERVAL_MS = 5000
const VISION_DEBOUNCE_MS = 3000
const initialDebug = {
enabled: null,
queueConnection: '',
queuedJobs: 0,
failedJobs: 0,
triggered: false,
aiTagCount: 0,
totalTagCount: 0,
syncDone: false,
lastError: '',
}
/**
* useVisionTags
*
* Two-phase Vision AI tag suggestions triggered as soon as the upload is ready,
* BEFORE the user navigates to the Details step so suggestions arrive early:
*
* Phase 1 — Immediate (on upload completion):
* POST /api/uploads/{id}/vision-suggest
* Calls the Vision gateway (/analyze/all) synchronously and returns
* CLIP + YOLO tag suggestions within ~5-8 s. Results are shown right away.
*
* Phase 2 — Background polling:
* GET /api/artworks/{id}/tags?trigger=1
* Triggers the queue-based AutoTagArtworkJob, then polls every 5 s until
* the DB-persisted AI tags appear (after job completes). Merges with Phase 1
* results so the suggestions panel stays populated regardless of queue lag.
*
* @param {number|null} artworkId
* @param {boolean} uploadReady true once the upload reaches ready_to_publish
* @returns {{ visionSuggestedTags: any[], visionDebug: object }}
*/
export default function useVisionTags(artworkId, uploadReady) {
const [visionSuggestedTags, setVisionSuggestedTags] = useState([])
const [visionDebug, setVisionDebug] = useState(initialDebug)
// Refs for deduplication
const syncDoneRef = useRef(false)
const lastPollAtRef = useRef(0)
const abortRef = useRef(null)
// ── Tag merging helper ──────────────────────────────────────────────────
const mergeTags = useCallback((incoming) => {
setVisionSuggestedTags((prev) => {
if (!incoming.length) return prev
const seen = new Set(prev.map((t) => t?.slug || t?.name || t))
const next = [...prev]
for (const tag of incoming) {
const key = tag?.slug || tag?.name || tag
if (!seen.has(key)) {
seen.add(key)
next.push(tag)
}
}
return next
})
}, [])
// ── Phase 1: Immediate gateway call ────────────────────────────────────
const callGatewaySync = useCallback(async () => {
if (!artworkId || artworkId <= 0 || syncDoneRef.current) return
syncDoneRef.current = true
if (abortRef.current) abortRef.current.abort()
abortRef.current = new AbortController()
try {
const response = await window.axios.post(
`/api/uploads/${artworkId}/vision-suggest`,
{},
{ signal: abortRef.current.signal },
)
const payload = response?.data || {}
if (payload?.vision_enabled === false) {
setVisionDebug((prev) => ({ ...prev, enabled: false, syncDone: true }))
return
}
const tags = Array.isArray(payload?.tags) ? payload.tags : []
mergeTags(tags)
setVisionDebug((prev) => ({
...prev,
enabled: true,
syncDone: true,
aiTagCount: Math.max(prev.aiTagCount, tags.length),
}))
window.console?.debug?.('[upload][vision-tags][sync]', {
artworkId,
count: tags.length,
source: payload?.source,
})
} catch (err) {
if (err?.code === 'ERR_CANCELED') return
setVisionDebug((prev) => ({
...prev,
syncDone: true,
lastError: err?.response?.data?.reason || err?.message || '',
}))
}
}, [artworkId, mergeTags])
// ── Phase 2: Background DB poll ─────────────────────────────────────────
const pollDbTags = useCallback(async () => {
if (!artworkId || artworkId <= 0) return
const now = Date.now()
if (now - lastPollAtRef.current < VISION_DEBOUNCE_MS) return
lastPollAtRef.current = now
try {
const response = await window.axios.get(`/api/artworks/${artworkId}/tags`, {
params: { trigger: 1 },
})
const payload = response?.data || {}
if (payload?.vision_enabled === false) {
setVisionDebug((prev) => ({ ...prev, enabled: false }))
return
}
const aiTags = Array.isArray(payload?.ai_tags) ? payload.ai_tags : []
mergeTags(aiTags)
const debug = payload?.debug || {}
setVisionDebug((prev) => ({
...prev,
enabled: Boolean(payload?.vision_enabled),
queueConnection: String(debug?.queue_connection || prev.queueConnection),
queuedJobs: Number(debug?.queued_jobs ?? prev.queuedJobs),
failedJobs: Number(debug?.failed_jobs ?? prev.failedJobs),
triggered: Boolean(debug?.triggered),
aiTagCount: Math.max(prev.aiTagCount, Number(debug?.ai_tag_count || aiTags.length || 0)),
totalTagCount: Number(debug?.total_tag_count || 0),
lastError: '',
}))
window.console?.debug?.('[upload][vision-tags][poll]', {
artworkId,
aiTags: aiTags.map((t) => t?.slug || t?.name || t),
})
} catch (err) {
if (err?.response?.status === 404 || err?.response?.status === 403) return
setVisionDebug((prev) => ({
...prev,
lastError: err?.response?.data?.message || err?.message || '',
}))
}
}, [artworkId, mergeTags])
// ── Lifecycle ────────────────────────────────────────────────────────────
useEffect(() => {
if (!artworkId || !uploadReady) return
// Kick off the immediate gateway call
callGatewaySync()
// Start background polling
pollDbTags()
const timer = window.setInterval(pollDbTags, VISION_POLL_INTERVAL_MS)
return () => {
window.clearInterval(timer)
abortRef.current?.abort()
}
}, [artworkId, uploadReady, callGatewaySync, pollDbTags])
// Reset when artworkId changes (new upload)
useEffect(() => {
syncDoneRef.current = false
lastPollAtRef.current = 0
setVisionSuggestedTags([])
setVisionDebug(initialDebug)
}, [artworkId])
return { visionSuggestedTags, visionDebug }
}

View File

@@ -0,0 +1,130 @@
/**
* Shared utilities for the upload system.
* These are pure functions no React, no side effects.
*/
// ─── Constants ────────────────────────────────────────────────────────────────
export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp'])
export const IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp'])
export const ARCHIVE_EXTENSIONS = new Set(['zip', 'rar', '7z', 'tar', 'gz'])
export const ARCHIVE_MIME = 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',
])
export const PRIMARY_IMAGE_MAX_BYTES = 50 * 1024 * 1024 // 50 MB
export const PRIMARY_ARCHIVE_MAX_BYTES = 200 * 1024 * 1024 // 200 MB
export const SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024 // 10 MB
// ─── Type detection ───────────────────────────────────────────────────────────
export function getExtension(fileName = '') {
const parts = String(fileName).toLowerCase().split('.')
return parts.length > 1 ? parts.pop() : ''
}
/**
* @param {File} file
* @returns {'image' | 'archive' | 'unsupported'}
*/
export function detectFileType(file) {
if (!file) return 'unknown'
const extension = getExtension(file.name)
const mime = String(file.type || '').toLowerCase()
if (IMAGE_MIME.has(mime) || IMAGE_EXTENSIONS.has(extension)) return 'image'
if (ARCHIVE_MIME.has(mime) || ARCHIVE_EXTENSIONS.has(extension)) return 'archive'
return 'unsupported'
}
// ─── Formatting ───────────────────────────────────────────────────────────────
export function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
if (mb < 1024) return `${mb.toFixed(1)} MB`
return `${(mb / 1024).toFixed(2)} GB`
}
// ─── Image utils ──────────────────────────────────────────────────────────────
/**
* @param {File} file
* @returns {Promise<{width: number, height: number}>}
*/
export function readImageDimensions(file) {
return new Promise((resolve, reject) => {
const blobUrl = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight })
URL.revokeObjectURL(blobUrl)
}
img.onerror = () => {
reject(new Error('image_read_failed'))
URL.revokeObjectURL(blobUrl)
}
img.src = blobUrl
})
}
// ─── Category tree ────────────────────────────────────────────────────────────
export function buildCategoryTree(contentTypes = []) {
const rootsById = new Map()
contentTypes.forEach((type) => {
const categories = Array.isArray(type?.categories) ? type.categories : []
categories.forEach((category) => {
if (!category?.id) return
const key = String(category.id)
if (!rootsById.has(key)) {
rootsById.set(key, {
id: key,
name: category.name || `Category ${category.id}`,
children: [],
})
}
const root = rootsById.get(key)
const children = Array.isArray(category?.children) ? category.children : []
children.forEach((child) => {
if (!child?.id) return
const exists = root.children.some((item) => String(item.id) === String(child.id))
if (!exists) {
root.children.push({ id: String(child.id), name: child.name || `Subcategory ${child.id}` })
}
})
})
})
return Array.from(rootsById.values())
}
// ─── Content type helpers ─────────────────────────────────────────────────────
export function getContentTypeValue(type) {
if (!type) return ''
return String(type.id ?? type.key ?? type.slug ?? type.name ?? '')
}
export function getContentTypeVisualKey(type) {
const raw = String(type?.slug || type?.name || type?.key || '').toLowerCase()
const normalized = raw.replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
if (normalized.includes('wallpaper')) return 'wallpapers'
if (normalized.includes('skin')) return 'skins'
if (normalized.includes('photo')) return 'photography'
return 'other'
}
// ─── Processing status helpers ────────────────────────────────────────────────
export function getProcessingTransparencyLabel(processingStatus, machineState) {
if (!['processing', 'finishing', 'publishing'].includes(machineState)) return ''
const normalized = String(processingStatus || '').toLowerCase()
if (normalized === 'generating_preview') return 'Generating preview'
if (['processed', 'ready', 'published', 'queued', 'publish_ready'].includes(normalized)) {
return 'Preparing for publish'
}
return 'Analyzing content'
}

View File

@@ -53,6 +53,9 @@ Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group
Route::post('artworks/{id}/toggle', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'toggle'])->whereNumber('id')->name('artworks.toggle'); Route::post('artworks/{id}/toggle', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'toggle'])->whereNumber('id')->name('artworks.toggle');
Route::get('artworks/{id}/analytics', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'analytics'])->whereNumber('id')->name('artworks.analytics'); Route::get('artworks/{id}/analytics', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'analytics'])->whereNumber('id')->name('artworks.analytics');
Route::post('artworks/{id}/replace-file', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'replaceFile'])->whereNumber('id')->name('artworks.replaceFile'); Route::post('artworks/{id}/replace-file', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'replaceFile'])->whereNumber('id')->name('artworks.replaceFile');
// Versioning
Route::get('artworks/{id}/versions', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'versions'])->whereNumber('id')->name('artworks.versions');
Route::post('artworks/{id}/restore/{version_id}', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'restoreVersion'])->whereNumber('id')->whereNumber('version_id')->name('artworks.restoreVersion');
Route::get('tags/search', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'searchTags'])->name('tags.search'); Route::get('tags/search', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'searchTags'])->name('tags.search');
}); });
@@ -143,6 +146,11 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->nam
Route::get('status/{id}', [\App\Http\Controllers\Api\UploadController::class, 'status']) Route::get('status/{id}', [\App\Http\Controllers\Api\UploadController::class, 'status'])
->middleware('throttle:uploads-status') ->middleware('throttle:uploads-status')
->name('status'); ->name('status');
// Synchronous Vision gateway call — returns suggested tags immediately for Step 2 pre-fill
Route::post('{id}/vision-suggest', \App\Http\Controllers\Api\UploadVisionSuggestController::class)
->middleware('throttle:60,1')
->name('vision-suggest');
}); });
Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/uploads')->name('api.admin.uploads.')->group(function () { Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/uploads')->name('api.admin.uploads.')->group(function () {

View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkStats;
use App\Models\ArtworkVersion;
use App\Models\ArtworkVersionEvent;
use App\Models\User;
use App\Services\ArtworkVersioningService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
// ── Helpers ────────────────────────────────────────────────────────────────────
function versioningArtwork(array $attrs = []): Artwork
{
return Artwork::withoutEvents(fn () => Artwork::factory()->create($attrs));
}
beforeEach(function () {
// SQLite GREATEST() polyfill for observer compatibility
if (DB::connection()->getDriverName() === 'sqlite') {
DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) {
return max($args);
}, -1);
}
Cache::flush();
$this->user = User::factory()->create();
$this->artwork = versioningArtwork(['user_id' => $this->user->id, 'hash' => 'aaa', 'version_count' => 1]);
$this->service = new ArtworkVersioningService();
});
// ──────────────────────────────────────────────────────────────────────────────
// ArtworkVersioningService unit tests
// ──────────────────────────────────────────────────────────────────────────────
test('createNewVersion inserts a version row and marks it current', function () {
$version = $this->service->createNewVersion(
$this->artwork,
'path/to/new.webp',
'bbb',
1920, 1080, 204800,
$this->user->id,
'First replacement',
);
expect($version)->toBeInstanceOf(ArtworkVersion::class)
->and($version->version_number)->toBe(2)
->and($version->is_current)->toBeTrue()
->and($version->file_hash)->toBe('bbb');
$this->artwork->refresh();
expect($this->artwork->current_version_id)->toBe($version->id)
->and($this->artwork->version_count)->toBe(2);
});
test('createNewVersion sets previous version is_current = false', function () {
// Seed an existing "current" version row
$old = ArtworkVersion::create([
'artwork_id' => $this->artwork->id,
'version_number' => 1,
'file_path' => 'old.webp',
'file_hash' => 'aaahash',
'is_current' => true,
]);
$this->service->createNewVersion(
$this->artwork, 'new.webp', 'bbbhash',
1920, 1080, 500, $this->user->id,
);
expect(ArtworkVersion::findOrFail($old->id)->is_current)->toBeFalse();
});
test('createNewVersion writes an audit log entry', function () {
$this->service->createNewVersion(
$this->artwork, 'path.webp', 'ccc',
800, 600, 1024, $this->user->id,
);
$event = ArtworkVersionEvent::where('artwork_id', $this->artwork->id)->first();
expect($event)->not->toBeNull()
->and($event->action)->toBe('create_version')
->and($event->user_id)->toBe($this->user->id);
});
test('createNewVersion rejects identical hash', function () {
$this->artwork->update(['hash' => 'same_hash_here']);
expect(fn () => $this->service->createNewVersion(
$this->artwork, 'path.webp', 'same_hash_here',
800, 600, 1024, $this->user->id,
))->toThrow(\RuntimeException::class, 'identical');
});
test('artworkVersioningService enforces hourly rate limit', function () {
// Exhaust rate limit
for ($i = 0; $i < 3; $i++) {
$hash = 'hash_' . $i;
$this->artwork->update(['hash' => 'different_' . $i]); // avoid identical-hash rejection
$this->service->createNewVersion(
$this->artwork, 'path.webp', $hash,
800, 600, 1024, $this->user->id,
);
}
$this->artwork->update(['hash' => 'final_different']);
expect(fn () => $this->service->createNewVersion(
$this->artwork, 'path.webp', 'hash_over_limit',
800, 600, 1024, $this->user->id,
))->toThrow(\Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException::class);
});
test('shouldRequireReapproval returns false for first upload', function () {
$this->artwork->update(['width' => 0, 'height' => 0]);
expect($this->service->shouldRequireReapproval($this->artwork, 1920, 1080))->toBeFalse();
});
test('shouldRequireReapproval returns true when dimensions change drastically', function () {
$this->artwork->update(['width' => 1920, 'height' => 1080]);
// 300% increase in width → triggers
expect($this->service->shouldRequireReapproval($this->artwork, 7680, 4320))->toBeTrue();
});
test('shouldRequireReapproval returns false for small dimension change', function () {
$this->artwork->update(['width' => 1920, 'height' => 1080]);
// 5 % change → fine
expect($this->service->shouldRequireReapproval($this->artwork, 2016, 1134))->toBeFalse();
});
test('applyRankingProtection decays ranking and heat scores', function () {
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $this->artwork->id],
['ranking_score' => 100.0, 'heat_score' => 50.0, 'engagement_velocity' => 20.0]
);
$this->service->applyRankingProtection($this->artwork);
$stats = DB::table('artwork_stats')->where('artwork_id', $this->artwork->id)->first();
expect((float) $stats->ranking_score)->toBeLessThan(100.0)
->and((float) $stats->heat_score)->toBeLessThan(50.0);
});
test('restoreVersion clones old version as new current version', function () {
$old = ArtworkVersion::create([
'artwork_id' => $this->artwork->id,
'version_number' => 1,
'file_path' => 'original.webp',
'file_hash' => 'oldhash',
'width' => 1920,
'height' => 1080,
'file_size' => 99999,
'is_current' => true,
]);
// Simulate artwork being at version 2 with a different hash
$this->artwork->update(['hash' => 'currenthash', 'version_count' => 2]);
$restored = $this->service->restoreVersion($old, $this->artwork, $this->user->id);
expect($restored->version_number)->toBe(3)
->and($restored->file_hash)->toBe('oldhash')
->and($restored->change_note)->toContain('Restored from version 1');
$event = ArtworkVersionEvent::where('action', 'create_version')
->where('artwork_id', $this->artwork->id)
->orderByDesc('id')
->first();
expect($event)->not->toBeNull();
});
// ──────────────────────────────────────────────────────────────────────────────
// Version history API endpoint
// ──────────────────────────────────────────────────────────────────────────────
test('GET studio/artworks/{id}/versions returns version list', function () {
$this->actingAs($this->user);
ArtworkVersion::create([
'artwork_id' => $this->artwork->id, 'version_number' => 1,
'file_path' => 'a.webp', 'file_hash' => 'hash1', 'is_current' => false,
]);
ArtworkVersion::create([
'artwork_id' => $this->artwork->id, 'version_number' => 2,
'file_path' => 'b.webp', 'file_hash' => 'hash2', 'is_current' => true,
]);
$response = $this->getJson("/api/studio/artworks/{$this->artwork->id}/versions");
$response->assertOk()
->assertJsonCount(2, 'versions')
->assertJsonPath('versions.0.version_number', 2); // newest first
});
test('GET studio/artworks/{id}/versions rejects other users', function () {
$other = User::factory()->create();
$this->actingAs($other);
$this->getJson("/api/studio/artworks/{$this->artwork->id}/versions")
->assertStatus(404);
});
test('POST studio/artworks/{id}/restore/{version_id} restores version', function () {
$this->actingAs($this->user);
$old = ArtworkVersion::create([
'artwork_id' => $this->artwork->id, 'version_number' => 1,
'file_path' => 'restored.webp', 'file_hash' => 'restorehash',
'width' => 800, 'height' => 600, 'file_size' => 5000,
'is_current' => false,
]);
$this->artwork->update(['hash' => 'differenthash123', 'version_count' => 2]);
$response = $this->postJson("/api/studio/artworks/{$this->artwork->id}/restore/{$old->id}");
$response->assertOk()->assertJsonPath('success', true);
expect(ArtworkVersion::where('artwork_id', $this->artwork->id)
->where('file_hash', 'restorehash')
->where('is_current', true)
->exists()
)->toBeTrue();
});
test('POST restore rejects attempt to restore already-current version', function () {
$this->actingAs($this->user);
$current = ArtworkVersion::create([
'artwork_id' => $this->artwork->id, 'version_number' => 1,
'file_path' => 'x.webp', 'file_hash' => 'aaa',
'is_current' => true,
]);
$this->postJson("/api/studio/artworks/{$this->artwork->id}/restore/{$current->id}")
->assertStatus(422)
->assertJsonPath('success', false);
});