From 56eaa3bcbf3ceaef1f4c732a84ad1092aa92c482 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Thu, 16 Apr 2026 14:44:41 +0200 Subject: [PATCH] Improve studio artwork media revisions --- .../Studio/StudioArtworksApiController.php | 427 ++++++++- .../js/Pages/Studio/StudioArtworkEdit.jsx | 839 +++++++++++++++--- scripts/deploy-production.sh | 29 + 3 files changed, 1169 insertions(+), 126 deletions(-) diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php index a6176bb7..dad6aa3c 100644 --- a/app/Http/Controllers/Studio/StudioArtworksApiController.php +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -13,6 +13,7 @@ use App\Services\ArtworkEvolutionService; use App\Services\Cdn\ArtworkCdnPurgeService; use App\Services\ArtworkSearchIndexer; use App\Services\ArtworkAttributionService; +use App\Services\Artworks\ArtworkPublicationService; use App\Services\TagService; use App\Services\ArtworkVersioningService; use App\Services\Studio\StudioArtworkQueryService; @@ -21,6 +22,7 @@ use App\Services\Tags\TagDiscoveryService; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Validator; @@ -40,6 +42,7 @@ final class StudioArtworksApiController extends Controller private readonly TagDiscoveryService $tagDiscoveryService, private readonly TagService $tagService, private readonly ArtworkCdnPurgeService $cdnPurge, + private readonly ArtworkPublicationService $artworkPublication, ) {} /** @@ -50,6 +53,8 @@ final class StudioArtworksApiController extends Controller { $userId = $request->user()->id; + $this->artworkPublication->publishDueScheduledForUser((int) $userId); + $filters = $request->only([ 'q', 'status', 'category', 'tags', 'date_from', 'date_to', 'performance', 'sort', @@ -418,6 +423,10 @@ final class StudioArtworksApiController extends Controller private function transformArtwork($artwork): array { + if ($artwork instanceof Artwork) { + $artwork = $this->artworkPublication->publishIfDue($artwork); + } + $stats = $artwork->stats ?? null; return [ @@ -468,6 +477,7 @@ final class StudioArtworksApiController extends Controller public function replaceFile(Request $request, int $id): JsonResponse { $artwork = $request->user()->artworks()->findOrFail($id); + $previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork); $request->validate([ 'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB @@ -548,16 +558,14 @@ final class StudioArtworksApiController extends Controller 'height' => max(1, $height), ]); - // 5. Create version record, apply ranking protection, audit log - $version = $this->versioningService->createNewVersion( + // 5. Create version record from the new full media snapshot. + $artwork->refresh(); + $version = $this->versioningService->createVersionFromSnapshot( $artwork, - $originalRelative, - $hash, - max(1, $width), - max(1, $height), - $size, + $this->versioningService->captureArtworkSnapshot($artwork), $request->user()->id, $request->input('change_note'), + $previousSnapshot, ); // 6. Reindex in Meilisearch (non-blocking) @@ -575,14 +583,9 @@ final class StudioArtworksApiController extends Controller return response()->json([ 'success' => true, - 'thumb_url' => $artwork->thumbUrl('md'), - 'thumb_url_lg' => $artwork->thumbUrl('lg'), - 'width' => $artwork->width, - 'height' => $artwork->height, - 'file_size' => $artwork->file_size, 'version_number' => $version->version_number, 'requires_reapproval' => (bool) $artwork->requires_reapproval, - ]); + ] + $this->mediaPayload($artwork)); } catch (\Throwable $e) { Log::error('replaceFile: processing error', [ 'artwork_id' => $artwork->id, @@ -592,6 +595,259 @@ final class StudioArtworksApiController extends Controller } } + public function reviseMedia(Request $request, int $id): JsonResponse + { + $artwork = $request->user()->artworks()->findOrFail($id); + $previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork); + $filesByVariant = $this->snapshotFilesByVariant($previousSnapshot); + $hasArchiveFile = array_key_exists('orig_archive', $filesByVariant); + + $request->validate([ + 'cover_file' => 'sometimes|nullable|file|mimes:jpeg,jpg,png,webp|max:51200', + 'archive_file' => 'sometimes|nullable|file|mimes:zip,rar,7z,tar,gz|max:204800', + 'screenshot_files' => 'sometimes|array|max:4', + 'screenshot_files.*' => 'file|mimes:jpeg,jpg,png,webp|max:51200', + 'replace_shots' => 'sometimes|array|max:4', + 'replace_shots.*' => 'file|mimes:jpeg,jpg,png,webp|max:51200', + 'remove_shots' => 'sometimes|array|max:4', + 'remove_shots.*' => 'integer|min:0|max:3', + 'change_note' => 'sometimes|nullable|string|max:500', + ]); + + /** @var UploadedFile|null $coverFile */ + $coverFile = $request->file('cover_file'); + /** @var UploadedFile|null $archiveFile */ + $archiveFile = $request->file('archive_file'); + $screenshotFiles = array_values(array_filter((array) $request->file('screenshot_files', []), fn ($file): bool => $file instanceof UploadedFile)); + $replaceShotFiles = array_filter((array) $request->file('replace_shots', []), fn ($file): bool => $file instanceof UploadedFile); + $removeShotIndexes = collect((array) $request->input('remove_shots', [])) + ->map(fn ($value): int => (int) $value) + ->filter(fn (int $value): bool => $value >= 0) + ->unique() + ->values(); + + if (! $hasArchiveFile && $archiveFile) { + return response()->json([ + 'success' => false, + 'error' => 'Archive package replacement is available only for archive artworks.', + ], 422); + } + + if (! $coverFile && ! $archiveFile && $screenshotFiles === [] && $replaceShotFiles === [] && $removeShotIndexes->isEmpty()) { + return response()->json([ + 'success' => false, + 'error' => 'Choose a new cover screenshot, a new archive file, or extra screenshots first.', + ], 422); + } + + $existingScreenshots = collect($previousSnapshot['files'] ?? []) + ->filter(fn (array $file): bool => str_starts_with((string) ($file['variant'] ?? ''), 'shot')) + ->values(); + + $remainingScreenshotCount = max(0, $existingScreenshots->count() - $removeShotIndexes->count()); + + if (($remainingScreenshotCount + count($screenshotFiles)) > 4) { + return response()->json([ + 'success' => false, + 'error' => 'Artworks can have up to 5 screenshots total: 1 main cover plus 4 additional screenshots.', + ], 422); + } + + try { + $this->versioningService->rateLimitCheck($request->user()->id, $artwork->id); + } catch (TooManyRequestsHttpException $e) { + return response()->json(['success' => false, 'error' => $e->getMessage()], 429); + } + + $derivatives = app(\App\Services\Uploads\UploadDerivativesService::class); + $storage = app(\App\Services\Uploads\UploadStorageService::class); + $cleanupLocalPaths = []; + $cleanupObjectPaths = []; + + try { + $coverDescriptor = null; + $coverHash = (string) ($previousSnapshot['artwork']['hash'] ?? $artwork->hash ?? ''); + $width = (int) ($previousSnapshot['artwork']['width'] ?? $artwork->width ?? 0); + $height = (int) ($previousSnapshot['artwork']['height'] ?? $artwork->height ?? 0); + $publicAssets = $this->currentPublicAssetsFromSnapshot($previousSnapshot); + + if ($coverFile) { + $coverHash = hash_file('sha256', $coverFile->getPathname()); + $coverStored = $derivatives->storeOriginal($coverFile->getPathname(), $coverHash, $coverFile->getClientOriginalName()); + $cleanupLocalPaths[] = $coverStored['local_path']; + $cleanupObjectPaths[] = $coverStored['object_path']; + $coverDescriptor = $this->storedOriginalDescriptor($coverStored, 'orig_image'); + + $publicAssets = $derivatives->generatePublicDerivatives($coverFile->getPathname(), $coverHash); + foreach ($publicAssets as $asset) { + $cleanupObjectPaths[] = (string) ($asset['path'] ?? ''); + } + + $dims = @getimagesize($coverFile->getPathname()); + $width = is_array($dims) && isset($dims[0]) ? max(1, (int) $dims[0]) : max(1, (int) $artwork->width); + $height = is_array($dims) && isset($dims[1]) ? max(1, (int) $dims[1]) : max(1, (int) $artwork->height); + } else { + $coverDescriptor = $hasArchiveFile + ? ($this->existingVariantDescriptor($filesByVariant['orig_image'] ?? null, 'orig_image') ?? $this->existingVariantDescriptor($filesByVariant['orig'] ?? null, 'orig')) + : $this->existingVariantDescriptor($filesByVariant['orig'] ?? null, 'orig'); + } + + if (! $coverDescriptor) { + return response()->json(['success' => false, 'error' => 'Unable to resolve the current cover screenshot.'], 422); + } + + $archiveDescriptor = null; + if ($hasArchiveFile || $archiveFile) { + if ($archiveFile) { + $archiveHash = hash_file('sha256', $archiveFile->getPathname()); + $archiveStored = $derivatives->storeOriginal($archiveFile->getPathname(), $archiveHash, $archiveFile->getClientOriginalName()); + $cleanupLocalPaths[] = $archiveStored['local_path']; + $cleanupObjectPaths[] = $archiveStored['object_path']; + $archiveDescriptor = $this->storedOriginalDescriptor($archiveStored, 'orig_archive'); + } else { + $archiveDescriptor = $this->existingVariantDescriptor($filesByVariant['orig_archive'] ?? null, 'orig_archive'); + } + } + + $newScreenshotDescriptors = []; + foreach ($screenshotFiles as $screenshotFile) { + $screenshotHash = hash_file('sha256', $screenshotFile->getPathname()); + $screenshotStored = $derivatives->storeOriginal($screenshotFile->getPathname(), $screenshotHash, $screenshotFile->getClientOriginalName()); + $cleanupLocalPaths[] = $screenshotStored['local_path']; + $cleanupObjectPaths[] = $screenshotStored['object_path']; + $newScreenshotDescriptors[] = $this->storedOriginalDescriptor($screenshotStored, ''); + } + + // Build per-slot replacement descriptors + $replaceShotDescriptors = []; + foreach ($replaceShotFiles as $slotIndex => $replaceShotFile) { + $replaceShotHash = hash_file('sha256', $replaceShotFile->getPathname()); + $replaceShotStored = $derivatives->storeOriginal($replaceShotFile->getPathname(), $replaceShotHash, $replaceShotFile->getClientOriginalName()); + $cleanupLocalPaths[] = $replaceShotStored['local_path']; + $cleanupObjectPaths[] = $replaceShotStored['object_path']; + $replaceShotDescriptors[(int) $slotIndex] = $this->storedOriginalDescriptor($replaceShotStored, ''); + } + + // Merge existing slots with per-slot replacements + $existingScreenshotDescriptors = $existingScreenshots + ->map(function (array $file, int $index) use ($replaceShotDescriptors, $removeShotIndexes): ?array { + if ($removeShotIndexes->contains($index)) { + return null; + } + + if (isset($replaceShotDescriptors[$index])) { + return $replaceShotDescriptors[$index]; + } + + return $this->existingVariantDescriptor($file, ''); + }) + ->filter() + ->values() + ->all(); + + $allScreenshotDescriptors = []; + foreach (array_values(array_merge($existingScreenshotDescriptors, $newScreenshotDescriptors)) as $index => $descriptor) { + $descriptor['variant'] = 'shot' . ($index + 1); + $allScreenshotDescriptors[] = $descriptor; + } + + $primaryDescriptor = $archiveDescriptor ?? $coverDescriptor; + $preferredFileName = $archiveFile?->getClientOriginalName() + ?? ($archiveDescriptor ? (string) ($previousSnapshot['artwork']['file_name'] ?? $artwork->file_name) : $coverFile?->getClientOriginalName()) + ?? (string) ($previousSnapshot['artwork']['file_name'] ?? $artwork->file_name); + + $snapshot = [ + 'artwork' => [ + 'file_name' => $this->resolvePreferredFileName($preferredFileName, (string) ($primaryDescriptor['ext'] ?? '')), + 'file_path' => (string) ($primaryDescriptor['path'] ?? ''), + 'hash' => $coverHash, + 'file_ext' => (string) ($primaryDescriptor['ext'] ?? ''), + 'thumb_ext' => 'webp', + 'file_size' => (int) ($primaryDescriptor['size'] ?? 0), + 'mime_type' => (string) ($primaryDescriptor['mime'] ?? 'application/octet-stream'), + 'width' => max(1, $width), + 'height' => max(1, $height), + ], + 'files' => array_values(array_filter(array_merge( + [[ + 'variant' => 'orig', + 'path' => (string) ($primaryDescriptor['path'] ?? ''), + 'mime' => (string) ($primaryDescriptor['mime'] ?? 'application/octet-stream'), + 'size' => (int) ($primaryDescriptor['size'] ?? 0), + ]], + [[ + 'variant' => 'orig_image', + 'path' => (string) ($coverDescriptor['path'] ?? ''), + 'mime' => (string) ($coverDescriptor['mime'] ?? 'application/octet-stream'), + 'size' => (int) ($coverDescriptor['size'] ?? 0), + ]], + $archiveDescriptor ? [[ + 'variant' => 'orig_archive', + 'path' => (string) ($archiveDescriptor['path'] ?? ''), + 'mime' => (string) ($archiveDescriptor['mime'] ?? 'application/octet-stream'), + 'size' => (int) ($archiveDescriptor['size'] ?? 0), + ]] : [], + collect($publicAssets)->map(fn (array $asset, string $variant): array => [ + 'variant' => $variant, + 'path' => (string) ($asset['path'] ?? ''), + 'mime' => (string) ($asset['mime'] ?? 'image/webp'), + 'size' => (int) ($asset['size'] ?? 0), + ])->values()->all(), + array_map(fn (array $descriptor): array => [ + 'variant' => (string) $descriptor['variant'], + 'path' => (string) $descriptor['path'], + 'mime' => (string) ($descriptor['mime'] ?? 'application/octet-stream'), + 'size' => (int) ($descriptor['size'] ?? 0), + ], $allScreenshotDescriptors), + ), fn (array $file): bool => (string) ($file['variant'] ?? '') !== '' && (string) ($file['path'] ?? '') !== '')), + ]; + + $this->versioningService->applySnapshot($artwork, $snapshot); + $artwork->refresh(); + + $version = $this->versioningService->createVersionFromSnapshot( + $artwork, + $snapshot, + $request->user()->id, + $request->input('change_note'), + $previousSnapshot, + ); + + try { + $this->searchIndexer->update($artwork); + } catch (\Throwable $exception) { + Log::warning('Archive media revision reindex failed', [ + 'artwork_id' => $artwork->id, + 'error' => $exception->getMessage(), + ]); + } + + return response()->json([ + 'success' => true, + 'version_number' => $version->version_number, + 'requires_reapproval' => (bool) $artwork->requires_reapproval, + ] + $this->mediaPayload($artwork)); + } catch (\Throwable $exception) { + foreach ($cleanupLocalPaths as $path) { + $storage->deleteLocalFile($path); + } + + foreach ($cleanupObjectPaths as $path) { + $storage->deleteObject($path); + } + + Log::error('reviseMedia: processing error', [ + 'artwork_id' => $artwork->id, + 'error' => $exception->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'Revision update failed: ' . $exception->getMessage(), + ], 500); + } + } + /** * GET /api/studio/artworks/{id}/versions * Return version history for an artwork (newest first). @@ -615,6 +871,7 @@ final class StudioArtworksApiController extends Controller 'width' => $v->width, 'height' => $v->height, 'file_size' => $v->file_size, + 'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''), 'change_note' => $v->change_note, 'is_current' => $v->is_current, 'created_at' => $v->created_at?->toIso8601String(), @@ -637,14 +894,6 @@ final class StudioArtworksApiController extends Controller try { $newVersion = $this->versioningService->restoreVersion($version, $artwork, $request->user()->id); - - // Sync artwork file fields back to restored version dimensions - $artwork->update([ - 'width' => max(1, (int) $version->width), - 'height' => max(1, (int) $version->height), - 'file_size' => (int) $version->file_size, - ]); - $artwork->refresh(); // Reindex @@ -656,7 +905,7 @@ final class StudioArtworksApiController extends Controller 'success' => true, 'version_number' => $newVersion->version_number, 'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.", - ]); + ] + $this->mediaPayload($artwork)); } catch (TooManyRequestsHttpException $e) { return response()->json(['success' => false, 'error' => $e->getMessage()], 429); } catch (\Throwable $e) { @@ -681,4 +930,138 @@ final class StudioArtworksApiController extends Controller Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]); } } + + /** + * @param array $snapshot + * @return array> + */ + private function snapshotFilesByVariant(array $snapshot): array + { + return collect($snapshot['files'] ?? []) + ->filter(fn ($file): bool => is_array($file) && (string) ($file['variant'] ?? '') !== '') + ->mapWithKeys(fn (array $file): array => [(string) $file['variant'] => $file]) + ->all(); + } + + /** + * @param array $snapshot + * @return array + */ + private function currentPublicAssetsFromSnapshot(array $snapshot): array + { + return collect($snapshot['files'] ?? []) + ->filter(fn ($file): bool => is_array($file) && in_array((string) ($file['variant'] ?? ''), ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], true)) + ->mapWithKeys(fn (array $file): array => [ + (string) $file['variant'] => [ + 'path' => (string) ($file['path'] ?? ''), + 'mime' => (string) ($file['mime'] ?? 'image/webp'), + 'size' => (int) ($file['size'] ?? 0), + ], + ]) + ->all(); + } + + /** + * @param array|null $file + * @return array|null + */ + private function existingVariantDescriptor(?array $file, string $fallbackVariant): ?array + { + if (! is_array($file) || (string) ($file['path'] ?? '') === '') { + return null; + } + + $path = (string) ($file['path'] ?? ''); + + return [ + 'variant' => $fallbackVariant !== '' ? $fallbackVariant : (string) ($file['variant'] ?? ''), + 'path' => $path, + 'mime' => (string) ($file['mime'] ?? 'application/octet-stream'), + 'size' => (int) ($file['size'] ?? 0), + 'ext' => strtolower((string) pathinfo($path, PATHINFO_EXTENSION)), + ]; + } + + /** + * @param array $stored + * @return array + */ + private function storedOriginalDescriptor(array $stored, string $variant): array + { + return [ + 'variant' => $variant, + 'path' => (string) ($stored['object_path'] ?? ''), + 'mime' => (string) ($stored['mime'] ?? 'application/octet-stream'), + 'size' => (int) ($stored['size'] ?? 0), + 'ext' => strtolower((string) ($stored['ext'] ?? pathinfo((string) ($stored['object_path'] ?? ''), PATHINFO_EXTENSION))), + ]; + } + + private function resolvePreferredFileName(?string $preferredFileName, string $ext): string + { + $candidate = basename(str_replace('\\', '/', (string) ($preferredFileName ?? ''))); + $candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? ''; + $candidate = trim((string) $candidate); + + if ($candidate === '') { + $candidate = 'artwork'; + } + + $candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION)); + if ($candidateExt === '' && $ext !== '') { + $candidate .= '.' . ltrim($ext, '.'); + } + + return $candidate; + } + + /** + * @return array + */ + private function mediaPayload(Artwork $artwork): array + { + $artwork->refresh(); + $snapshot = $this->versioningService->captureArtworkSnapshot($artwork); + $filesByVariant = $this->snapshotFilesByVariant($snapshot); + + return [ + 'thumb_url' => $artwork->thumbUrl('md'), + 'thumb_url_lg' => $artwork->thumbUrl('lg'), + 'width' => (int) ($artwork->width ?? 0), + 'height' => (int) ($artwork->height ?? 0), + 'file_size' => (int) ($artwork->file_size ?? 0), + 'file_name' => (string) ($artwork->file_name ?? ''), + 'file_ext' => (string) ($artwork->file_ext ?? ''), + 'mime_type' => (string) ($artwork->mime_type ?? ''), + 'has_archive_file' => array_key_exists('orig_archive', $filesByVariant), + 'screenshots' => $this->screenshotAssetsFromSnapshot($snapshot), + ]; + } + + /** + * @param array $snapshot + * @return array> + */ + private function screenshotAssetsFromSnapshot(array $snapshot): array + { + $base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'); + + return collect($snapshot['files'] ?? []) + ->filter(fn ($file): bool => is_array($file) && str_starts_with((string) ($file['variant'] ?? ''), 'shot') && (string) ($file['path'] ?? '') !== '') + ->values() + ->map(function (array $file, int $index) use ($base): array { + $path = trim((string) ($file['path'] ?? ''), '/'); + $url = $base . '/' . $path; + + return [ + 'id' => (string) ($file['variant'] ?? ('shot' . ($index + 1))), + 'label' => 'Screenshot ' . ($index + 1), + 'url' => $url, + 'thumb_url' => $url, + 'mime_type' => (string) ($file['mime'] ?? 'image/jpeg'), + 'size' => (int) ($file['size'] ?? 0), + ]; + }) + ->all(); + } } diff --git a/resources/js/Pages/Studio/StudioArtworkEdit.jsx b/resources/js/Pages/Studio/StudioArtworkEdit.jsx index faab2e9e..7ab189b0 100644 --- a/resources/js/Pages/Studio/StudioArtworkEdit.jsx +++ b/resources/js/Pages/Studio/StudioArtworkEdit.jsx @@ -23,6 +23,7 @@ const EDIT_SECTIONS = [ const TABS = [ { id: 'details', label: 'Details', icon: 'fa-solid fa-pen-fancy' }, + { id: 'media', label: 'Media', icon: 'fa-solid fa-photo-film' }, { id: 'evolution', label: 'Evolution', icon: 'fa-solid fa-code-branch' }, { id: 'tags', label: 'Tags', icon: 'fa-solid fa-tags' }, { id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' }, @@ -43,6 +44,28 @@ function formatBytes(bytes) { return (bytes / 1048576).toFixed(1) + ' MB' } +function resolveFileExtension(fileName, fallbackExt = '') { + const normalizedFallback = String(fallbackExt || '').trim().replace(/^\./, '').toLowerCase() + const normalizedName = String(fileName || '').trim() + const fromName = normalizedName.includes('.') + ? normalizedName.split('.').pop()?.trim().toLowerCase() + : '' + + return fromName || normalizedFallback +} + +function isArchiveArtwork(fileName, mimeType, fileExt) { + const extension = resolveFileExtension(fileName, fileExt) + if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) return true + + const normalizedMime = String(mimeType || '').toLowerCase() + return normalizedMime.includes('zip') + || normalizedMime.includes('rar') + || normalizedMime.includes('7z') + || normalizedMime.includes('tar') + || normalizedMime.includes('gzip') +} + function formatSchedulePreview(value, timezone) { if (!value) return 'Pick a date and time' @@ -303,6 +326,12 @@ export default function StudioArtworkEdit() { const fileInputRef = useRef(null) const [replacing, setReplacing] = useState(false) const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null) + const downloadUrl = artwork?.download_url || (artwork?.id ? `/download/artwork/${artwork.id}` : null) + const [selectedMediaId, setSelectedMediaId] = useState('cover') + const [fileExt, setFileExt] = useState(artwork?.file_ext || '') + const [mimeType, setMimeType] = useState(artwork?.mime_type || '') + const [hasArchiveFile, setHasArchiveFile] = useState(Boolean(artwork?.has_archive_file)) + const [artworkScreenshots, setArtworkScreenshots] = useState(() => (Array.isArray(artwork?.screenshots) ? artwork.screenshots : [])) const [fileMeta, setFileMeta] = useState({ name: artwork?.file_name || '—', size: artwork?.file_size || 0, @@ -319,6 +348,48 @@ export default function StudioArtworkEdit() { const [historyData, setHistoryData] = useState(null) const [historyLoading, setHistoryLoading] = useState(false) const [restoring, setRestoring] = useState(null) + const [archiveRevisionSaving, setArchiveRevisionSaving] = useState(false) + const [archiveRevisionError, setArchiveRevisionError] = useState('') + const [archiveCoverFile, setArchiveCoverFile] = useState(null) + const [archiveCoverPreview, setArchiveCoverPreview] = useState(null) + const [archivePackageFile, setArchivePackageFile] = useState(null) + const [archiveExtraScreenshots, setArchiveExtraScreenshots] = useState([]) + const [archiveExtraPreviews, setArchiveExtraPreviews] = useState([]) + // Per-slot screenshot replacement: { slotIndex: File } + const [replaceShots, setReplaceShots] = useState({}) + const [replaceShotPreviews, setReplaceShotPreviews] = useState({}) + const [removedShots, setRemovedShots] = useState({}) + // Staged single-image replace (no auto-upload) + const [pendingReplaceFile, setPendingReplaceFile] = useState(null) + const [pendingReplacePreview, setPendingReplacePreview] = useState(null) + // Drag-over tracking for drop zones + const [dragOverZone, setDragOverZone] = useState(null) + const screenshotItems = artworkScreenshots + const activeScreenshotCount = screenshotItems.filter((_, index) => !removedShots[index]).length + const currentFileExt = resolveFileExtension(fileMeta.name, fileExt) + const archiveArtwork = hasArchiveFile || isArchiveArtwork(fileMeta.name, mimeType, fileExt) + const quickReplaceSupported = !archiveArtwork + const mediaItems = useMemo(() => { + const coverItem = { + id: 'cover', + label: archiveArtwork ? 'Cover preview' : 'Main artwork', + url: thumbUrl, + width: fileMeta.width || 0, + height: fileMeta.height || 0, + } + + const screenshotMedia = screenshotItems.map((item, index) => ({ + id: item.id || `shot-${index + 1}`, + label: item.label || `Screenshot ${index + 1}`, + url: item.thumb_url || item.url || null, + width: 0, + height: 0, + })) + + return [coverItem, ...screenshotMedia].filter((item) => Boolean(item.url)) + }, [archiveArtwork, fileMeta.height, fileMeta.width, screenshotItems, thumbUrl]) + const activeMedia = mediaItems.find((item) => item.id === selectedMediaId) || mediaItems[0] || null + const activeMediaLabel = activeMedia?.label || (archiveArtwork ? 'Cover preview' : 'Main artwork') // ── Derived ──────────────────────────────────────────────────────────────── const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null @@ -760,8 +831,7 @@ export default function StudioArtworkEdit() { } }, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes]) - const handleFileReplace = async (e) => { - const file = e.target.files?.[0] + const handleFileReplace = async (file) => { if (!file) return setReplacing(true) try { @@ -776,12 +846,13 @@ export default function StudioArtworkEdit() { }) const data = await res.json() if (res.ok && data.thumb_url) { - setThumbUrl(data.thumb_url) - setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 }) + syncMediaPayload(data, { fallbackName: file.name, fallbackSize: file.size }) if (data.version_number) setVersionCount(data.version_number) if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval) setChangeNote('') setShowChangeNote(false) + if (pendingReplacePreview) { URL.revokeObjectURL(pendingReplacePreview); setPendingReplacePreview(null) } + setPendingReplaceFile(null) } else { alert(data.error || 'File replacement failed.') } @@ -820,7 +891,8 @@ export default function StudioArtworkEdit() { }) const data = await res.json() if (res.ok && data.success) { - setVersionCount((n) => n + 1) + syncMediaPayload(data) + if (data.version_number) setVersionCount(data.version_number) setShowHistory(false) } else { alert(data.error || 'Restore failed.') @@ -832,6 +904,94 @@ export default function StudioArtworkEdit() { } } + const syncMediaPayload = useCallback((payload, options = {}) => { + const fallbackName = typeof options.fallbackName === 'string' ? options.fallbackName : null + const fallbackSize = Number.isFinite(options.fallbackSize) ? Number(options.fallbackSize) : null + + if (payload?.thumb_url) { + setThumbUrl(payload.thumb_url_lg || payload.thumb_url) + } + + setSelectedMediaId('cover') + setFileMeta({ + name: payload?.file_name || fallbackName || '—', + size: typeof payload?.file_size === 'number' ? payload.file_size : (fallbackSize ?? 0), + width: payload?.width || 0, + height: payload?.height || 0, + }) + + if (typeof payload?.file_ext === 'string') setFileExt(payload.file_ext) + if (typeof payload?.mime_type === 'string') setMimeType(payload.mime_type) + if (typeof payload?.has_archive_file !== 'undefined') setHasArchiveFile(Boolean(payload.has_archive_file)) + if (Array.isArray(payload?.screenshots)) setArtworkScreenshots(payload.screenshots) + }, []) + + const resetArchiveRevisionState = useCallback(() => { + setArchiveRevisionError('') + if (archiveCoverPreview) URL.revokeObjectURL(archiveCoverPreview) + setArchiveCoverFile(null) + setArchiveCoverPreview(null) + setArchivePackageFile(null) + setArchiveExtraScreenshots([]) + archiveExtraPreviews.forEach((url) => URL.revokeObjectURL(url)) + setArchiveExtraPreviews([]) + Object.values(replaceShotPreviews).forEach((url) => URL.revokeObjectURL(url)) + setReplaceShots({}) + setReplaceShotPreviews({}) + setRemovedShots({}) + }, [archiveCoverPreview, archiveExtraPreviews, replaceShotPreviews]) + + const handleArchiveRevisionSubmit = async () => { + const hasReplaceShots = Object.values(replaceShots).some(Boolean) + const hasRemovedShots = Object.values(removedShots).some(Boolean) + if (!archiveCoverFile && !archivePackageFile && archiveExtraScreenshots.length === 0 && !hasReplaceShots && !hasRemovedShots) { + setArchiveRevisionError('Choose a new cover screenshot, a new archive file, or extra screenshots first.') + return + } + + setArchiveRevisionSaving(true) + setArchiveRevisionError('') + + try { + const fd = new FormData() + if (archiveCoverFile) fd.append('cover_file', archiveCoverFile) + if (archivePackageFile) fd.append('archive_file', archivePackageFile) + archiveExtraScreenshots.forEach((file) => fd.append('screenshot_files[]', file)) + Object.entries(replaceShots).forEach(([idx, file]) => { + if (file) fd.append(`replace_shots[${idx}]`, file) + }) + Object.entries(removedShots).forEach(([idx, removed]) => { + if (removed) fd.append('remove_shots[]', idx) + }) + if (changeNote.trim()) fd.append('change_note', changeNote.trim()) + + const res = await fetch(`/api/studio/artworks/${artwork.id}/revise-media`, { + method: 'POST', + headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: fd, + }) + + const data = await res.json() + if (!res.ok || !data.success) { + setArchiveRevisionError(data.error || 'Archive revision failed.') + return + } + + syncMediaPayload(data) + if (data.version_number) setVersionCount(data.version_number) + if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval) + setShowChangeNote(false) + setChangeNote('') + resetArchiveRevisionState() + } catch (err) { + console.error('Archive revision failed:', err) + setArchiveRevisionError('Archive revision failed.') + } finally { + setArchiveRevisionSaving(false) + } + } + // ── Render ───────────────────────────────────────────────────────────────── return ( @@ -876,112 +1036,106 @@ export default function StudioArtworkEdit() {
{/* Preview Card */} -
- Preview +
+ Media - {/* Thumbnail */} -
- {thumbUrl ? ( - {title - ) : ( -
- -
- )} - {replacing && ( -
-
-
- )} -
+
+
+
+
+ + {archiveArtwork ? 'Archive package' : 'Single image'} + + + v{versionCount} + +
- {/* File Metadata */} -
-

{fileMeta.name}

-
- {fileMeta.width > 0 && ( - - - {fileMeta.width} × {fileMeta.height} - - )} - - - {formatBytes(fileMeta.size)} - +
+ {activeMedia?.url ? ( + {title + ) : ( +
+ +
+ )} + +
+

{activeMediaLabel}

+

{fileMeta.name}

+
+ {currentFileExt && ( + {currentFileExt} + )} + {screenshotItems.length > 0 && ( + {screenshotItems.length} screenshot{screenshotItems.length !== 1 ? 's' : ''} + )} + {fileMeta.width > 0 && ( + {fileMeta.width} × {fileMeta.height} + )} +
+
+ + {replacing && ( +
+
+
+ Uploading new revision… +
+
+ )} +
+
- {/* Version + History */} -
- - v{versionCount} - - -
+
+
+ Size + {formatBytes(fileMeta.size)} +
- {requiresReapproval && ( -

- - Requires re-approval after replace -

- )} -
- - {/* Replace File */} -
- {showChangeNote && ( - setChangeNote(e.target.value)} - placeholder="Change note (optional)…" - size="sm" - /> - )} -
- - {!showChangeNote && ( - - )} + + + ) : null}
- + +