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

@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use App\Services\ThumbnailService;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable;
@@ -47,13 +48,20 @@ class Artwork extends Model
'published_at',
'hash',
'thumb_ext',
'file_ext'
'file_ext',
// Versioning
'current_version_id',
'version_count',
'version_updated_at',
'requires_reapproval',
];
protected $casts = [
'is_public' => 'boolean',
'is_approved' => 'boolean',
'published_at' => 'datetime',
'is_public' => 'boolean',
'is_approved' => 'boolean',
'published_at' => 'datetime',
'version_updated_at' => 'datetime',
'requires_reapproval' => 'boolean',
];
/**
@@ -193,6 +201,18 @@ class Artwork extends Model
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
{
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);
}
}