Sync deploy mirror and upstream error page
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Studio;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Uploads\UploadQueueService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
final class StudioUploadQueueApiController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, UploadQueueService $queue): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(
|
||||||
|
$queue->listPayload($request->user(), $request->only(['batch_id', 'status', 'sort']))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, UploadQueueService $queue): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['nullable', 'string', 'max:160'],
|
||||||
|
'files' => ['required', 'array', 'min:1', 'max:50'],
|
||||||
|
'files.*.name' => ['required', 'string', 'max:255'],
|
||||||
|
'defaults' => ['nullable', 'array'],
|
||||||
|
'defaults.category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||||
|
'defaults.tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||||
|
'defaults.tags.*' => ['string', 'max:64'],
|
||||||
|
'defaults.visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||||
|
'defaults.is_mature' => ['nullable', 'boolean'],
|
||||||
|
'defaults.group' => ['nullable', 'string', 'max:90'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$batch = $queue->createBatch(
|
||||||
|
$request->user(),
|
||||||
|
(array) $validated['files'],
|
||||||
|
(array) ($validated['defaults'] ?? []),
|
||||||
|
Arr::get($validated, 'name')
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'batch' => [
|
||||||
|
'id' => (int) $batch->id,
|
||||||
|
'name' => $batch->name,
|
||||||
|
],
|
||||||
|
'items' => $batch->items->map(fn ($item): array => [
|
||||||
|
'id' => (int) $item->id,
|
||||||
|
'artwork_id' => (int) $item->artwork_id,
|
||||||
|
'original_filename' => (string) $item->original_filename,
|
||||||
|
])->values()->all(),
|
||||||
|
'queue' => $queue->listPayload($request->user(), ['batch_id' => (int) $batch->id]),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markFailed(Request $request, int $id, UploadQueueService $queue): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'error_code' => ['nullable', 'string', 'max:64'],
|
||||||
|
'error_message' => ['nullable', 'string', 'max:4000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queue->markItemFailedForUser(
|
||||||
|
$request->user(),
|
||||||
|
$id,
|
||||||
|
(string) ($validated['error_code'] ?? 'upload_failed'),
|
||||||
|
(string) ($validated['error_message'] ?? 'Upload failed before processing completed.')
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulk(Request $request, UploadQueueService $queue): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'action' => ['required', 'string', 'in:publish,delete,apply_category,apply_tags,set_visibility,generate_ai'],
|
||||||
|
'item_ids' => ['required', 'array', 'min:1', 'max:200'],
|
||||||
|
'item_ids.*' => ['integer'],
|
||||||
|
'params' => ['nullable', 'array'],
|
||||||
|
'params.category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||||
|
'params.tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||||
|
'params.tags.*' => ['string', 'max:64'],
|
||||||
|
'params.visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||||
|
'confirm' => ['required_if:action,delete', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (($validated['action'] ?? '') === 'delete' && ($validated['confirm'] ?? '') !== 'DELETE') {
|
||||||
|
return response()->json([
|
||||||
|
'errors' => ['You must type DELETE to confirm draft deletion.'],
|
||||||
|
'success' => 0,
|
||||||
|
'failed' => count((array) ($validated['item_ids'] ?? [])),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $queue->bulkAction(
|
||||||
|
$request->user(),
|
||||||
|
(string) $validated['action'],
|
||||||
|
(array) $validated['item_ids'],
|
||||||
|
(array) ($validated['params'] ?? [])
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result, $result['success'] > 0 ? 200 : 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retry(Request $request, int $id, UploadQueueService $queue): JsonResponse
|
||||||
|
{
|
||||||
|
$queue->retryProcessingForUser($request->user(), $id);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
.deploy/artwork-evolution-release/app/Models/UploadBatch.php
Normal file
43
.deploy/artwork-evolution-release/app/Models/UploadBatch.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
final class UploadBatch extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_UPLOADING = 'uploading';
|
||||||
|
public const STATUS_PROCESSING = 'processing';
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
public const STATUS_COMPLETED_WITH_ERRORS = 'completed_with_errors';
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
'total_items',
|
||||||
|
'processed_items',
|
||||||
|
'failed_items',
|
||||||
|
'published_items',
|
||||||
|
'defaults_json',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'defaults_json' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UploadBatchItem::class)->orderBy('id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
final class UploadBatchItem extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_UPLOADED = 'uploaded';
|
||||||
|
public const STATUS_PROCESSING = 'processing';
|
||||||
|
public const STATUS_NEEDS_METADATA = 'needs_metadata';
|
||||||
|
public const STATUS_NEEDS_REVIEW = 'needs_review';
|
||||||
|
public const STATUS_READY = 'ready';
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
public const STATUS_PUBLISHED = 'published';
|
||||||
|
public const STATUS_DELETED = 'deleted';
|
||||||
|
|
||||||
|
public const STAGE_QUEUED = 'queued';
|
||||||
|
public const STAGE_STORED = 'stored';
|
||||||
|
public const STAGE_THUMBNAILS = 'thumbnails';
|
||||||
|
public const STAGE_VISION_ANALYSIS = 'vision_analysis';
|
||||||
|
public const STAGE_MATURITY_CHECK = 'maturity_check';
|
||||||
|
public const STAGE_METADATA_SUGGESTIONS = 'metadata_suggestions';
|
||||||
|
public const STAGE_FINALIZED = 'finalized';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'upload_batch_id',
|
||||||
|
'user_id',
|
||||||
|
'artwork_id',
|
||||||
|
'original_filename',
|
||||||
|
'status',
|
||||||
|
'processing_stage',
|
||||||
|
'error_code',
|
||||||
|
'error_message',
|
||||||
|
'metadata_completeness',
|
||||||
|
'is_ready_to_publish',
|
||||||
|
'uploaded_at',
|
||||||
|
'processed_at',
|
||||||
|
'published_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_ready_to_publish' => 'boolean',
|
||||||
|
'uploaded_at' => 'datetime',
|
||||||
|
'processed_at' => 'datetime',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function batch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(UploadBatch::class, 'upload_batch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Uploads;
|
||||||
|
|
||||||
|
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||||
|
use App\Jobs\AutoTagArtworkJob;
|
||||||
|
use App\Jobs\DetectArtworkMaturityJob;
|
||||||
|
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\UploadBatch;
|
||||||
|
use App\Models\UploadBatchItem;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Artworks\ArtworkDraftService;
|
||||||
|
use App\Services\Artworks\ArtworkPublicationService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
|
use App\Services\TagService;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class UploadQueueService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ArtworkDraftService $artworkDrafts,
|
||||||
|
private readonly ArtworkPublicationService $publication,
|
||||||
|
private readonly TagService $tags,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createBatch(User $user, array $files, array $defaults = [], ?string $name = null): UploadBatch
|
||||||
|
{
|
||||||
|
$normalizedFiles = collect($files)
|
||||||
|
->map(fn (mixed $file): array => [
|
||||||
|
'name' => trim((string) data_get($file, 'name', '')),
|
||||||
|
])
|
||||||
|
->filter(fn (array $file): bool => $file['name'] !== '')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($normalizedFiles->isEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'files' => ['Choose at least one file for the upload queue.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalizedFiles->count() > 50) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'files' => ['Bulk upload is limited to 50 files per batch in v1.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedDefaults = $this->normalizeDefaults($defaults);
|
||||||
|
|
||||||
|
$batch = DB::transaction(function () use ($user, $normalizedFiles, $normalizedDefaults, $name): UploadBatch {
|
||||||
|
$batch = UploadBatch::query()->create([
|
||||||
|
'user_id' => (int) $user->id,
|
||||||
|
'name' => $this->normalizeBatchName($name, $normalizedFiles->count()),
|
||||||
|
'status' => UploadBatch::STATUS_UPLOADING,
|
||||||
|
'total_items' => $normalizedFiles->count(),
|
||||||
|
'defaults_json' => $normalizedDefaults === [] ? null : $normalizedDefaults,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($normalizedFiles as $file) {
|
||||||
|
$draft = $this->artworkDrafts->createDraft(
|
||||||
|
$user,
|
||||||
|
$this->titleFromFilename($file['name']),
|
||||||
|
null,
|
||||||
|
Arr::get($normalizedDefaults, 'category_id'),
|
||||||
|
(bool) Arr::get($normalizedDefaults, 'is_mature', false),
|
||||||
|
Arr::get($normalizedDefaults, 'group')
|
||||||
|
);
|
||||||
|
|
||||||
|
$artwork = Artwork::query()->findOrFail($draft->artworkId);
|
||||||
|
|
||||||
|
$artwork->forceFill([
|
||||||
|
'visibility' => Arr::get($normalizedDefaults, 'visibility', Artwork::VISIBILITY_PUBLIC),
|
||||||
|
'is_public' => false,
|
||||||
|
'artwork_status' => 'draft',
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
$defaultTags = (array) Arr::get($normalizedDefaults, 'tags', []);
|
||||||
|
if ($defaultTags !== []) {
|
||||||
|
$this->tags->syncStudioTags($artwork, $defaultTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch->items()->create([
|
||||||
|
'user_id' => (int) $user->id,
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'original_filename' => $file['name'],
|
||||||
|
'status' => UploadBatchItem::STATUS_UPLOADED,
|
||||||
|
'processing_stage' => UploadBatchItem::STAGE_QUEUED,
|
||||||
|
'metadata_completeness' => $this->metadataCompleteness($artwork, false, false),
|
||||||
|
'is_ready_to_publish' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $batch;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->refreshBatch($batch->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listPayload(User $user, array $filters = []): array
|
||||||
|
{
|
||||||
|
$requestedBatchId = (int) ($filters['batch_id'] ?? 0);
|
||||||
|
$statusFilter = Str::lower(trim((string) ($filters['status'] ?? 'all')));
|
||||||
|
$sort = Str::lower(trim((string) ($filters['sort'] ?? 'newest')));
|
||||||
|
|
||||||
|
$recentBatches = UploadBatch::query()
|
||||||
|
->where('user_id', (int) $user->id)
|
||||||
|
->latest('id')
|
||||||
|
->limit(12)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$currentBatch = $requestedBatchId > 0
|
||||||
|
? $recentBatches->firstWhere('id', $requestedBatchId)
|
||||||
|
: $recentBatches->first();
|
||||||
|
|
||||||
|
if ($currentBatch) {
|
||||||
|
$currentBatch = $this->refreshBatch((int) $currentBatch->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $currentBatch
|
||||||
|
? $currentBatch->items
|
||||||
|
->loadMissing(['artwork.categories.contentType', 'artwork.tags'])
|
||||||
|
->map(fn (UploadBatchItem $item): array => $this->presentItem($this->refreshItem((int) $item->id)))
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$filteredItems = $this->applyFiltersAndSorting($items, $statusFilter, $sort)->values();
|
||||||
|
$recentBatchPayloads = $recentBatches
|
||||||
|
->map(fn (UploadBatch $batch): UploadBatch => $batch->relationLoaded('items') ? $batch : $this->refreshBatch((int) $batch->id))
|
||||||
|
->map(fn (UploadBatch $batch): array => $this->presentBatch($batch))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filters' => [
|
||||||
|
'batch_id' => $currentBatch?->id,
|
||||||
|
'status' => $statusFilter,
|
||||||
|
'sort' => $sort,
|
||||||
|
],
|
||||||
|
'batches' => $recentBatchPayloads,
|
||||||
|
'current_batch' => $currentBatch ? $this->presentBatch($currentBatch) : null,
|
||||||
|
'items' => $filteredItems->all(),
|
||||||
|
'status_options' => [
|
||||||
|
['value' => 'all', 'label' => 'All'],
|
||||||
|
['value' => UploadBatchItem::STATUS_PROCESSING, 'label' => 'Processing'],
|
||||||
|
['value' => UploadBatchItem::STATUS_NEEDS_METADATA, 'label' => 'Needs metadata'],
|
||||||
|
['value' => UploadBatchItem::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'],
|
||||||
|
['value' => UploadBatchItem::STATUS_READY, 'label' => 'Ready'],
|
||||||
|
['value' => UploadBatchItem::STATUS_FAILED, 'label' => 'Failed'],
|
||||||
|
['value' => UploadBatchItem::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||||
|
],
|
||||||
|
'sort_options' => [
|
||||||
|
['value' => 'newest', 'label' => 'Newest first'],
|
||||||
|
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||||
|
['value' => 'filename', 'label' => 'Filename'],
|
||||||
|
['value' => 'status', 'label' => 'Status'],
|
||||||
|
['value' => 'ready', 'label' => 'Ready first'],
|
||||||
|
['value' => 'failed', 'label' => 'Failed first'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markItemProcessingQueued(int $itemId): UploadBatchItem
|
||||||
|
{
|
||||||
|
$item = $this->itemQuery()->findOrFail($itemId);
|
||||||
|
|
||||||
|
$item->forceFill([
|
||||||
|
'status' => UploadBatchItem::STATUS_PROCESSING,
|
||||||
|
'processing_stage' => UploadBatchItem::STAGE_THUMBNAILS,
|
||||||
|
'error_code' => null,
|
||||||
|
'error_message' => null,
|
||||||
|
'uploaded_at' => $item->uploaded_at ?: now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->refreshBatch((int) $item->upload_batch_id);
|
||||||
|
|
||||||
|
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markItemMediaProcessed(int $itemId): UploadBatchItem
|
||||||
|
{
|
||||||
|
$item = $this->itemQuery()->findOrFail($itemId);
|
||||||
|
|
||||||
|
$item->forceFill([
|
||||||
|
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||||
|
'error_code' => null,
|
||||||
|
'error_message' => null,
|
||||||
|
'processed_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$refreshed = $this->refreshItem((int) $item->id);
|
||||||
|
$this->refreshBatch((int) $item->upload_batch_id);
|
||||||
|
|
||||||
|
return $refreshed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markItemFailed(int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
|
||||||
|
{
|
||||||
|
$item = $this->itemQuery()->findOrFail($itemId);
|
||||||
|
|
||||||
|
$item->forceFill([
|
||||||
|
'status' => UploadBatchItem::STATUS_FAILED,
|
||||||
|
'error_code' => $this->nullableString($errorCode),
|
||||||
|
'error_message' => $this->nullableText($errorMessage),
|
||||||
|
'is_ready_to_publish' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->refreshBatch((int) $item->upload_batch_id);
|
||||||
|
|
||||||
|
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markItemFailedForUser(User $user, int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
|
||||||
|
{
|
||||||
|
$item = $this->ownedItems($user, [$itemId])->first();
|
||||||
|
if (! $item) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'item' => ['Upload queue item not found.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->markItemFailed((int) $item->id, $errorCode, $errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshItem(int $itemId): UploadBatchItem
|
||||||
|
{
|
||||||
|
$item = $this->itemQuery()->findOrFail($itemId);
|
||||||
|
$state = $this->evaluateItemState($item);
|
||||||
|
|
||||||
|
$item->forceFill([
|
||||||
|
'status' => $state['status'],
|
||||||
|
'processing_stage' => $state['processing_stage'],
|
||||||
|
'metadata_completeness' => $state['metadata_completeness'],
|
||||||
|
'is_ready_to_publish' => $state['is_ready_to_publish'],
|
||||||
|
'published_at' => $state['published_at'],
|
||||||
|
'processed_at' => $state['processed_at'],
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshBatch(int $batchId): UploadBatch
|
||||||
|
{
|
||||||
|
$batch = UploadBatch::query()
|
||||||
|
->with(['items.artwork.categories.contentType', 'items.artwork.tags'])
|
||||||
|
->findOrFail($batchId);
|
||||||
|
|
||||||
|
$items = $batch->items->map(fn (UploadBatchItem $item): UploadBatchItem => $this->refreshItem((int) $item->id));
|
||||||
|
$summary = $this->summarizeItems($items);
|
||||||
|
|
||||||
|
$batch->forceFill([
|
||||||
|
'status' => $summary['status'],
|
||||||
|
'total_items' => $summary['total_items'],
|
||||||
|
'processed_items' => $summary['processed_items'],
|
||||||
|
'failed_items' => $summary['failed_items'],
|
||||||
|
'published_items' => $summary['published_items'],
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
return $batch->fresh(['items.artwork.categories.contentType', 'items.artwork.tags']) ?? $batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkAction(User $user, string $action, array $itemIds, array $params = []): array
|
||||||
|
{
|
||||||
|
$items = $this->ownedItems($user, $itemIds);
|
||||||
|
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'success' => 0,
|
||||||
|
'failed' => count($itemIds),
|
||||||
|
'errors' => ['No owned upload queue items were found.'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$success = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
try {
|
||||||
|
match ($action) {
|
||||||
|
'publish' => $this->publishItem($item),
|
||||||
|
'delete' => $this->deleteItem($item),
|
||||||
|
'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)),
|
||||||
|
'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])),
|
||||||
|
'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')),
|
||||||
|
'generate_ai' => $this->retryProcessing($item),
|
||||||
|
default => throw ValidationException::withMessages([
|
||||||
|
'action' => ['Unsupported upload queue action.'],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
$success++;
|
||||||
|
} catch (ValidationException $exception) {
|
||||||
|
$failed++;
|
||||||
|
$errors[] = collect($exception->errors())->flatten()->first() ?: 'Action failed.';
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$failed++;
|
||||||
|
$errors[] = $exception->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchIds = $items->pluck('upload_batch_id')->unique()->filter()->all();
|
||||||
|
foreach ($batchIds as $batchId) {
|
||||||
|
$this->refreshBatch((int) $batchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $success,
|
||||||
|
'failed' => $failed,
|
||||||
|
'errors' => $errors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryProcessingForUser(User $user, int $itemId): UploadBatchItem
|
||||||
|
{
|
||||||
|
$item = $this->ownedItems($user, [$itemId])->first();
|
||||||
|
if (! $item) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'item' => ['Upload queue item not found.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->retryProcessing($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function retryProcessing(UploadBatchItem $item): UploadBatchItem
|
||||||
|
{
|
||||||
|
$artwork = $item->artwork;
|
||||||
|
if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'item' => ['This item cannot be retried safely. Re-upload the original file instead.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->forceFill([
|
||||||
|
'status' => UploadBatchItem::STATUS_PROCESSING,
|
||||||
|
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||||
|
'error_code' => null,
|
||||||
|
'error_message' => null,
|
||||||
|
'is_ready_to_publish' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||||
|
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||||
|
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||||
|
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
|
||||||
|
|
||||||
|
return $this->refreshItem((int) $item->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publishItem(UploadBatchItem $item): void
|
||||||
|
{
|
||||||
|
$item = $this->refreshItem((int) $item->id);
|
||||||
|
if (! $item->is_ready_to_publish || ! $item->artwork) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'item' => ['Only ready queue items can be published.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = $item->artwork;
|
||||||
|
$artwork->forceFill([
|
||||||
|
'is_approved' => true,
|
||||||
|
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
$this->publication->publishNow($artwork->fresh() ?? $artwork);
|
||||||
|
|
||||||
|
$item->forceFill([
|
||||||
|
'status' => UploadBatchItem::STATUS_PUBLISHED,
|
||||||
|
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
|
||||||
|
'is_ready_to_publish' => false,
|
||||||
|
'published_at' => now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteItem(UploadBatchItem $item): void
|
||||||
|
{
|
||||||
|
if ($item->artwork && ! $item->artwork->trashed()) {
|
||||||
|
$item->artwork->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->forceFill([
|
||||||
|
'status' => UploadBatchItem::STATUS_DELETED,
|
||||||
|
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
|
||||||
|
'is_ready_to_publish' => false,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyCategory(UploadBatchItem $item, int $categoryId): void
|
||||||
|
{
|
||||||
|
if ($categoryId <= 0 || ! $item->artwork) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'category_id' => ['Choose a valid category.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->artwork->categories()->sync([$categoryId]);
|
||||||
|
$this->refreshItem((int) $item->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyTags(UploadBatchItem $item, array $tags): void
|
||||||
|
{
|
||||||
|
if (! $item->artwork) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'tags' => ['Artwork not found for this queue item.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedTags = collect($tags)
|
||||||
|
->map(fn (mixed $tag): string => trim((string) $tag))
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($normalizedTags->isEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'tags' => ['Enter at least one tag to apply.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mergedTags = collect($item->artwork->tags()->pluck('tags.slug')->all())
|
||||||
|
->merge($normalizedTags)
|
||||||
|
->map(fn (string $tag): string => trim($tag))
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->tags->syncStudioTags($item->artwork->fresh(['tags']) ?? $item->artwork, $mergedTags);
|
||||||
|
$this->refreshItem((int) $item->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setVisibility(UploadBatchItem $item, string $visibility): void
|
||||||
|
{
|
||||||
|
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true) || ! $item->artwork) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'visibility' => ['Choose a valid visibility.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->artwork->forceFill([
|
||||||
|
'visibility' => $visibility,
|
||||||
|
'is_public' => false,
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
$this->refreshItem((int) $item->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownedItems(User $user, array $itemIds): EloquentCollection
|
||||||
|
{
|
||||||
|
$normalizedIds = collect($itemIds)
|
||||||
|
->map(fn (mixed $id): int => (int) $id)
|
||||||
|
->filter(fn (int $id): bool => $id > 0)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->itemQuery()
|
||||||
|
->where('user_id', (int) $user->id)
|
||||||
|
->whereIn('id', $normalizedIds)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function itemQuery()
|
||||||
|
{
|
||||||
|
return UploadBatchItem::query()->with(['artwork.categories.contentType', 'artwork.tags']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyFiltersAndSorting(Collection $items, string $statusFilter, string $sort): Collection
|
||||||
|
{
|
||||||
|
$filtered = $statusFilter === 'all'
|
||||||
|
? $items
|
||||||
|
: $items->filter(fn (array $item): bool => (string) ($item['status'] ?? '') === $statusFilter);
|
||||||
|
|
||||||
|
return match ($sort) {
|
||||||
|
'oldest' => $filtered->sortBy('created_at'),
|
||||||
|
'filename' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['original_filename'] ?? ''))),
|
||||||
|
'status' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['status'] ?? ''))),
|
||||||
|
'ready' => $filtered->sortByDesc(fn (array $item): int => (int) ($item['is_ready_to_publish'] ?? false)),
|
||||||
|
'failed' => $filtered->sortByDesc(fn (array $item): int => (int) ((string) ($item['status'] ?? '') === UploadBatchItem::STATUS_FAILED)),
|
||||||
|
default => $filtered->sortByDesc('created_at'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function summarizeItems(EloquentCollection $items): array
|
||||||
|
{
|
||||||
|
$statusCounts = $items->groupBy('status')->map->count();
|
||||||
|
$activeCount = (int) ($statusCounts[UploadBatchItem::STATUS_UPLOADED] ?? 0)
|
||||||
|
+ (int) ($statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0);
|
||||||
|
$failedCount = (int) ($statusCounts[UploadBatchItem::STATUS_FAILED] ?? 0);
|
||||||
|
$publishedCount = (int) ($statusCounts[UploadBatchItem::STATUS_PUBLISHED] ?? 0);
|
||||||
|
$processedCount = $items->filter(fn (UploadBatchItem $item): bool => in_array((string) $item->status, [
|
||||||
|
UploadBatchItem::STATUS_NEEDS_METADATA,
|
||||||
|
UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||||
|
UploadBatchItem::STATUS_READY,
|
||||||
|
UploadBatchItem::STATUS_FAILED,
|
||||||
|
UploadBatchItem::STATUS_PUBLISHED,
|
||||||
|
UploadBatchItem::STATUS_DELETED,
|
||||||
|
], true))->count();
|
||||||
|
|
||||||
|
$status = UploadBatch::STATUS_COMPLETED;
|
||||||
|
if ($activeCount > 0) {
|
||||||
|
$status = $statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0
|
||||||
|
? UploadBatch::STATUS_PROCESSING
|
||||||
|
: UploadBatch::STATUS_UPLOADING;
|
||||||
|
} elseif ($failedCount > 0) {
|
||||||
|
$status = UploadBatch::STATUS_COMPLETED_WITH_ERRORS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'total_items' => $items->count(),
|
||||||
|
'processed_items' => $processedCount,
|
||||||
|
'failed_items' => $failedCount,
|
||||||
|
'published_items' => $publishedCount,
|
||||||
|
'ready_items' => (int) ($statusCounts[UploadBatchItem::STATUS_READY] ?? 0),
|
||||||
|
'processing_items' => $activeCount,
|
||||||
|
'needs_metadata_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_METADATA] ?? 0),
|
||||||
|
'needs_review_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_REVIEW] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluateItemState(UploadBatchItem $item): array
|
||||||
|
{
|
||||||
|
$artwork = $item->artwork;
|
||||||
|
$isDeleted = (string) $item->status === UploadBatchItem::STATUS_DELETED || ($artwork?->trashed() ?? false);
|
||||||
|
$isPublished = $artwork
|
||||||
|
&& (string) ($artwork->artwork_status ?? '') === 'published'
|
||||||
|
&& $artwork->published_at !== null;
|
||||||
|
$hasProcessedMedia = $artwork
|
||||||
|
&& trim((string) ($artwork->file_name ?? '')) !== ''
|
||||||
|
&& trim((string) ($artwork->file_name ?? '')) !== 'pending'
|
||||||
|
&& trim((string) ($artwork->file_path ?? '')) !== ''
|
||||||
|
&& trim((string) ($artwork->hash ?? '')) !== '';
|
||||||
|
$title = trim((string) ($artwork?->title ?? ''));
|
||||||
|
$hasTitle = $title !== '';
|
||||||
|
$hasCategory = (bool) $artwork?->categories?->first();
|
||||||
|
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
|
||||||
|
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
|
||||||
|
$aiStatus = Str::lower((string) ($artwork?->ai_status ?? ''));
|
||||||
|
$visionEnabled = (bool) config('vision.enabled', true);
|
||||||
|
|
||||||
|
$maturityPending = $visionEnabled && in_array($maturityAiStatus, [
|
||||||
|
ArtworkMaturityService::AI_STATUS_PENDING,
|
||||||
|
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
|
||||||
|
], true);
|
||||||
|
$maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
|
||||||
|
$needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed;
|
||||||
|
$needsMetadata = ! $hasTitle || ! $hasCategory;
|
||||||
|
$blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null);
|
||||||
|
$isReadyToPublish = ! $isDeleted
|
||||||
|
&& ! $isPublished
|
||||||
|
&& ! $blockingUploadFailure
|
||||||
|
&& $hasProcessedMedia
|
||||||
|
&& ! $maturityPending
|
||||||
|
&& ! $needsReview
|
||||||
|
&& ! $needsMetadata
|
||||||
|
&& $artwork
|
||||||
|
&& (int) $artwork->user_id === (int) $item->user_id;
|
||||||
|
|
||||||
|
$status = match (true) {
|
||||||
|
$isDeleted => UploadBatchItem::STATUS_DELETED,
|
||||||
|
$isPublished => UploadBatchItem::STATUS_PUBLISHED,
|
||||||
|
$blockingUploadFailure => UploadBatchItem::STATUS_FAILED,
|
||||||
|
! $hasProcessedMedia || $maturityPending => UploadBatchItem::STATUS_PROCESSING,
|
||||||
|
$needsReview => UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||||
|
$needsMetadata => UploadBatchItem::STATUS_NEEDS_METADATA,
|
||||||
|
$isReadyToPublish => UploadBatchItem::STATUS_READY,
|
||||||
|
default => UploadBatchItem::STATUS_PROCESSING,
|
||||||
|
};
|
||||||
|
|
||||||
|
$processingStage = match (true) {
|
||||||
|
in_array($status, [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED, UploadBatchItem::STATUS_FAILED], true) => UploadBatchItem::STAGE_FINALIZED,
|
||||||
|
! $hasProcessedMedia && (string) $item->processing_stage === UploadBatchItem::STAGE_QUEUED => UploadBatchItem::STAGE_QUEUED,
|
||||||
|
! $hasProcessedMedia => UploadBatchItem::STAGE_THUMBNAILS,
|
||||||
|
$maturityPending => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||||
|
in_array($aiStatus, ['queued', 'processing'], true) => UploadBatchItem::STAGE_METADATA_SUGGESTIONS,
|
||||||
|
default => UploadBatchItem::STAGE_FINALIZED,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'processing_stage' => $processingStage,
|
||||||
|
'metadata_completeness' => $this->metadataCompleteness($artwork, $hasProcessedMedia, ! $maturityPending && ! $maturityFailed),
|
||||||
|
'is_ready_to_publish' => $isReadyToPublish,
|
||||||
|
'published_at' => $isPublished ? ($item->published_at ?: now()) : null,
|
||||||
|
'processed_at' => $hasProcessedMedia ? ($item->processed_at ?: now()) : $item->processed_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function presentBatch(UploadBatch $batch): array
|
||||||
|
{
|
||||||
|
$summary = $this->summarizeItems($batch->items);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $batch->id,
|
||||||
|
'name' => $batch->name,
|
||||||
|
'status' => $summary['status'],
|
||||||
|
'total_items' => $summary['total_items'],
|
||||||
|
'processed_items' => $summary['processed_items'],
|
||||||
|
'failed_items' => $summary['failed_items'],
|
||||||
|
'published_items' => $summary['published_items'],
|
||||||
|
'ready_items' => $summary['ready_items'],
|
||||||
|
'processing_items' => $summary['processing_items'],
|
||||||
|
'needs_metadata_items' => $summary['needs_metadata_items'],
|
||||||
|
'needs_review_items' => $summary['needs_review_items'],
|
||||||
|
'defaults' => $batch->defaults_json ?? [],
|
||||||
|
'created_at' => $this->iso($batch->created_at),
|
||||||
|
'updated_at' => $this->iso($batch->updated_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function presentItem(UploadBatchItem $item): array
|
||||||
|
{
|
||||||
|
$artwork = $item->artwork;
|
||||||
|
$state = $this->evaluateItemState($item);
|
||||||
|
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
|
||||||
|
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
|
||||||
|
$missing = [];
|
||||||
|
|
||||||
|
if (! $artwork || trim((string) ($artwork->file_path ?? '')) === '' || trim((string) ($artwork->hash ?? '')) === '') {
|
||||||
|
$missing[] = 'Processing incomplete';
|
||||||
|
}
|
||||||
|
if (! $artwork || trim((string) ($artwork->title ?? '')) === '') {
|
||||||
|
$missing[] = 'Missing title';
|
||||||
|
}
|
||||||
|
if (! $artwork?->categories?->first()) {
|
||||||
|
$missing[] = 'Missing category';
|
||||||
|
}
|
||||||
|
if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) {
|
||||||
|
$missing[] = 'Needs maturity review';
|
||||||
|
} elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
|
||||||
|
$missing[] = 'Maturity analysis pending';
|
||||||
|
} elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
|
||||||
|
$missing[] = 'Maturity check failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$canRetry = ! empty($artwork?->hash)
|
||||||
|
&& ! empty($artwork?->file_path)
|
||||||
|
&& ($state['status'] === UploadBatchItem::STATUS_FAILED || $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $item->id,
|
||||||
|
'batch_id' => (int) $item->upload_batch_id,
|
||||||
|
'artwork_id' => $artwork?->id,
|
||||||
|
'title' => $artwork?->title ?: $this->titleFromFilename((string) $item->original_filename),
|
||||||
|
'original_filename' => $item->original_filename,
|
||||||
|
'status' => $state['status'],
|
||||||
|
'processing_stage' => $state['processing_stage'],
|
||||||
|
'metadata_completeness' => $state['metadata_completeness'],
|
||||||
|
'metadata_label' => $state['metadata_completeness'] . '% complete',
|
||||||
|
'is_ready_to_publish' => $state['is_ready_to_publish'],
|
||||||
|
'error_code' => $item->error_code,
|
||||||
|
'error_message' => $item->error_message,
|
||||||
|
'missing' => $missing,
|
||||||
|
'thumbnail_url' => $artwork?->thumbUrl('sm'),
|
||||||
|
'visibility' => $artwork?->visibility,
|
||||||
|
'maturity_status' => $maturityStatus,
|
||||||
|
'maturity_ai_status' => $maturityAiStatus,
|
||||||
|
'ai_status' => Str::lower((string) ($artwork?->ai_status ?? '')) ?: null,
|
||||||
|
'created_at' => $this->iso($item->created_at),
|
||||||
|
'updated_at' => $this->iso($item->updated_at),
|
||||||
|
'published_at' => $this->iso($item->published_at),
|
||||||
|
'edit_url' => $artwork ? route('studio.artworks.edit', ['id' => $artwork->id]) : null,
|
||||||
|
'public_url' => $artwork && $artwork->published_at ? route('art.show', ['id' => $artwork->id]) : null,
|
||||||
|
'actions' => [
|
||||||
|
'can_edit' => $artwork !== null && $state['status'] !== UploadBatchItem::STATUS_DELETED,
|
||||||
|
'can_publish' => $state['is_ready_to_publish'],
|
||||||
|
'can_delete' => ! in_array($state['status'], [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED], true),
|
||||||
|
'can_retry_processing' => $canRetry,
|
||||||
|
'can_generate_ai' => $artwork !== null && trim((string) ($artwork->hash ?? '')) !== '',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function metadataCompleteness(?Artwork $artwork, bool $hasProcessedMedia, bool $maturityReady): int
|
||||||
|
{
|
||||||
|
$checks = [
|
||||||
|
$hasProcessedMedia,
|
||||||
|
trim((string) ($artwork?->title ?? '')) !== '',
|
||||||
|
(bool) $artwork?->categories?->first(),
|
||||||
|
$maturityReady,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (int) round((collect($checks)->filter()->count() / count($checks)) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDefaults(array $defaults): array
|
||||||
|
{
|
||||||
|
$visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC);
|
||||||
|
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true)) {
|
||||||
|
$visibility = Artwork::VISIBILITY_PUBLIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'category_id' => ($categoryId = (int) ($defaults['category_id'] ?? 0)) > 0 ? $categoryId : null,
|
||||||
|
'tags' => collect((array) ($defaults['tags'] ?? []))
|
||||||
|
->map(fn (mixed $tag): string => trim((string) $tag))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
'visibility' => $visibility,
|
||||||
|
'is_mature' => (bool) ($defaults['is_mature'] ?? false),
|
||||||
|
'group' => $this->nullableString($defaults['group'] ?? null),
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function titleFromFilename(string $filename): string
|
||||||
|
{
|
||||||
|
$base = pathinfo($filename, PATHINFO_FILENAME);
|
||||||
|
$normalized = Str::of($base)
|
||||||
|
->replace(['_', '-'], ' ')
|
||||||
|
->squish()
|
||||||
|
->trim();
|
||||||
|
|
||||||
|
return (string) ($normalized !== '' ? Str::limit((string) $normalized, 255, '') : 'Untitled artwork');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBatchName(?string $name, int $count): string
|
||||||
|
{
|
||||||
|
$normalized = trim((string) $name);
|
||||||
|
if ($normalized !== '') {
|
||||||
|
return Str::limit($normalized, 160, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Upload Queue ' . now()->format('M j, Y g:i A') . ' (' . $count . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function iso(CarbonInterface|string|null $value): ?string
|
||||||
|
{
|
||||||
|
if ($value instanceof CarbonInterface) {
|
||||||
|
return $value->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableText(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?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('upload_batches', function (Blueprint $table) {
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name', 160)->nullable();
|
||||||
|
$table->string('status', 32)->default('uploading')->index();
|
||||||
|
$table->unsignedInteger('total_items')->default(0);
|
||||||
|
$table->unsignedInteger('processed_items')->default(0);
|
||||||
|
$table->unsignedInteger('failed_items')->default(0);
|
||||||
|
$table->unsignedInteger('published_items')->default(0);
|
||||||
|
$table->json('defaults_json')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'created_at'], 'upload_batches_user_created_idx');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('upload_batch_items', function (Blueprint $table) {
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->foreignId('upload_batch_id')->constrained('upload_batches')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
|
||||||
|
$table->string('original_filename');
|
||||||
|
$table->string('status', 32)->default('uploaded')->index();
|
||||||
|
$table->string('processing_stage', 32)->default('queued')->index();
|
||||||
|
$table->string('error_code', 64)->nullable();
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->unsignedTinyInteger('metadata_completeness')->default(0);
|
||||||
|
$table->boolean('is_ready_to_publish')->default(false)->index();
|
||||||
|
$table->timestamp('uploaded_at')->nullable();
|
||||||
|
$table->timestamp('processed_at')->nullable();
|
||||||
|
$table->timestamp('published_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['upload_batch_id', 'status'], 'upload_batch_items_batch_status_idx');
|
||||||
|
$table->index(['user_id', 'status'], 'upload_batch_items_user_status_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('upload_batch_items');
|
||||||
|
Schema::dropIfExists('upload_batches');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,941 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { usePage } from '@inertiajs/react'
|
||||||
|
import StudioLayout from '../../Layouts/StudioLayout'
|
||||||
|
|
||||||
|
const MIN_CHUNK_SIZE_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return 'Just now'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return 'Just now'
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value) {
|
||||||
|
const normalized = Number(value || 0)
|
||||||
|
if (!Number.isFinite(normalized)) return '0%'
|
||||||
|
return `${Math.max(0, Math.min(100, Math.round(normalized)))}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(raw) {
|
||||||
|
return String(raw || '')
|
||||||
|
.split(/[\n,]+/)
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClasses(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'ready':
|
||||||
|
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
|
||||||
|
case 'published':
|
||||||
|
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
|
||||||
|
case 'failed':
|
||||||
|
return 'border-rose-400/30 bg-rose-400/10 text-rose-100'
|
||||||
|
case 'needs_review':
|
||||||
|
case 'needs_metadata':
|
||||||
|
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||||
|
default:
|
||||||
|
return 'border-white/15 bg-white/5 text-slate-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchStatusClasses(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
|
||||||
|
case 'completed_with_errors':
|
||||||
|
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||||
|
case 'processing':
|
||||||
|
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
|
||||||
|
default:
|
||||||
|
return 'border-white/15 bg-white/5 text-slate-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noticeClasses(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
|
||||||
|
case 'warning':
|
||||||
|
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||||
|
default:
|
||||||
|
return 'border-rose-400/30 bg-rose-400/10 text-rose-100'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanStage(stage) {
|
||||||
|
return String(stage || 'queued').replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenCategories(contentTypes) {
|
||||||
|
return (Array.isArray(contentTypes) ? contentTypes : []).flatMap((type) => {
|
||||||
|
const parents = Array.isArray(type?.categories) ? type.categories : []
|
||||||
|
return parents.flatMap((category) => {
|
||||||
|
const children = Array.isArray(category?.children) ? category.children : []
|
||||||
|
if (children.length === 0) {
|
||||||
|
return [{
|
||||||
|
id: category.id,
|
||||||
|
label: `${type.name} / ${category.name}`,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
return children.map((child) => ({
|
||||||
|
id: child.id,
|
||||||
|
label: `${type.name} / ${category.name} / ${child.name}`,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ label, value, hint }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-white">{value}</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">{hint}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudioUploadQueue() {
|
||||||
|
const { props } = usePage()
|
||||||
|
const queueProp = props.queue || {}
|
||||||
|
const chunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Number(props.chunkSize || 0) || (5 * 1024 * 1024))
|
||||||
|
const chunkRequestTimeoutMs = Math.max(15000, Number(props.chunkRequestTimeoutMs || 0) || 45000)
|
||||||
|
const categoryOptions = useMemo(() => flattenCategories(props.contentTypes || []), [props.contentTypes])
|
||||||
|
|
||||||
|
const [queue, setQueue] = useState(queueProp)
|
||||||
|
const [selectedBatchId, setSelectedBatchId] = useState(queueProp?.filters?.batch_id ?? queueProp?.current_batch?.id ?? '')
|
||||||
|
const [statusFilter, setStatusFilter] = useState(queueProp?.filters?.status || 'all')
|
||||||
|
const [sort, setSort] = useState(queueProp?.filters?.sort || 'newest')
|
||||||
|
const [selectedIds, setSelectedIds] = useState([])
|
||||||
|
const [files, setFiles] = useState([])
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadState, setUploadState] = useState({})
|
||||||
|
const [notice, setNotice] = useState(null)
|
||||||
|
const [busyAction, setBusyAction] = useState('')
|
||||||
|
const [defaults, setDefaults] = useState({
|
||||||
|
name: '',
|
||||||
|
categoryId: '',
|
||||||
|
tags: '',
|
||||||
|
visibility: 'public',
|
||||||
|
isMature: false,
|
||||||
|
})
|
||||||
|
const [bulkForm, setBulkForm] = useState({
|
||||||
|
categoryId: '',
|
||||||
|
tags: '',
|
||||||
|
visibility: 'public',
|
||||||
|
})
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
const noticeTimeoutRef = useRef(null)
|
||||||
|
|
||||||
|
const items = Array.isArray(queue?.items) ? queue.items : []
|
||||||
|
const currentBatch = queue?.current_batch || null
|
||||||
|
const batches = Array.isArray(queue?.batches) ? queue.batches : []
|
||||||
|
const selectableIds = items
|
||||||
|
.filter((item) => item?.actions?.can_delete || item?.actions?.can_publish || item?.actions?.can_generate_ai)
|
||||||
|
.map((item) => Number(item.id))
|
||||||
|
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id))
|
||||||
|
const activeProcessing = uploading || ['uploading', 'processing'].includes(String(currentBatch?.status || ''))
|
||||||
|
|
||||||
|
const pushNotice = (type, message) => {
|
||||||
|
setNotice({ type, message })
|
||||||
|
window.clearTimeout(noticeTimeoutRef.current)
|
||||||
|
noticeTimeoutRef.current = window.setTimeout(() => setNotice(null), 4500)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(noticeTimeoutRef.current), [])
|
||||||
|
|
||||||
|
const syncSelectedIds = (queueItems) => {
|
||||||
|
const validIds = new Set((queueItems || []).map((item) => Number(item.id)))
|
||||||
|
setSelectedIds((current) => current.filter((id) => validIds.has(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadQueue = async (overrides = {}) => {
|
||||||
|
const params = {
|
||||||
|
batch_id: overrides.batch_id ?? (selectedBatchId || undefined),
|
||||||
|
status: overrides.status ?? statusFilter,
|
||||||
|
sort: overrides.sort ?? sort,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get('/api/studio/upload-queue', { params })
|
||||||
|
const nextQueue = response.data || {}
|
||||||
|
setQueue(nextQueue)
|
||||||
|
setSelectedBatchId(nextQueue?.filters?.batch_id ?? '')
|
||||||
|
syncSelectedIds(nextQueue?.items || [])
|
||||||
|
return nextQueue
|
||||||
|
} catch (error) {
|
||||||
|
pushNotice('error', error?.response?.data?.message || 'Failed to refresh the upload queue.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeProcessing || !selectedBatchId) return undefined
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
loadQueue({ batch_id: selectedBatchId })
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [activeProcessing, selectedBatchId, statusFilter, sort])
|
||||||
|
|
||||||
|
const uploadChunk = async (sessionId, uploadToken, blob, offset, totalSize) => {
|
||||||
|
const payload = new FormData()
|
||||||
|
payload.append('session_id', sessionId)
|
||||||
|
payload.append('offset', String(offset))
|
||||||
|
payload.append('chunk_size', String(blob.size))
|
||||||
|
payload.append('total_size', String(totalSize))
|
||||||
|
payload.append('chunk', blob)
|
||||||
|
payload.append('upload_token', uploadToken)
|
||||||
|
|
||||||
|
const response = await window.axios.post('/api/uploads/chunk', payload, {
|
||||||
|
timeout: chunkRequestTimeoutMs,
|
||||||
|
headers: { 'X-Upload-Token': uploadToken },
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadSingleFile = async (item, file) => {
|
||||||
|
const init = await window.axios.post('/api/uploads/init', { client: 'web' })
|
||||||
|
const sessionId = init?.data?.session_id
|
||||||
|
const uploadToken = init?.data?.upload_token
|
||||||
|
if (!sessionId || !uploadToken) {
|
||||||
|
throw new Error('Upload session initialization failed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
while (offset < file.size) {
|
||||||
|
const nextOffset = Math.min(offset + chunkSize, file.size)
|
||||||
|
const chunk = file.slice(offset, nextOffset)
|
||||||
|
const data = await uploadChunk(sessionId, uploadToken, chunk, offset, file.size)
|
||||||
|
offset = Number(data?.received_bytes ?? nextOffset)
|
||||||
|
const progress = Math.max(1, Math.min(100, Math.round((offset / file.size) * 100)))
|
||||||
|
setUploadState((current) => ({
|
||||||
|
...current,
|
||||||
|
[item.id]: {
|
||||||
|
...current[item.id],
|
||||||
|
status: 'uploading',
|
||||||
|
progress,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.axios.post('/api/uploads/finish', {
|
||||||
|
session_id: sessionId,
|
||||||
|
artwork_id: item.artwork_id,
|
||||||
|
batch_item_id: item.id,
|
||||||
|
file_name: file.name,
|
||||||
|
upload_token: uploadToken,
|
||||||
|
}, {
|
||||||
|
headers: { 'X-Upload-Token': uploadToken },
|
||||||
|
})
|
||||||
|
|
||||||
|
setUploadState((current) => ({
|
||||||
|
...current,
|
||||||
|
[item.id]: {
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const markItemFailed = async (itemId, error) => {
|
||||||
|
try {
|
||||||
|
await window.axios.post(`/api/studio/upload-queue/items/${itemId}/fail`, {
|
||||||
|
error_code: error?.response?.data?.reason || 'upload_failed',
|
||||||
|
error_message: error?.response?.data?.message || error?.message || 'Upload failed.',
|
||||||
|
})
|
||||||
|
} catch (markError) {
|
||||||
|
// Keep the original upload error as the visible one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startUpload = async () => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
pushNotice('error', 'Choose at least one image file to start a batch.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
setBusyAction('create-batch')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.axios.post('/api/studio/upload-queue/batches', {
|
||||||
|
name: defaults.name || null,
|
||||||
|
files: files.map((file) => ({ name: file.name })),
|
||||||
|
defaults: {
|
||||||
|
category_id: defaults.categoryId ? Number(defaults.categoryId) : null,
|
||||||
|
tags: parseTags(defaults.tags),
|
||||||
|
visibility: defaults.visibility,
|
||||||
|
is_mature: Boolean(defaults.isMature),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createdItems = Array.isArray(response?.data?.items) ? response.data.items : []
|
||||||
|
const batchId = response?.data?.batch?.id
|
||||||
|
if (!batchId || createdItems.length !== files.length) {
|
||||||
|
throw new Error('Batch registration did not return a usable file map.')
|
||||||
|
}
|
||||||
|
|
||||||
|
setQueue(response.data.queue || queue)
|
||||||
|
setSelectedBatchId(batchId)
|
||||||
|
setSelectedIds([])
|
||||||
|
|
||||||
|
for (let index = 0; index < createdItems.length; index += 1) {
|
||||||
|
const item = createdItems[index]
|
||||||
|
const file = files[index]
|
||||||
|
|
||||||
|
setUploadState((current) => ({
|
||||||
|
...current,
|
||||||
|
[item.id]: { status: 'queued', progress: 0 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadSingleFile(item, file)
|
||||||
|
} catch (error) {
|
||||||
|
await markItemFailed(item.id, error)
|
||||||
|
setUploadState((current) => ({
|
||||||
|
...current,
|
||||||
|
[item.id]: {
|
||||||
|
status: 'failed',
|
||||||
|
progress: current[item.id]?.progress || 0,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadQueue({ batch_id: batchId })
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles([])
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
pushNotice('success', 'Upload batch created. Processing continues in the queue.')
|
||||||
|
} catch (error) {
|
||||||
|
pushNotice('error', error?.response?.data?.message || error?.message || 'Failed to create the upload batch.')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
setBusyAction('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedIds([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedIds(selectableIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleSelected = (itemId) => {
|
||||||
|
setSelectedIds((current) => current.includes(itemId)
|
||||||
|
? current.filter((id) => id !== itemId)
|
||||||
|
: [...current, itemId])
|
||||||
|
}
|
||||||
|
|
||||||
|
const summarizePublishSelection = (ids) => {
|
||||||
|
const selectedItems = items.filter((item) => ids.includes(Number(item.id)))
|
||||||
|
const readyItems = selectedItems.filter((item) => item?.is_ready_to_publish)
|
||||||
|
const blockedItems = selectedItems.filter((item) => !item?.is_ready_to_publish)
|
||||||
|
const reviewBlockedCount = blockedItems.filter((item) => item?.status === 'needs_review').length
|
||||||
|
const metadataBlockedCount = blockedItems.filter((item) => item?.status === 'needs_metadata').length
|
||||||
|
const processingBlockedCount = blockedItems.filter((item) => item?.status === 'processing').length
|
||||||
|
const failedBlockedCount = blockedItems.filter((item) => item?.status === 'failed').length
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCount: selectedItems.length,
|
||||||
|
readyCount: readyItems.length,
|
||||||
|
blockedCount: blockedItems.length,
|
||||||
|
reviewBlockedCount,
|
||||||
|
metadataBlockedCount,
|
||||||
|
processingBlockedCount,
|
||||||
|
failedBlockedCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPublishSelection = (ids) => {
|
||||||
|
const summary = summarizePublishSelection(ids)
|
||||||
|
|
||||||
|
if (summary.totalCount === 0) {
|
||||||
|
pushNotice('warning', 'Select at least one queue item first.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.readyCount === 0) {
|
||||||
|
pushNotice('warning', 'None of the selected drafts are ready to publish yet.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
`Publish ${summary.readyCount} ready draft(s)?`,
|
||||||
|
`Selected: ${summary.totalCount}`,
|
||||||
|
`Ready now: ${summary.readyCount}`,
|
||||||
|
`Blocked and skipped: ${summary.blockedCount}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (summary.reviewBlockedCount > 0) {
|
||||||
|
message.push(`Needs review: ${summary.reviewBlockedCount}`)
|
||||||
|
}
|
||||||
|
if (summary.metadataBlockedCount > 0) {
|
||||||
|
message.push(`Missing metadata: ${summary.metadataBlockedCount}`)
|
||||||
|
}
|
||||||
|
if (summary.processingBlockedCount > 0) {
|
||||||
|
message.push(`Still processing: ${summary.processingBlockedCount}`)
|
||||||
|
}
|
||||||
|
if (summary.failedBlockedCount > 0) {
|
||||||
|
message.push(`Failed items: ${summary.failedBlockedCount}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.push('Blocked drafts will not be published.')
|
||||||
|
|
||||||
|
return window.confirm(message.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBulkAction = async (action, params = {}, ids = selectedIds) => {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
pushNotice('warning', 'Select at least one queue item first.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmValue = undefined
|
||||||
|
if (action === 'publish') {
|
||||||
|
if (!confirmPublishSelection(ids)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
const value = window.prompt('Type DELETE to remove the selected drafts from the queue.')
|
||||||
|
if (value !== 'DELETE') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmValue = value
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusyAction(action)
|
||||||
|
try {
|
||||||
|
const response = await window.axios.post('/api/studio/upload-queue/bulk', {
|
||||||
|
action,
|
||||||
|
item_ids: ids,
|
||||||
|
params,
|
||||||
|
confirm: confirmValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
const success = Number(response?.data?.success || 0)
|
||||||
|
const failed = Number(response?.data?.failed || 0)
|
||||||
|
if (failed > 0 && success === 0) {
|
||||||
|
pushNotice('error', response?.data?.errors?.[0] || 'The queue action failed.')
|
||||||
|
} else if (failed > 0) {
|
||||||
|
pushNotice('warning', `${success} item(s) updated. ${failed} item(s) could not be changed.`)
|
||||||
|
} else {
|
||||||
|
pushNotice('success', `${success} item(s) updated.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadQueue({ batch_id: selectedBatchId })
|
||||||
|
setSelectedIds([])
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.response?.data?.errors?.[0]
|
||||||
|
|| error?.response?.data?.message
|
||||||
|
|| 'The queue action failed.'
|
||||||
|
pushNotice('error', message)
|
||||||
|
} finally {
|
||||||
|
setBusyAction('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryItem = async (itemId) => {
|
||||||
|
setBusyAction(`retry-${itemId}`)
|
||||||
|
try {
|
||||||
|
await window.axios.post(`/api/studio/upload-queue/items/${itemId}/retry`)
|
||||||
|
pushNotice('success', 'Background processing has been queued again for this draft.')
|
||||||
|
await loadQueue({ batch_id: selectedBatchId })
|
||||||
|
} catch (error) {
|
||||||
|
pushNotice('error', error?.response?.data?.message || 'Retry failed for this queue item.')
|
||||||
|
} finally {
|
||||||
|
setBusyAction('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchChange = async (event) => {
|
||||||
|
const nextBatchId = event.target.value
|
||||||
|
setSelectedBatchId(nextBatchId)
|
||||||
|
await loadQueue({ batch_id: nextBatchId || undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDropFiles = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const dropped = Array.from(event.dataTransfer.files || [])
|
||||||
|
setFiles(dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StudioLayout title={props.title} subtitle={props.description}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{notice && (
|
||||||
|
<div className={`rounded-[24px] border px-4 py-3 text-sm ${noticeClasses(notice.type)}`}>
|
||||||
|
{notice.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,_rgba(15,23,42,0.84),_rgba(2,6,23,0.96))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.3)]">
|
||||||
|
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Bulk upload drafts</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-white">Start a batch, then let Studio handle the review queue.</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-300">
|
||||||
|
Each file becomes a normal draft artwork. Upload transport happens now, thumbnail and maturity work continue in the background, and publishing stays blocked until the draft is actually ready.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:w-[380px]">
|
||||||
|
<SummaryCard label="Selected files" value={files.length} hint="Add multiple images to build a batch." />
|
||||||
|
<SummaryCard label="Current batch" value={currentBatch?.total_items || 0} hint={currentBatch ? `Status: ${String(currentBatch.status || 'uploading').replace(/_/g, ' ')}` : 'No active batch selected.'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<div
|
||||||
|
className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.03] p-5 transition hover:border-sky-300/35"
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={onDropFiles}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">Drag multiple image files here</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">PNG, JPG, and WebP files are supported through the normal upload pipeline. Each file becomes one draft artwork.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-cloud-arrow-up" />
|
||||||
|
Choose files
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => setFiles(Array.from(event.target.files || []))}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-500">{files.length > 0 ? `${files.length} file(s) ready` : 'Nothing selected yet'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch contents</p>
|
||||||
|
<div className="mt-3 max-h-48 space-y-2 overflow-y-auto pr-1 text-sm text-slate-300">
|
||||||
|
{files.map((file) => (
|
||||||
|
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between gap-3 rounded-2xl border border-white/5 bg-white/[0.02] px-3 py-2">
|
||||||
|
<span className="truncate">{file.name}</span>
|
||||||
|
<span className="text-xs text-slate-500">{Math.max(1, Math.round(file.size / 1024))} KB</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||||
|
<p className="text-sm font-semibold text-white">Shared defaults</p>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<label className="block text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch name</span>
|
||||||
|
<input
|
||||||
|
value={defaults.name}
|
||||||
|
onChange={(event) => setDefaults((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
placeholder="Optional batch label"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Category</span>
|
||||||
|
<select
|
||||||
|
value={defaults.categoryId}
|
||||||
|
onChange={(event) => setDefaults((current) => ({ ...current, categoryId: event.target.value }))}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
>
|
||||||
|
<option value="">No shared category</option>
|
||||||
|
{categoryOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id} className="bg-slate-950">{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visibility when published</span>
|
||||||
|
<select
|
||||||
|
value={defaults.visibility}
|
||||||
|
onChange={(event) => setDefaults((current) => ({ ...current, visibility: event.target.value }))}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
>
|
||||||
|
<option value="public" className="bg-slate-950">Public</option>
|
||||||
|
<option value="unlisted" className="bg-slate-950">Unlisted</option>
|
||||||
|
<option value="private" className="bg-slate-950">Private</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shared tags</span>
|
||||||
|
<textarea
|
||||||
|
value={defaults.tags}
|
||||||
|
onChange={(event) => setDefaults((current) => ({ ...current, tags: event.target.value }))}
|
||||||
|
className="mt-2 min-h-[92px] w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
placeholder="fantasy, portrait, wallpaper"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={defaults.isMature}
|
||||||
|
onChange={(event) => setDefaults((current) => ({ ...current, isMature: event.target.checked }))}
|
||||||
|
className="h-4 w-4 rounded border-white/20 bg-transparent"
|
||||||
|
/>
|
||||||
|
Mark all files as creator-declared mature
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startUpload}
|
||||||
|
disabled={uploading || files.length === 0}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/45 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-play" />
|
||||||
|
{busyAction === 'create-batch' ? 'Creating batch...' : 'Start upload batch'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Queue view</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold text-white">Review a batch, then work the drafts that actually need attention.</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label className="text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch</span>
|
||||||
|
<select
|
||||||
|
value={selectedBatchId}
|
||||||
|
onChange={handleBatchChange}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
>
|
||||||
|
<option value="">Latest batch</option>
|
||||||
|
{batches.map((batch) => (
|
||||||
|
<option key={batch.id} value={batch.id} className="bg-slate-950">
|
||||||
|
{batch.name || `Batch #${batch.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Filter</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={async (event) => {
|
||||||
|
const nextStatus = event.target.value
|
||||||
|
setStatusFilter(nextStatus)
|
||||||
|
await loadQueue({ status: nextStatus })
|
||||||
|
}}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
>
|
||||||
|
{(queue?.status_options || []).map((option) => (
|
||||||
|
<option key={option.value} value={option.value} className="bg-slate-950">{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sort</span>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={async (event) => {
|
||||||
|
const nextSort = event.target.value
|
||||||
|
setSort(nextSort)
|
||||||
|
await loadQueue({ sort: nextSort })
|
||||||
|
}}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
>
|
||||||
|
{(queue?.sort_options || []).map((option) => (
|
||||||
|
<option key={option.value} value={option.value} className="bg-slate-950">{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentBatch && (
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-5">
|
||||||
|
<SummaryCard label="Batch status" value={String(currentBatch.status || 'uploading').replace(/_/g, ' ')} hint={`Updated ${formatDate(currentBatch.updated_at)}`} />
|
||||||
|
<SummaryCard label="Ready" value={currentBatch.ready_items || 0} hint="Can be published right now." />
|
||||||
|
<SummaryCard label="Processing" value={currentBatch.processing_items || 0} hint="Still moving through the pipeline." />
|
||||||
|
<SummaryCard label="Needs review" value={currentBatch.needs_review_items || 0} hint="Blocked on maturity or review." />
|
||||||
|
<SummaryCard label="Failed" value={currentBatch.failed_items || 0} hint="Needs retry or a fresh upload." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-[28px] border border-white/10 bg-slate-950/35 p-4">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
disabled={selectableIds.length === 0}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-check-double" />
|
||||||
|
{allSelected ? 'Clear selection' : 'Select visible'}
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-500">{selectedIds.length} selected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('publish')}
|
||||||
|
disabled={busyAction !== '' || selectedIds.length === 0}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Publish selected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('generate_ai')}
|
||||||
|
disabled={busyAction !== '' || selectedIds.length === 0}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Generate AI
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('delete')}
|
||||||
|
disabled={busyAction !== '' || selectedIds.length === 0}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Delete selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 lg:grid-cols-[1fr,1fr,auto,auto]">
|
||||||
|
<select
|
||||||
|
value={bulkForm.categoryId}
|
||||||
|
onChange={(event) => setBulkForm((current) => ({ ...current, categoryId: event.target.value }))}
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
>
|
||||||
|
<option value="">Apply category...</option>
|
||||||
|
{categoryOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id} className="bg-slate-950">{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={bulkForm.tags}
|
||||||
|
onChange={(event) => setBulkForm((current) => ({ ...current, tags: event.target.value }))}
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
placeholder="Add shared tags to selection"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={bulkForm.visibility}
|
||||||
|
onChange={(event) => setBulkForm((current) => ({ ...current, visibility: event.target.value }))}
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||||
|
>
|
||||||
|
<option value="public" className="bg-slate-950">Public</option>
|
||||||
|
<option value="unlisted" className="bg-slate-950">Unlisted</option>
|
||||||
|
<option value="private" className="bg-slate-950">Private</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('apply_category', { category_id: Number(bulkForm.categoryId) })}
|
||||||
|
disabled={busyAction !== '' || selectedIds.length === 0 || !bulkForm.categoryId}
|
||||||
|
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Apply category
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('apply_tags', { tags: parseTags(bulkForm.tags) })}
|
||||||
|
disabled={busyAction !== '' || selectedIds.length === 0 || parseTags(bulkForm.tags).length === 0}
|
||||||
|
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Apply tags
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('set_visibility', { visibility: bulkForm.visibility })}
|
||||||
|
disabled={busyAction !== '' || selectedIds.length === 0}
|
||||||
|
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Set visibility
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.02] p-8 text-center text-sm text-slate-400">
|
||||||
|
No items match this view yet. Start a batch above or switch to another recent batch.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const localUpload = uploadState[item.id] || null
|
||||||
|
const progress = localUpload?.progress ?? null
|
||||||
|
const actionState = item.actions || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={item.id} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(3,7,18,0.18)]">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<label className="mt-1 inline-flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.includes(Number(item.id))}
|
||||||
|
onChange={() => handleToggleSelected(Number(item.id))}
|
||||||
|
className="h-4 w-4 rounded border-white/20 bg-transparent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="h-24 w-24 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
|
||||||
|
{item.thumbnail_url ? (
|
||||||
|
<img src={item.thumbnail_url} alt={item.title} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-slate-500">
|
||||||
|
<i className="fa-solid fa-image text-2xl" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${statusClasses(item.status)}`}>
|
||||||
|
{String(item.status || 'processing').replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${batchStatusClasses(item.processing_stage === 'finalized' ? 'completed' : 'processing')}`}>
|
||||||
|
{humanStage(item.processing_stage)}
|
||||||
|
</span>
|
||||||
|
{item.is_ready_to_publish && (
|
||||||
|
<span className="inline-flex items-center rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">
|
||||||
|
Ready to publish
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
|
||||||
|
<p className="mt-1 truncate text-sm text-slate-400">{item.original_filename}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-2 text-sm text-slate-300 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Metadata</span>
|
||||||
|
<p className="mt-2 text-white">{item.metadata_label}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Updated</span>
|
||||||
|
<p className="mt-2 text-white">{formatDate(item.updated_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{typeof progress === 'number' && progress < 100 && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-sky-300/20 bg-sky-300/10 px-3 py-3 text-sm text-sky-100">
|
||||||
|
Uploading now: {formatPercent(progress)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(item.missing) && item.missing.length > 0 && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-3 text-sm text-slate-300">
|
||||||
|
{item.missing.join(' • ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.error_message && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-3 py-3 text-sm text-rose-100">
|
||||||
|
{item.error_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
{actionState.can_edit && item.edit_url && (
|
||||||
|
<a href={item.edit_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06]">
|
||||||
|
<i className="fa-solid fa-pen-to-square" />
|
||||||
|
Edit in Studio
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{actionState.can_publish && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('publish', {}, [item.id])}
|
||||||
|
disabled={busyAction !== ''}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-rocket" />
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{actionState.can_generate_ai && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('generate_ai', {}, [item.id])}
|
||||||
|
disabled={busyAction !== ''}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||||
|
Generate AI
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{actionState.can_retry_processing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => retryItem(item.id)}
|
||||||
|
disabled={busyAction !== ''}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-sm font-semibold text-amber-100 transition hover:border-amber-400/40 hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-rotate-right" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{actionState.can_delete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBulkAction('delete', {}, [item.id])}
|
||||||
|
disabled={busyAction !== ''}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-trash" />
|
||||||
|
Delete draft
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</StudioLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function shinyFlagUrl(countryCode, filesCdnUrl = '') {
|
||||||
|
const normalized = String(countryCode ?? '').trim().toUpperCase()
|
||||||
|
|
||||||
|
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = String(filesCdnUrl ?? '').replace(/\/+$/, '')
|
||||||
|
const relativePath = `/images/flags/shiny/24/${encodeURIComponent(normalized)}.png`
|
||||||
|
|
||||||
|
return base ? `${base}${relativePath}` : relativePath
|
||||||
|
}
|
||||||
197
public/errors/upstream-gateway.html
Normal file
197
public/errors/upstream-gateway.html
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Skinbase Nova Temporarily Unavailable</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg0: #020617;
|
||||||
|
--bg1: #0f172a;
|
||||||
|
--panel: rgba(15, 23, 42, 0.72);
|
||||||
|
--panel-border: rgba(148, 163, 184, 0.16);
|
||||||
|
--text: #f8fafc;
|
||||||
|
--muted: rgba(226, 232, 240, 0.72);
|
||||||
|
--soft: rgba(226, 232, 240, 0.42);
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent-strong: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(56, 189, 248, 0.18), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(244, 114, 182, 0.16), transparent 32%),
|
||||||
|
linear-gradient(180deg, var(--bg1) 0%, var(--bg0) 100%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 32px;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.86), rgba(2, 6, 23, 0.94));
|
||||||
|
box-shadow: 0 30px 100px rgba(2, 6, 23, 0.45);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 14%, rgba(125, 211, 252, 0.16), transparent 24%),
|
||||||
|
radial-gradient(circle at 84% 0%, rgba(244, 114, 182, 0.14), transparent 30%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
padding: 56px 28px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: clamp(72px, 18vw, 132px);
|
||||||
|
line-height: 0.9;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
color: rgba(56, 189, 248, 0.18);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.24);
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
color: #c4f1ff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 22px 0 14px;
|
||||||
|
font-size: clamp(34px, 7vw, 48px);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: clamp(16px, 2vw, 20px);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.button-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 22px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: linear-gradient(180deg, var(--accent), var(--accent-strong));
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 18px 40px rgba(14, 165, 233, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(248, 250, 252, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover,
|
||||||
|
.button-secondary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
border-color: rgba(226, 232, 240, 0.24);
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote {
|
||||||
|
margin-top: 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.content {
|
||||||
|
padding: 44px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.button-secondary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell" aria-labelledby="error-title">
|
||||||
|
<div class="content">
|
||||||
|
<div class="center">
|
||||||
|
<div class="code">50x</div>
|
||||||
|
<div class="badge">Gateway Error</div>
|
||||||
|
<h1 id="error-title">Skinbase Nova Is Temporarily Out of Orbit</h1>
|
||||||
|
<p>The site is reachable, but the application layer did not answer in time. This is usually brief. Give it a moment and try again.</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button" href="javascript:window.location.reload()">Try Again</a>
|
||||||
|
<a class="button-secondary" href="/">Return Home</a>
|
||||||
|
<a class="button-secondary" href="/discover/trending">Browse Discover</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footnote">Static upstream fallback for nginx-level 502 and 504 responses</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user