Allow artworks to have one primary category and extra secondaries.

Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
This commit is contained in:
test
2026-09-20 14:50:12 +02:00
parent 4d60a876af
commit 232880e01f
43 changed files with 2896 additions and 565 deletions
@@ -6,27 +6,34 @@ namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkVersion;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkVersion;
use App\Services\ArtworkEvolutionService;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ArtworkSearchIndexer;
use App\Notifications\NewArtworkReviewNotification;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\ArtworkAttributionService;
use App\Services\ArtworkEvolutionService;
use App\Services\Artworks\ArtworkCategoryService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\TagService;
use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkVersioningService;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\Moderation\ArtworkUploadPolicy;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Services\Tags\TagDiscoveryService;
use App\Services\TagService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\ArtworkDescriptionContentValidator;
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\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
@@ -45,6 +52,7 @@ final class StudioArtworksApiController extends Controller
private readonly TagService $tagService,
private readonly ArtworkCdnPurgeService $cdnPurge,
private readonly ArtworkPublicationService $artworkPublication,
private readonly ArtworkCategoryService $artworkCategories,
) {}
/**
@@ -74,9 +82,9 @@ final class StudioArtworksApiController extends Controller
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
@@ -88,14 +96,14 @@ final class StudioArtworksApiController extends Controller
public function bulk(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
'artwork_ids' => 'required|array|min:1|max:200',
'artwork_ids.*' => 'integer',
'params' => 'sometimes|array',
'params' => 'sometimes|array',
'params.category_id' => 'sometimes|integer|exists:categories,id',
'params.tag_ids' => 'sometimes|array',
'params.tag_ids.*' => 'integer|exists:tags,id',
'confirm' => 'required_if:action,delete|string',
'params.tag_ids' => 'sometimes|array',
'params.tag_ids.*' => 'integer|exists:tags,id',
'confirm' => 'required_if:action,delete|string',
]);
if ($validator->fails()) {
@@ -127,23 +135,26 @@ final class StudioArtworksApiController extends Controller
* PUT /api/studio/artworks/{id}
* Update artwork details (title, description, visibility).
*/
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$evolution = app(ArtworkEvolutionService::class);
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'title' => 'sometimes|string|max:255',
'description' => 'sometimes|nullable|string|max:5000',
'is_public' => 'sometimes|boolean',
'visibility' => 'sometimes|string|in:public,unlisted,private',
'mode' => 'sometimes|string|in:now,schedule',
'publish_at' => 'sometimes|nullable|string|date',
'timezone' => 'sometimes|nullable|string|max:64',
'is_public' => 'sometimes|boolean',
'visibility' => 'sometimes|string|in:public,unlisted,private',
'mode' => 'sometimes|string|in:now,schedule',
'publish_at' => 'sometimes|nullable|string|date',
'timezone' => 'sometimes|nullable|string|max:64',
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'primary_category_id' => 'sometimes|nullable|integer|exists:categories,id',
'secondary_category_ids' => 'sometimes|array|max:'.(int) config('categories.max_secondary_categories', 5),
'secondary_category_ids.*' => 'integer|exists:categories,id',
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30),
'tags.*' => 'string|max:64',
'tags' => 'sometimes|array|max:'.(int) config('tags.max_user_tags', 30),
'tags.*' => 'string|max:64',
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
@@ -210,14 +221,18 @@ final class StudioArtworksApiController extends Controller
// Extract tags and category before updating core fields
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
$categoryId = $validated['primary_category_id'] ?? $validated['category_id'] ?? null;
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated)
? array_values(array_map('intval', $validated['secondary_category_ids'] ?? []))
: [];
$hasSecondaryCategoryUpdate = array_key_exists('secondary_category_ids', $validated);
$contentTypeId = $validated['content_type_id'] ?? null;
$evolutionPayload = [
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
'relation_type' => $validated['evolution_relation_type'] ?? null,
'note' => $validated['evolution_note'] ?? null,
];
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
unset($validated['tags'], $validated['category_id'], $validated['primary_category_id'], $validated['secondary_category_ids'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
$validated['visibility'] = $visibility;
@@ -231,9 +246,17 @@ final class StudioArtworksApiController extends Controller
$validated['artwork_status'] = 'scheduled';
} else {
$validated['is_public'] = $visibility !== Artwork::VISIBILITY_PRIVATE;
$validated['is_approved'] = true;
$policy = $uploadPolicy->assess($request->user(), $artwork);
$validated['is_approved'] = ! $policy['requires_review'];
$validated['approval_source'] = $policy['requires_review'] ? null : 'trusted_auto';
$validated['publish_at'] = null;
$validated['artwork_status'] = 'published';
$validated['artwork_status'] = $policy['requires_review'] ? 'review' : 'published';
if ($policy['requires_review']) {
$validated['is_public'] = false;
$validated['published_at'] = null;
$validated['moderation_note'] = implode(', ', $policy['reasons']);
}
if (($validated['is_public'] ?? false) && ! $artwork->published_at) {
$validated['published_at'] = now();
@@ -249,10 +272,19 @@ final class StudioArtworksApiController extends Controller
}
$artwork->update($validated);
$requiresReview = ($policy['requires_review'] ?? false) === true;
// Sync category
if ($categoryId !== null) {
$artwork->categories()->sync([(int) $categoryId]);
if ($categoryId !== null || $hasSecondaryCategoryUpdate) {
try {
$this->artworkCategories->sync(
$artwork,
$categoryId !== null ? (int) $categoryId : (int) $artwork->primary_category_id,
$hasSecondaryCategoryUpdate ? $secondaryCategoryIds : ($categoryId !== null ? [] : null),
(string) ($validated['category_source'] ?? $artwork->category_source ?? 'manual'),
);
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
}
// Sync tags through the shared tag service so pivot source/usage rules stay valid.
@@ -291,24 +323,40 @@ final class StudioArtworksApiController extends Controller
// Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
$this->searchIndexer->update($artwork);
if ($requiresReview) {
$notifyEmail = trim((string) config('uploads.moderation.notify_email', ''));
if ($notifyEmail !== '') {
try {
Notification::route('mail', $notifyEmail)->notify(
new NewArtworkReviewNotification($artwork, $request->user(), $policy['reasons'])
);
} catch (\Throwable) {
// Keep the artwork private if notification delivery fails.
}
}
}
// Reload relationships for response
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->categories->first();
$artwork->load(['categories.contentType', 'primaryCategory.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->resolvedPrimaryCategory();
return response()->json([
'success' => true,
'status' => $requiresReview ? 'submitted_for_review' : 'published',
'message' => $requiresReview ? 'Artwork saved and queued for moderation.' : 'Artwork updated successfully.',
'review_reasons' => $requiresReview ? $policy['reasons'] : [],
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'id' => $artwork->id,
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'slug' => $artwork->slug,
'group_slug' => $artwork->group?->slug,
'slug' => $artwork->slug,
'group_slug' => $artwork->group?->slug,
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($contributorId): int => (int) $contributorId)->values()->all(),
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
@@ -317,11 +365,18 @@ final class StudioArtworksApiController extends Controller
'is_primary' => (bool) $contributor->is_primary,
])->values()->all(),
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'title_source' => $artwork->title_source ?: 'manual',
'category_id' => $primaryCategory?->id,
'primary_category_id' => $primaryCategory?->id,
'secondary_category_ids' => $artwork->categories
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
->pluck('id')
->map(fn ($id): int => (int) $id)
->values()
->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
],
@@ -423,18 +478,18 @@ final class StudioArtworksApiController extends Controller
return response()->json([
'artwork' => [
'id' => $artwork->id,
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'slug' => $artwork->slug,
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
@@ -449,28 +504,28 @@ final class StudioArtworksApiController extends Controller
$stats = $artwork->stats ?? null;
return [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->categories->first()?->name,
'category_slug' => $artwork->categories->first()?->slug,
'tags' => $artwork->tags->pluck('slug')->values()->all(),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->resolvedPrimaryCategory()?->name,
'category_slug' => $artwork->resolvedPrimaryCategory()?->slug,
'tags' => $artwork->tags->pluck('slug')->values()->all(),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
];
}
@@ -499,7 +554,7 @@ final class StudioArtworksApiController extends Controller
$previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork);
$request->validate([
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
'change_note' => 'sometimes|nullable|string|max:500',
]);
@@ -510,43 +565,43 @@ final class StudioArtworksApiController extends Controller
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
}
$file = $request->file('file');
$file = $request->file('file');
$tempPath = $file->getRealPath();
$hash = hash_file('sha256', $tempPath);
$hash = hash_file('sha256', $tempPath);
// Reject identical files early (before any disk writes)
if ($artwork->hash === $hash) {
return response()->json([
'success' => false,
'error' => 'The uploaded file is identical to the current version.',
'error' => 'The uploaded file is identical to the current version.',
], 422);
}
try {
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
$derivatives = app(UploadDerivativesService::class);
$storage = app(UploadStorageService::class);
$artworkFiles = app(ArtworkFileRepository::class);
// 1. Store original on disk (preserve extension when possible)
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
$originalPath = $originalAsset['local_path'];
$origFilename = basename($originalPath);
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
$originalPath = $originalAsset['local_path'];
$origFilename = basename($originalPath);
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
// 2. Generate thumbnails (xs/sm/md/lg/xl)
$publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAssets as $variant => $asset) {
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash.'.webp');
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
}
// 3. Get new dimensions
$dims = @getimagesize($tempPath);
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
$dims = @getimagesize($tempPath);
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
$height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
$size = (int) filesize($originalPath);
$size = (int) filesize($originalPath);
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
@@ -559,7 +614,7 @@ final class StudioArtworksApiController extends Controller
if ($clientName !== '') {
$clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION));
if ($clientExt === '' && $origExt !== '') {
$clientName .= '.' . $origExt;
$clientName .= '.'.$origExt;
}
$displayFileName = $clientName;
@@ -570,11 +625,11 @@ final class StudioArtworksApiController extends Controller
'file_path' => '',
'file_size' => $size,
'mime_type' => $origMime,
'hash' => $hash,
'file_ext' => $origExt,
'hash' => $hash,
'file_ext' => $origExt,
'thumb_ext' => 'webp',
'width' => max(1, $width),
'height' => max(1, $height),
'width' => max(1, $width),
'height' => max(1, $height),
]);
// 5. Create version record from the new full media snapshot.
@@ -593,7 +648,7 @@ final class StudioArtworksApiController extends Controller
} catch (\Throwable $e) {
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
'error' => $e->getMessage(),
]);
}
@@ -601,16 +656,17 @@ final class StudioArtworksApiController extends Controller
$this->purgeCdnCache($artwork, $hash);
return response()->json([
'success' => true,
'success' => true,
'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,
'error' => $e->getMessage(),
'error' => $e->getMessage(),
]);
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
return response()->json(['success' => false, 'error' => 'File processing failed: '.$e->getMessage()], 500);
}
}
@@ -678,8 +734,8 @@ final class StudioArtworksApiController extends Controller
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
}
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$derivatives = app(UploadDerivativesService::class);
$storage = app(UploadStorageService::class);
$cleanupLocalPaths = [];
$cleanupObjectPaths = [];
@@ -766,7 +822,7 @@ final class StudioArtworksApiController extends Controller
$allScreenshotDescriptors = [];
foreach (array_values(array_merge($existingScreenshotDescriptors, $newScreenshotDescriptors)) as $index => $descriptor) {
$descriptor['variant'] = 'shot' . ($index + 1);
$descriptor['variant'] = 'shot'.($index + 1);
$allScreenshotDescriptors[] = $descriptor;
}
@@ -862,7 +918,7 @@ final class StudioArtworksApiController extends Controller
return response()->json([
'success' => false,
'error' => 'Revision update failed: ' . $exception->getMessage(),
'error' => 'Revision update failed: '.$exception->getMessage(),
], 500);
}
}
@@ -873,27 +929,27 @@ final class StudioArtworksApiController extends Controller
*/
public function versions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$artwork = $request->user()->artworks()->findOrFail($id);
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
return response()->json([
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'id' => $artwork->id,
'title' => $artwork->title,
'version_count' => (int) ($artwork->version_count ?? 1),
],
'versions' => $versions->map(fn (ArtworkVersion $v) => [
'id' => $v->id,
'id' => $v->id,
'version_number' => $v->version_number,
'file_path' => $v->file_path,
'file_hash' => $v->file_hash,
'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(),
'file_path' => $v->file_path,
'file_hash' => $v->file_hash,
'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(),
])->values(),
]);
}
@@ -918,17 +974,18 @@ final class StudioArtworksApiController extends Controller
// Reindex
try {
$this->searchIndexer->update($artwork);
} catch (\Throwable) {}
} catch (\Throwable) {
}
return response()->json([
'success' => true,
'success' => true,
'version_number' => $newVersion->version_number,
'message' => "Version {$version->version_number} has been restored as version {$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) {
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
return response()->json(['success' => false, 'error' => 'Restore failed: '.$e->getMessage()], 500);
}
}
@@ -938,7 +995,7 @@ final class StudioArtworksApiController extends Controller
* This is best-effort; failures are logged but never fatal.
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
*/
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
private function purgeCdnCache(Artwork $artwork, string $oldHash): void
{
try {
$this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [
@@ -951,7 +1008,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed> $snapshot
* @param array<string, mixed> $snapshot
* @return array<string, array<string, mixed>>
*/
private function snapshotFilesByVariant(array $snapshot): array
@@ -963,7 +1020,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed> $snapshot
* @param array<string, mixed> $snapshot
* @return array<string, array{path: string, mime: string, size: int}>
*/
private function currentPublicAssetsFromSnapshot(array $snapshot): array
@@ -981,7 +1038,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed>|null $file
* @param array<string, mixed>|null $file
* @return array<string, mixed>|null
*/
private function existingVariantDescriptor(?array $file, string $fallbackVariant): ?array
@@ -1002,7 +1059,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed> $stored
* @param array<string, mixed> $stored
* @return array<string, mixed>
*/
private function storedOriginalDescriptor(array $stored, string $variant): array
@@ -1028,7 +1085,7 @@ final class StudioArtworksApiController extends Controller
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
if ($candidateExt === '' && $ext !== '') {
$candidate .= '.' . ltrim($ext, '.');
$candidate .= '.'.ltrim($ext, '.');
}
return $candidate;
@@ -1058,7 +1115,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed> $snapshot
* @param array<string, mixed> $snapshot
* @return array<int, array<string, mixed>>
*/
private function screenshotAssetsFromSnapshot(array $snapshot): array
@@ -1070,11 +1127,11 @@ final class StudioArtworksApiController extends Controller
->values()
->map(function (array $file, int $index) use ($base): array {
$path = trim((string) ($file['path'] ?? ''), '/');
$url = $base . '/' . $path;
$url = $base.'/'.$path;
return [
'id' => (string) ($file['variant'] ?? ('shot' . ($index + 1))),
'label' => 'Screenshot ' . ($index + 1),
'id' => (string) ($file['variant'] ?? ('shot'.($index + 1))),
'label' => 'Screenshot '.($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($file['mime'] ?? 'image/jpeg'),
@@ -5,26 +5,27 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Group;
use App\Models\ContentType;
use App\Models\Group;
use App\Services\ArtworkEvolutionService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\GroupMembershipService;
use App\Services\GroupService;
use App\Services\Studio\CreatorStudioActivityService;
use App\Services\Studio\CreatorStudioAnalyticsService;
use App\Services\Studio\CreatorStudioAssetService;
use App\Services\Studio\CreatorStudioCalendarService;
use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioCommentService;
use App\Services\Studio\CreatorStudioContentService;
use App\Services\Studio\CreatorStudioFollowersService;
use App\Services\Studio\CreatorStudioGrowthService;
use App\Services\Studio\CreatorStudioActivityService;
use App\Services\Studio\CreatorStudioInboxService;
use App\Services\Studio\CreatorStudioOverviewService;
use App\Services\Studio\CreatorStudioPreferenceService;
use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Studio\CreatorStudioScheduledService;
use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\CoverUrl;
use Illuminate\Http\RedirectResponse;
@@ -132,7 +133,7 @@ final class StudioController extends Controller
public function uploadQueue(Request $request): Response
{
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
$queue = app(UploadQueueService::class)->listPayload(
$request->user(),
$request->only(['batch_id', 'status', 'sort'])
);
@@ -361,7 +362,7 @@ final class StudioController extends Controller
'cover_url' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()) : null,
'cover_position' => (int) ($user->cover_position ?? 50),
'followers' => (int) ($user->statistics?->followers_count ?? 0),
'profile_url' => '/@' . strtolower((string) $user->username),
'profile_url' => '/@'.strtolower((string) $user->username),
'social_links' => $socialLinks,
],
'moduleSummaries' => $this->content->moduleSummaries($user),
@@ -449,13 +450,13 @@ final class StudioController extends Controller
{
$user = $request->user();
$artwork = $user->artworks()
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
->with(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
->findOrFail($id);
$artwork = app(ArtworkPublicationService::class)->publishIfDue($artwork);
$artwork->loadMissing(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
$artwork->loadMissing(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->categories->first();
$primaryCategory = $artwork->resolvedPrimaryCategory();
$availableGroups = app(GroupService::class)->studioOptionsForUser($user);
$membershipService = app(GroupMembershipService::class);
$contributorOptionsByGroup = [];
@@ -470,30 +471,30 @@ final class StudioController extends Controller
}
return Inertia::render('Studio/StudioArtworkEdit', [
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
'is_approved' => (bool) $artwork->is_approved,
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
'is_approved' => (bool) $artwork->is_approved,
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'download_url' => route('art.download', ['id' => $artwork->id]),
'file_name' => $artwork->file_name,
'file_ext' => $artwork->file_ext,
'file_size' => $artwork->file_size,
'width' => $artwork->width,
'height' => $artwork->height,
'mime_type' => $artwork->mime_type,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'download_url' => route('art.download', ['id' => $artwork->id]),
'file_name' => $artwork->file_name,
'file_ext' => $artwork->file_ext,
'file_size' => $artwork->file_size,
'width' => $artwork->width,
'height' => $artwork->height,
'mime_type' => $artwork->mime_type,
'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id),
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
'group_slug' => $artwork->group?->slug,
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
'group_slug' => $artwork->group?->slug,
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(),
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
@@ -502,19 +503,27 @@ final class StudioController extends Controller
'is_primary' => (bool) $contributor->is_primary,
])->values()->all(),
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'category_id' => $primaryCategory?->id,
'primary_category_id' => $primaryCategory?->id,
'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id,
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'ai_status' => $artwork->ai_status,
'title_source' => $artwork->title_source ?: 'manual',
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
'secondary_category_ids' => $artwork->categories
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
->pluck('id')
->map(fn ($id): int => (int) $id)
->values()
->all(),
'max_secondary_categories' => (int) config('categories.max_secondary_categories', 5),
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug, 'is_primary' => (int) $c->id === (int) ($primaryCategory?->id ?? 0)])->values()->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'ai_status' => $artwork->ai_status,
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user),
// Versioning
'version_count' => (int) ($artwork->version_count ?? 1),
'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval,
],
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
@@ -538,19 +547,19 @@ final class StudioController extends Controller
return Inertia::render('Studio/StudioArtworkAnalytics', [
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md'),
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md'),
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
@@ -569,16 +578,16 @@ final class StudioController extends Controller
$data = $this->analytics->overview($user, $rangeDays);
return Inertia::render('Studio/StudioAnalytics', [
'totals' => $data['totals'],
'topContent' => $data['top_content'],
'moduleBreakdown' => $data['module_breakdown'],
'viewsTrend' => $data['views_trend'],
'engagementTrend' => $data['engagement_trend'],
'totals' => $data['totals'],
'topContent' => $data['top_content'],
'moduleBreakdown' => $data['module_breakdown'],
'viewsTrend' => $data['views_trend'],
'engagementTrend' => $data['engagement_trend'],
'publishingTimeline' => $data['publishing_timeline'],
'comparison' => $data['comparison'],
'insightBlocks' => $data['insight_blocks'],
'rangeDays' => $data['range_days'],
'recentComments' => $this->overview->recentComments($user, 8),
'comparison' => $data['comparison'],
'insightBlocks' => $data['insight_blocks'],
'rangeDays' => $data['range_days'],
'recentComments' => $this->overview->recentComments($user, 8),
]);
}
@@ -586,16 +595,16 @@ final class StudioController extends Controller
{
return ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) {
return [
'id' => $ct->id,
'id' => $ct->id,
'name' => $ct->name,
'slug' => $ct->slug,
'categories' => $ct->rootCategories->map(function ($c) {
return [
'id' => $c->id,
'name' => $c->name,
'slug' => $c->slug,
'id' => $c->id,
'name' => $c->name,
'slug' => $c->slug,
'children' => $c->children->map(fn ($ch) => [
'id' => $ch->id,
'id' => $ch->id,
'name' => $ch->name,
'slug' => $ch->slug,
])->values()->all(),
@@ -642,11 +651,11 @@ final class StudioController extends Controller
return null;
}
$url = $base . '/' . $path;
$url = $base.'/'.$path;
return [
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
'label' => 'Screenshot ' . ($index + 1),
'id' => (string) ($row->variant ?? ('shot'.($index + 1))),
'label' => 'Screenshot '.($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),