Improve studio artwork media revisions
This commit is contained in:
@@ -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<string, mixed> $snapshot
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $snapshot
|
||||
* @return array<string, array{path: string, mime: string, size: int}>
|
||||
*/
|
||||
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<string, mixed>|null $file
|
||||
* @return array<string, mixed>|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<string, mixed> $stored
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $snapshot
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<StudioLayout title="Edit Artwork">
|
||||
@@ -876,19 +1036,30 @@ export default function StudioArtworkEdit() {
|
||||
<div className="space-y-4 xl:sticky xl:top-6 xl:max-h-[calc(100vh-48px)] xl:overflow-y-auto xl:overscroll-contain xl:pr-1 nova-scrollbar">
|
||||
|
||||
{/* Preview Card */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-image">Preview</SectionTitle>
|
||||
<Section className="overflow-hidden">
|
||||
<SectionTitle icon="fa-solid fa-image">Media</SectionTitle>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/5 border border-white/10 mb-4">
|
||||
{thumbUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.14),_transparent_54%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-3 shadow-[0_20px_60px_rgba(2,8,23,0.28)]">
|
||||
<div className="relative overflow-hidden rounded-[22px] border border-white/10 bg-black/25">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-start justify-between gap-2 p-3">
|
||||
<span className="rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/75">
|
||||
{archiveArtwork ? 'Archive package' : 'Single image'}
|
||||
</span>
|
||||
<span className="rounded-full border border-accent/20 bg-accent/12 px-2.5 py-1 text-[10px] font-semibold text-accent">
|
||||
v{versionCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-[4/5] min-h-[280px]">
|
||||
{activeMedia?.url ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
src={activeMedia.url}
|
||||
alt={title || 'Artwork preview'}
|
||||
className="w-full h-full object-cover"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-600">
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||
@@ -896,93 +1067,76 @@ export default function StudioArtworkEdit() {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{replacing && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<div className="w-7 h-7 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Metadata */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white truncate" title={fileMeta.name}>{fileMeta.name}</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/50">{activeMediaLabel}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-white" title={fileMeta.name}>{fileMeta.name}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-white/65">
|
||||
{currentFileExt && (
|
||||
<span className="rounded-full border border-white/10 bg-white/10 px-2 py-0.5 uppercase tracking-[0.14em] text-white/70">{currentFileExt}</span>
|
||||
)}
|
||||
{screenshotItems.length > 0 && (
|
||||
<span>{screenshotItems.length} screenshot{screenshotItems.length !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{fileMeta.width > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="text-slate-600" aria-hidden="true">
|
||||
<path d="M2 3a1 1 0 011-1h10a1 1 0 011 1v10a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm2 1v8h8V4H4z" />
|
||||
</svg>
|
||||
{fileMeta.width} × {fileMeta.height}
|
||||
</span>
|
||||
<span>{fileMeta.width} × {fileMeta.height}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="text-slate-600" aria-hidden="true">
|
||||
<path d="M4 1.5a.5.5 0 00-1 0V3H1.5a.5.5 0 000 1h11a.5.5 0 000-1H11V1.5a.5.5 0 00-1 0V3H6V1.5a.5.5 0 00-1 0V3H4V1.5z" />
|
||||
<path d="M1.5 5v8.5A1.5 1.5 0 003 15h10a1.5 1.5 0 001.5-1.5V5h-13z" />
|
||||
</svg>
|
||||
{formatBytes(fileMeta.size)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{replacing && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/60">
|
||||
<div className="flex items-center gap-3 rounded-full border border-white/10 bg-black/55 px-4 py-2 text-xs text-white/80 backdrop-blur">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-accent/30 border-t-accent animate-spin" />
|
||||
Uploading new revision…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] text-slate-300">
|
||||
<span className="font-semibold uppercase tracking-[0.16em] text-slate-500">Size</span>
|
||||
<span className="font-semibold text-white">{formatBytes(fileMeta.size)}</span>
|
||||
</div>
|
||||
|
||||
{downloadUrl ? (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
aria-label="Download artwork"
|
||||
title="Download artwork"
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-emerald-400/25 bg-emerald-400/10 text-emerald-200 transition hover:bg-emerald-400/15 hover:text-white"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8.75 1.5a.75.75 0 00-1.5 0v6.19L5.53 5.97a.75.75 0 10-1.06 1.06l3 3a.75.75 0 001.06 0l3-3a.75.75 0 10-1.06-1.06L8.75 7.69V1.5z" />
|
||||
<path d="M2.5 10.75A.75.75 0 013.25 10h9.5a.75.75 0 010 1.5h-9.5a.75.75 0 01-.75-.75z" />
|
||||
<path d="M2 12.5A1.5 1.5 0 013.5 11h9a1.5 1.5 0 011.5 1.5v1A1.5 1.5 0 0112.5 15h-9A1.5 1.5 0 012 13.5v-1z" />
|
||||
</svg>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Version + History */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-accent bg-accent/15 px-2 py-0.5 rounded-full border border-accent/20">
|
||||
v{versionCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadVersionHistory}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-accent transition-colors"
|
||||
onClick={() => setActiveTab('media')}
|
||||
className="group flex w-full items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-left transition hover:border-white/20 hover:bg-white/[0.05]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8 3.5a4.5 4.5 0 00-4.04 2.51.75.75 0 01-1.34-.67A6 6 0 1114 8a.75.75 0 01-1.5 0A4.5 4.5 0 008 3.5z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M4.75.75a.75.75 0 00-.75.75v3.5c0 .414.336.75.75.75h3.5a.75.75 0 000-1.5H5.5V1.5a.75.75 0 00-.75-.75z" clipRule="evenodd" />
|
||||
</svg>
|
||||
History
|
||||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.04] text-slate-300 transition group-hover:border-accent/30 group-hover:bg-accent/10 group-hover:text-accent">
|
||||
<i className="fa-solid fa-photo-film text-[13px]" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Media</span>
|
||||
<span className="mt-1 block text-sm font-medium text-white">
|
||||
{archiveArtwork ? 'Manage package and screenshots' : 'Replace image and manage screenshots'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pt-0.5 text-slate-600 transition group-hover:text-slate-300" aria-hidden="true">
|
||||
<i className="fa-solid fa-chevron-right text-[11px]" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{requiresReapproval && (
|
||||
<p className="text-[11px] text-amber-400/90 flex items-center gap-1.5 mt-1">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8.982 1.566a1.13 1.13 0 00-1.964 0L.165 13.233c-.457.778.091 1.767.982 1.767h13.706c.891 0 1.439-.989.982-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 01-1.1 0L7.1 5.995A.905.905 0 018 5zm.002 6a1 1 0 100 2 1 1 0 000-2z" />
|
||||
</svg>
|
||||
Requires re-approval after replace
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Replace File */}
|
||||
<div className="mt-4 pt-4 border-t border-white/8 space-y-2.5">
|
||||
{showChangeNote && (
|
||||
<TextInput
|
||||
value={changeNote}
|
||||
onChange={(e) => setChangeNote(e.target.value)}
|
||||
placeholder="Change note (optional)…"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
loading={replacing}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{replacing ? 'Replacing…' : 'Replace file'}
|
||||
</Button>
|
||||
{!showChangeNote && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChangeNote(true)}
|
||||
className="text-[11px] text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
+ note
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" className="hidden" accept="image/*" onChange={handleFileReplace} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section className="space-y-3">
|
||||
@@ -1502,6 +1656,482 @@ export default function StudioArtworkEdit() {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Media tab ── */}
|
||||
{activeTab === 'media' && (
|
||||
<Section id="media" className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<SectionTitle icon="fa-solid fa-photo-film">Media & Revisions</SectionTitle>
|
||||
<p className="-mt-2 text-sm text-slate-400">
|
||||
{quickReplaceSupported
|
||||
? 'Drop or upload a new image, then add or manage up to 4 extra screenshots in the same media workspace.'
|
||||
: 'Replace the archive package, update the cover screenshot, or add / replace screenshots — saved together as one revision.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadVersionHistory}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-slate-300 transition hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8 3.5a4.5 4.5 0 00-4.04 2.51.75.75 0 01-1.34-.67A6 6 0 1114 8a.75.75 0 01-1.5 0A4.5 4.5 0 008 3.5z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M4.75.75a.75.75 0 00-.75.75v3.5c0 .414.336.75.75.75h3.5a.75.75 0 000-1.5H5.5V1.5a.75.75 0 00-.75-.75z" clipRule="evenodd" />
|
||||
</svg>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current file summary bar */}
|
||||
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
{thumbUrl && <img src={thumbUrl} alt="" className="h-full w-full object-cover" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white" title={fileMeta.name}>{fileMeta.name || 'Artwork file'}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{currentFileExt && <span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-white/55">{currentFileExt}</span>}
|
||||
{fileMeta.width > 0 && <span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/55">{fileMeta.width} × {fileMeta.height}</span>}
|
||||
{fileMeta.size > 0 && <span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/55">{formatBytes(fileMeta.size)}</span>}
|
||||
{screenshotItems.length > 0 && <span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/55">{screenshotItems.length} screenshot{screenshotItems.length !== 1 ? 's' : ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-400">v{versionCount}</span>
|
||||
</div>
|
||||
|
||||
{requiresReapproval && (
|
||||
<div className="rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
||||
Visual changes on this artwork require a new moderation pass.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════ Single image replace ════ */}
|
||||
{quickReplaceSupported && (() => {
|
||||
const zone = 'single-replace'
|
||||
const isDragging = dragOverZone === zone
|
||||
const stageFile = (file) => {
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
if (pendingReplacePreview) URL.revokeObjectURL(pendingReplacePreview)
|
||||
setPendingReplaceFile(file)
|
||||
setPendingReplacePreview(URL.createObjectURL(file))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Replace image</p>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={[
|
||||
'relative overflow-hidden rounded-2xl border-2 border-dashed transition',
|
||||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/15 bg-white/[0.02]',
|
||||
].join(' ')}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||||
onDragLeave={() => setDragOverZone(null)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOverZone(null)
|
||||
stageFile(e.dataTransfer.files?.[0])
|
||||
}}
|
||||
>
|
||||
{pendingReplacePreview ? (
|
||||
<div className="flex items-start gap-4 p-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
<img src={pendingReplacePreview} alt="Preview" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 py-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-400">Ready to upload</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-white">{pendingReplaceFile?.name}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{formatBytes(pendingReplaceFile?.size)}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 text-xs text-slate-400 hover:text-white transition-colors"
|
||||
onClick={() => { URL.revokeObjectURL(pendingReplacePreview); setPendingReplacePreview(null); setPendingReplaceFile(null) }}
|
||||
>
|
||||
✕ Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex cursor-pointer flex-col items-center justify-center gap-3 p-10 text-center">
|
||||
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-white/[0.05] text-slate-400">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-300">Drop image here or <span className="text-sky-300">browse</span></span>
|
||||
<span className="text-xs text-slate-500">JPG · PNG · WEBP · TIFF — any resolution</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={(e) => stageFile(e.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{replacing && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-2xl bg-black/65 backdrop-blur-sm">
|
||||
<div className="h-8 w-8 rounded-full border-2 border-accent/30 border-t-accent animate-spin" />
|
||||
<span className="text-sm text-slate-300">Uploading revision…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change note + Upload button */}
|
||||
<TextInput
|
||||
value={changeNote}
|
||||
onChange={(e) => setChangeNote(e.target.value)}
|
||||
placeholder="Change note for this revision… (optional)"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{pendingReplaceFile && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
loading={replacing}
|
||||
onClick={() => handleFileReplace(pendingReplaceFile)}
|
||||
>
|
||||
{replacing ? 'Uploading…' : 'Upload new version'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ════ Screenshot and archive media form ════ */}
|
||||
<div className="space-y-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">{archiveArtwork ? 'Revise archive media' : 'Additional screenshots'}</p>
|
||||
|
||||
{archiveRevisionError && (
|
||||
<div className="rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{archiveRevisionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{archiveArtwork && (
|
||||
<>
|
||||
{/* ── Cover screenshot ── */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-slate-400">Cover screenshot</p>
|
||||
{(() => {
|
||||
const zone = 'archive-cover'
|
||||
const isDragging = dragOverZone === zone
|
||||
const stageFile = (file) => {
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
if (archiveCoverPreview) URL.revokeObjectURL(archiveCoverPreview)
|
||||
setArchiveCoverFile(file)
|
||||
setArchiveCoverPreview(URL.createObjectURL(file))
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'relative overflow-hidden rounded-2xl border-2 border-dashed transition',
|
||||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/15 bg-white/[0.02]',
|
||||
].join(' ')}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||||
onDragLeave={() => setDragOverZone(null)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragOverZone(null); stageFile(e.dataTransfer.files?.[0]) }}
|
||||
>
|
||||
<div className="flex items-start gap-4 p-4">
|
||||
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
{archiveCoverPreview
|
||||
? <img src={archiveCoverPreview} alt="New cover" className="h-full w-full object-cover" />
|
||||
: thumbUrl
|
||||
? <img src={thumbUrl} alt="Current cover" className="h-full w-full object-cover opacity-60" />
|
||||
: null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 py-1">
|
||||
{archiveCoverFile ? (
|
||||
<>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-400">New cover staged</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-white">{archiveCoverFile.name}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{formatBytes(archiveCoverFile.size)}</p>
|
||||
<button type="button" className="mt-2 text-xs text-slate-400 hover:text-white transition-colors"
|
||||
onClick={() => { URL.revokeObjectURL(archiveCoverPreview); setArchiveCoverFile(null); setArchiveCoverPreview(null) }}>
|
||||
✕ Remove
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs font-semibold text-slate-500">Current cover</p>
|
||||
<p className="mt-1 text-xs text-slate-400">Drop a new image or click to browse.</p>
|
||||
<label className="mt-2 inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-300 transition hover:bg-white/[0.08] hover:text-white">
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2.75 14A1.75 1.75 0 011 12.25V9.5a.75.75 0 011.5 0v2.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V9.5a.75.75 0 011.5 0v2.75A1.75 1.75 0 0113.25 14H2.75z"/><path d="M11.78 5.22a.75.75 0 00-1.06 0L8.75 7.19V1.75a.75.75 0 00-1.5 0v5.44L5.28 5.22a.75.75 0 00-1.06 1.06l3.25 3.25a.75.75 0 001.06 0l3.25-3.25a.75.75 0 000-1.06z"/></svg>
|
||||
Replace cover
|
||||
<input type="file" className="hidden" accept="image/*" onChange={(e) => stageFile(e.target.files?.[0])} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* ── Archive package ── */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-slate-400">Archive package</p>
|
||||
{(() => {
|
||||
const zone = 'archive-pkg'
|
||||
const isDragging = dragOverZone === zone
|
||||
const stageFile = (file) => {
|
||||
if (!file) return
|
||||
setArchivePackageFile(file)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border-2 border-dashed transition',
|
||||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/15 bg-white/[0.02]',
|
||||
].join(' ')}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||||
onDragLeave={() => setDragOverZone(null)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragOverZone(null); stageFile(e.dataTransfer.files?.[0]) }}
|
||||
>
|
||||
<div className="flex items-center gap-4 px-4 py-4">
|
||||
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.04] text-slate-400">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{archivePackageFile ? (
|
||||
<>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-400">New package staged</p>
|
||||
<p className="mt-0.5 truncate text-sm font-medium text-white">{archivePackageFile.name}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{formatBytes(archivePackageFile.size)}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs font-semibold text-slate-500">Current package</p>
|
||||
<p className="mt-0.5 truncate text-sm text-slate-300">{fileMeta.name || '—'}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
{archivePackageFile ? (
|
||||
<button type="button" className="text-xs text-slate-400 hover:text-white transition-colors"
|
||||
onClick={() => setArchivePackageFile(null)}>
|
||||
✕ Remove
|
||||
</button>
|
||||
) : (
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-300 transition hover:bg-white/[0.08] hover:text-white">
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2.75 14A1.75 1.75 0 011 12.25V9.5a.75.75 0 011.5 0v2.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V9.5a.75.75 0 011.5 0v2.75A1.75 1.75 0 0113.25 14H2.75z"/><path d="M11.78 5.22a.75.75 0 00-1.06 0L8.75 7.19V1.75a.75.75 0 00-1.5 0v5.44L5.28 5.22a.75.75 0 00-1.06 1.06l3.25 3.25a.75.75 0 001.06 0l3.25-3.25a.75.75 0 000-1.06z"/></svg>
|
||||
Replace
|
||||
<input type="file" className="hidden"
|
||||
accept=".zip,.rar,.7z,.tar,.gz,application/zip,application/x-zip-compressed,application/x-rar-compressed,application/vnd.rar,application/x-7z-compressed,application/x-tar,application/gzip"
|
||||
onChange={(e) => stageFile(e.target.files?.[0])} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Existing screenshots ── */}
|
||||
{screenshotItems.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold text-slate-400">Screenshots <span className="ml-1 font-normal text-slate-500">({activeScreenshotCount} active / {screenshotItems.length} existing)</span></p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{screenshotItems.map((shot, i) => {
|
||||
const shotZone = `replace-shot-${i}`
|
||||
const isDragging = dragOverZone === shotZone
|
||||
const pendingPreview = replaceShotPreviews[i]
|
||||
const pendingFile = replaceShots[i]
|
||||
const isRemoved = Boolean(removedShots[i])
|
||||
const stageShot = (file) => {
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
if (pendingPreview) URL.revokeObjectURL(pendingPreview)
|
||||
setRemovedShots((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[i]
|
||||
return next
|
||||
})
|
||||
setReplaceShots((prev) => ({ ...prev, [i]: file }))
|
||||
setReplaceShotPreviews((prev) => ({ ...prev, [i]: URL.createObjectURL(file) }))
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={shot.id || `shot-${i}`}
|
||||
className={[
|
||||
'relative overflow-hidden rounded-2xl border-2 border-dashed transition',
|
||||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/10 bg-white/[0.02]',
|
||||
].join(' ')}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(shotZone) }}
|
||||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(shotZone) }}
|
||||
onDragLeave={() => setDragOverZone(null)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragOverZone(null); stageShot(e.dataTransfer.files?.[0]) }}
|
||||
>
|
||||
<div className="aspect-square overflow-hidden bg-black/25">
|
||||
<img
|
||||
src={pendingPreview || shot.thumb_url || shot.url}
|
||||
alt={shot.label || `Screenshot ${i + 1}`}
|
||||
className={[
|
||||
'h-full w-full object-cover transition-opacity',
|
||||
isRemoved ? 'opacity-25 grayscale' : 'opacity-100',
|
||||
].join(' ')}
|
||||
/>
|
||||
{pendingPreview && (
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-center gap-1 bg-emerald-500/80 py-1 text-[10px] font-semibold text-white">
|
||||
New
|
||||
</div>
|
||||
)}
|
||||
{isRemoved && (
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-center gap-1 bg-red-500/85 py-1 text-[10px] font-semibold text-white">
|
||||
Disabled for next save
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 p-2">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] text-slate-500">Shot {i + 1}</span>
|
||||
{pendingFile ? (
|
||||
<button type="button" className="text-[10px] text-slate-400 hover:text-white transition-colors"
|
||||
onClick={() => {
|
||||
URL.revokeObjectURL(pendingPreview)
|
||||
setReplaceShots((prev) => { const n = { ...prev }; delete n[i]; return n })
|
||||
setReplaceShotPreviews((prev) => { const n = { ...prev }; delete n[i]; return n })
|
||||
}}>
|
||||
Undo replace
|
||||
</button>
|
||||
) : isRemoved ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-emerald-300 hover:text-white transition-colors"
|
||||
onClick={() => setRemovedShots((prev) => { const next = { ...prev }; delete next[i]; return next })}
|
||||
>
|
||||
Re-enable
|
||||
</button>
|
||||
) : (
|
||||
<label className="cursor-pointer text-[10px] font-semibold text-sky-300 hover:text-white transition-colors">
|
||||
Replace
|
||||
<input type="file" className="hidden" accept="image/*"
|
||||
onChange={(e) => stageShot(e.target.files?.[0])} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-[10px] text-slate-600">{pendingFile ? pendingFile.name : (shot.label || `Screenshot ${i + 1}`)}</span>
|
||||
{!pendingFile && !isRemoved && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-red-300 hover:text-white transition-colors"
|
||||
onClick={() => setRemovedShots((prev) => ({ ...prev, [i]: true }))}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Add new screenshots ── */}
|
||||
{(activeScreenshotCount < 4) && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold text-slate-400">Add screenshots <span className="ml-1 font-normal text-slate-500">(up to {4 - activeScreenshotCount} more)</span></p>
|
||||
{(() => {
|
||||
const zone = 'archive-extra-shots'
|
||||
const isDragging = dragOverZone === zone
|
||||
const availableSlots = Math.max(0, 4 - activeScreenshotCount)
|
||||
const stageFiles = (files) => {
|
||||
const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/')).slice(0, availableSlots)
|
||||
archiveExtraPreviews.forEach((url) => URL.revokeObjectURL(url))
|
||||
setArchiveExtraScreenshots(imageFiles)
|
||||
setArchiveExtraPreviews(imageFiles.map((f) => URL.createObjectURL(f)))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border-2 border-dashed transition',
|
||||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/15 bg-white/[0.02]',
|
||||
].join(' ')}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||||
onDragLeave={() => setDragOverZone(null)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragOverZone(null); stageFiles(e.dataTransfer.files) }}
|
||||
>
|
||||
<label className="flex cursor-pointer flex-col items-center justify-center gap-2 py-6 text-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-slate-500" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-slate-300">Drop images here or <span className="text-sky-300">browse</span></span>
|
||||
<span className="text-xs text-slate-500">Select up to {availableSlots} image{availableSlots !== 1 ? 's' : ''}</span>
|
||||
<input type="file" className="hidden" accept="image/*" multiple
|
||||
onChange={(e) => stageFiles(e.target.files)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{archiveExtraPreviews.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{archiveExtraPreviews.map((url, i) => (
|
||||
<div key={url} className="relative overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
<div className="aspect-square">
|
||||
<img src={url} alt={`New ${i + 1}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/70 text-[10px] text-white hover:bg-red-500/80 transition-colors"
|
||||
onClick={() => {
|
||||
URL.revokeObjectURL(url)
|
||||
const next = archiveExtraScreenshots.filter((_, j) => j !== i)
|
||||
const nextPrev = archiveExtraPreviews.filter((_, j) => j !== i)
|
||||
setArchiveExtraScreenshots(next)
|
||||
setArchiveExtraPreviews(nextPrev)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change note + save */}
|
||||
<TextInput
|
||||
value={changeNote}
|
||||
onChange={(e) => setChangeNote(e.target.value)}
|
||||
placeholder={archiveArtwork ? 'Describe what changed in this revision… (optional)' : 'Describe the screenshot update… (optional)'}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
loading={archiveRevisionSaving}
|
||||
onClick={handleArchiveRevisionSubmit}
|
||||
>
|
||||
{archiveRevisionSaving ? 'Saving revision…' : 'Save revision'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Evolution tab ── */}
|
||||
{activeTab === 'evolution' && (
|
||||
<Section id="evolution" className="space-y-6">
|
||||
@@ -2006,6 +2636,7 @@ export default function StudioArtworkEdit() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ composer_bin="${COMPOSER_BIN:-composer}"
|
||||
ssh_bin="${SSH_BIN:-ssh}"
|
||||
rsync_bin="${RSYNC_BIN:-rsync}"
|
||||
local_build_command="${LOCAL_BUILD_COMMAND:-}"
|
||||
allow_deploy_from_dot_deploy="${ALLOW_DEPLOY_FROM_DOT_DEPLOY:-0}"
|
||||
|
||||
run_local_build=1
|
||||
run_remote_migrations=1
|
||||
@@ -59,10 +60,34 @@ Options:
|
||||
Environment overrides:
|
||||
LOCAL_FOLDER, REMOTE_FOLDER, REMOTE_SERVER, PHP_BIN, COMPOSER_BIN, SSH_BIN, RSYNC_BIN,
|
||||
LOCAL_BUILD_COMMAND, DB_SYNC_CONFIRM_TARGET, DB_SYNC_CONFIRM_PHRASE,
|
||||
ALLOW_DEPLOY_FROM_DOT_DEPLOY,
|
||||
FULL_UPGRADE_PRE_HOOK, FULL_UPGRADE_POST_HOOK
|
||||
EOF
|
||||
}
|
||||
|
||||
guard_local_folder() {
|
||||
local normalized_local_folder
|
||||
|
||||
normalized_local_folder="$(cd -- "$local_folder" && pwd)"
|
||||
local_folder="$normalized_local_folder"
|
||||
|
||||
echo "Deploy source: $local_folder"
|
||||
echo "Deploy target: $remote_server:$remote_folder"
|
||||
|
||||
if [[ ! -d "$local_folder" ]]; then
|
||||
echo "Deploy source folder does not exist: $local_folder" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$allow_deploy_from_dot_deploy" != "1" && "$local_folder" == *"/.deploy/"* ]]; then
|
||||
echo "Refusing to deploy from a .deploy snapshot folder: $local_folder" >&2
|
||||
echo "This usually means LOCAL_FOLDER is pointing at a stale release snapshot instead of the repo root." >&2
|
||||
echo "Unset LOCAL_FOLDER or set it to the repository root before running sync.sh." >&2
|
||||
echo "If you intentionally want to deploy from that folder, set ALLOW_DEPLOY_FROM_DOT_DEPLOY=1." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
set_deploy_mode() {
|
||||
case "$1" in
|
||||
normal|full-upgrade)
|
||||
@@ -335,6 +360,8 @@ if [[ -n "$full_upgrade_pre_hook" || -n "$full_upgrade_post_hook" ]] && [[ "$dep
|
||||
exit 1
|
||||
fi
|
||||
|
||||
guard_local_folder
|
||||
|
||||
if [[ "$run_db_sync" -eq 1 && "$db_sync_source" != "local" ]]; then
|
||||
echo "Refusing DB sync without an explicit source. Use --with-db-from=local." >&2
|
||||
exit 1
|
||||
@@ -465,8 +492,10 @@ if [[ "$RUN_REMOTE_MIGRATIONS" -eq 1 ]]; then
|
||||
"$PHP_BIN" artisan migrate --force
|
||||
fi
|
||||
|
||||
"$PHP_BIN" artisan view:clear
|
||||
"$PHP_BIN" artisan optimize:clear
|
||||
"$PHP_BIN" artisan optimize
|
||||
"$PHP_BIN" artisan view:cache
|
||||
|
||||
if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then
|
||||
"$PHP_BIN" artisan up
|
||||
|
||||
Reference in New Issue
Block a user