{item.title}
+{item.original_filename}
+ +{item.metadata_label}
+{formatDate(item.updated_at)}
+diff --git a/.deploy/artwork-evolution-release/app/Http/Controllers/Studio/StudioUploadQueueApiController.php b/.deploy/artwork-evolution-release/app/Http/Controllers/Studio/StudioUploadQueueApiController.php new file mode 100644 index 00000000..714efe80 --- /dev/null +++ b/.deploy/artwork-evolution-release/app/Http/Controllers/Studio/StudioUploadQueueApiController.php @@ -0,0 +1,113 @@ +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]); + } +} \ No newline at end of file diff --git a/.deploy/artwork-evolution-release/app/Models/UploadBatch.php b/.deploy/artwork-evolution-release/app/Models/UploadBatch.php new file mode 100644 index 00000000..ebfd2c58 --- /dev/null +++ b/.deploy/artwork-evolution-release/app/Models/UploadBatch.php @@ -0,0 +1,43 @@ + 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function items(): HasMany + { + return $this->hasMany(UploadBatchItem::class)->orderBy('id'); + } +} \ No newline at end of file diff --git a/.deploy/artwork-evolution-release/app/Models/UploadBatchItem.php b/.deploy/artwork-evolution-release/app/Models/UploadBatchItem.php new file mode 100644 index 00000000..aba76a72 --- /dev/null +++ b/.deploy/artwork-evolution-release/app/Models/UploadBatchItem.php @@ -0,0 +1,66 @@ + '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); + } +} \ No newline at end of file diff --git a/.deploy/artwork-evolution-release/app/Services/Uploads/UploadQueueService.php b/.deploy/artwork-evolution-release/app/Services/Uploads/UploadQueueService.php new file mode 100644 index 00000000..d53ef4cb --- /dev/null +++ b/.deploy/artwork-evolution-release/app/Services/Uploads/UploadQueueService.php @@ -0,0 +1,760 @@ +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, ''); + } +} \ No newline at end of file diff --git a/.deploy/artwork-evolution-release/database/migrations/2026_04_18_220000_create_upload_batch_tables.php b/.deploy/artwork-evolution-release/database/migrations/2026_04_18_220000_create_upload_batch_tables.php new file mode 100644 index 00000000..cd9be7dc --- /dev/null +++ b/.deploy/artwork-evolution-release/database/migrations/2026_04_18_220000_create_upload_batch_tables.php @@ -0,0 +1,53 @@ +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'); + } +}; \ No newline at end of file diff --git a/.deploy/artwork-evolution-release/resources/js/Pages/Studio/StudioUploadQueue.jsx b/.deploy/artwork-evolution-release/resources/js/Pages/Studio/StudioUploadQueue.jsx new file mode 100644 index 00000000..8d75d94e --- /dev/null +++ b/.deploy/artwork-evolution-release/resources/js/Pages/Studio/StudioUploadQueue.jsx @@ -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 ( +
{label}
+{value}
+{hint}
+Bulk upload drafts
++ 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. +
+Drag multiple image files here
+PNG, JPG, and WebP files are supported through the normal upload pipeline. Each file becomes one draft artwork.
+Batch contents
+Shared defaults
+Queue view
+{item.original_filename}
+ +{item.metadata_label}
+{formatDate(item.updated_at)}
+The site is reachable, but the application layer did not answer in time. This is usually brief. Give it a moment and try again.
+ + + +