From cdd42a01868a500db513493435ee317d5dc8072f Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Thu, 16 Apr 2026 15:01:15 +0200 Subject: [PATCH] Improve creator studio browsing and versioning --- .../Controllers/Studio/StudioController.php | 60 ++- .../Studio/StudioScheduleApiController.php | 10 +- app/Models/ArtworkVersion.php | 2 + app/Services/ArtworkVersioningService.php | 364 +++++++++++++++--- .../Studio/CreatorStudioContentService.php | 103 +++-- .../Providers/ArtworkStudioProvider.php | 30 +- .../Studio/Providers/CardStudioProvider.php | 7 +- .../Providers/CollectionStudioProvider.php | 7 +- .../Studio/Providers/StoryStudioProvider.php | 7 +- .../Studio/StudioContentBrowser.jsx | 182 +++++++-- routes/api.php | 17 + tests/Feature/StudioTest.php | 79 ++++ 12 files changed, 728 insertions(+), 140 deletions(-) diff --git a/app/Http/Controllers/Studio/StudioController.php b/app/Http/Controllers/Studio/StudioController.php index 64c70304..c41fcee5 100644 --- a/app/Http/Controllers/Studio/StudioController.php +++ b/app/Http/Controllers/Studio/StudioController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Models\Group; use App\Models\ContentType; use App\Services\ArtworkEvolutionService; +use App\Services\Artworks\ArtworkPublicationService; use App\Services\GroupMembershipService; use App\Services\GroupService; use App\Services\Studio\CreatorStudioAnalyticsService; @@ -28,6 +29,7 @@ use App\Support\CoverUrl; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Inertia\Inertia; use Inertia\Response; @@ -74,7 +76,7 @@ final class StudioController extends Controller public function content(Request $request): Response { $prefs = $this->preferences->forUser($request->user()); - $listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'category', 'tag', 'visibility', 'activity_state', 'stale'])); + $listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'content_type', 'category', 'tag', 'visibility', 'activity_state', 'stale'])); $listing['default_view'] = $prefs['default_content_view']; return Inertia::render('Studio/StudioContentIndex', [ @@ -92,7 +94,7 @@ final class StudioController extends Controller { $provider = $this->content->provider('artworks'); $prefs = $this->preferences->forUser($request->user()); - $listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'category', 'tag']), null, 'artworks'); + $listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']), null, 'artworks'); $listing['default_view'] = $prefs['default_content_view']; return Inertia::render('Studio/StudioArtworks', [ @@ -426,6 +428,9 @@ final class StudioController extends Controller ->with(['stats', 'categories.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']); + $primaryCategory = $artwork->categories->first(); $availableGroups = app(GroupService::class)->studioOptionsForUser($user); $membershipService = app(GroupMembershipService::class); @@ -455,11 +460,15 @@ final class StudioController extends Controller '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, + 'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id), + '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(), @@ -588,4 +597,51 @@ final class StudioController extends Controller default => 'studio.index', }; } + + private function screenshotAssetsForArtwork(int $artworkId): array + { + if (! Schema::hasTable('artwork_files')) { + return []; + } + + $base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'); + + return DB::table('artwork_files') + ->where('artwork_id', $artworkId) + ->where('variant', 'like', 'shot%') + ->orderBy('variant') + ->get(['variant', 'path', 'mime', 'size']) + ->map(function ($row, int $index) use ($base): ?array { + $path = trim((string) ($row->path ?? ''), '/'); + if ($path === '') { + return null; + } + + $url = $base . '/' . $path; + + return [ + 'id' => (string) ($row->variant ?? ('shot' . ($index + 1))), + 'label' => 'Screenshot ' . ($index + 1), + 'url' => $url, + 'thumb_url' => $url, + 'mime_type' => (string) ($row->mime ?? 'image/jpeg'), + 'size' => (int) ($row->size ?? 0), + ]; + }) + ->filter() + ->values() + ->all(); + } + + private function artworkHasArchiveFile(int $artworkId): bool + { + if (! Schema::hasTable('artwork_files')) { + return false; + } + + return DB::table('artwork_files') + ->where('artwork_id', $artworkId) + ->where('variant', 'orig_archive') + ->exists(); + } } diff --git a/app/Http/Controllers/Studio/StudioScheduleApiController.php b/app/Http/Controllers/Studio/StudioScheduleApiController.php index 2f465696..f8d304f4 100644 --- a/app/Http/Controllers/Studio/StudioScheduleApiController.php +++ b/app/Http/Controllers/Studio/StudioScheduleApiController.php @@ -9,6 +9,7 @@ use App\Models\Artwork; use App\Models\Collection; use App\Models\NovaCard; use App\Models\Story; +use App\Services\Artworks\ArtworkPublicationService; use App\Services\CollectionLifecycleService; use App\Services\NovaCards\NovaCardPublishService; use App\Services\StoryPublicationService; @@ -20,6 +21,7 @@ final class StudioScheduleApiController extends Controller { public function __construct( private readonly CreatorStudioContentService $content, + private readonly ArtworkPublicationService $artworkPublication, private readonly NovaCardPublishService $cards, private readonly CollectionLifecycleService $collections, private readonly StoryPublicationService $stories, @@ -68,13 +70,7 @@ final class StudioScheduleApiController extends Controller ->where('user_id', $userId) ->findOrFail($id); - $artwork->forceFill([ - 'artwork_status' => 'published', - 'publish_at' => null, - 'artwork_timezone' => null, - 'published_at' => now(), - 'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE, - ])->save(); + $this->artworkPublication->publishNow($artwork); } private function unscheduleArtwork(int $userId, int $id): void diff --git a/app/Models/ArtworkVersion.php b/app/Models/ArtworkVersion.php index 365b2aae..7ce2d5c9 100644 --- a/app/Models/ArtworkVersion.php +++ b/app/Models/ArtworkVersion.php @@ -26,6 +26,7 @@ class ArtworkVersion extends Model 'height', 'file_size', 'change_note', + 'snapshot_json', 'is_current', ]; @@ -35,6 +36,7 @@ class ArtworkVersion extends Model 'width' => 'integer', 'height' => 'integer', 'file_size' => 'integer', + 'snapshot_json' => 'array', ]; public function artwork(): BelongsTo diff --git a/app/Services/ArtworkVersioningService.php b/app/Services/ArtworkVersioningService.php index 020806f5..d992ff23 100644 --- a/app/Services/ArtworkVersioningService.php +++ b/app/Services/ArtworkVersioningService.php @@ -7,8 +7,6 @@ namespace App\Services; use App\Models\Artwork; use App\Models\ArtworkVersion; use App\Models\ArtworkVersionEvent; -use App\Models\User; -use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -40,6 +38,148 @@ final class ArtworkVersioningService // ────────────────────────────────────────────────────────────────────── + /** + * Capture the current artwork media state as a revision snapshot. + * + * @return array{artwork: array, files: array>} + */ + public function captureArtworkSnapshot(Artwork $artwork): array + { + return [ + 'artwork' => [ + 'file_name' => (string) ($artwork->file_name ?? ''), + 'file_path' => (string) ($artwork->file_path ?? ''), + 'hash' => (string) ($artwork->hash ?? ''), + 'file_ext' => (string) ($artwork->file_ext ?? ''), + 'thumb_ext' => (string) ($artwork->thumb_ext ?? ''), + 'file_size' => (int) ($artwork->file_size ?? 0), + 'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'), + 'width' => (int) ($artwork->width ?? 0), + 'height' => (int) ($artwork->height ?? 0), + ], + 'files' => DB::table('artwork_files') + ->where('artwork_id', $artwork->id) + ->orderBy('variant') + ->get(['variant', 'path', 'mime', 'size']) + ->map(fn ($row): array => [ + 'variant' => (string) ($row->variant ?? ''), + 'path' => (string) ($row->path ?? ''), + 'mime' => (string) ($row->mime ?? 'application/octet-stream'), + 'size' => (int) ($row->size ?? 0), + ]) + ->values() + ->all(), + ]; + } + + /** + * Create a new immutable revision from a fully materialized artwork snapshot. + * + * @param array $snapshot + * @param array|null $previousSnapshot + */ + public function createVersionFromSnapshot( + Artwork $artwork, + array $snapshot, + int $userId, + ?string $changeNote = null, + ?array $previousSnapshot = null, + ): ArtworkVersion { + $normalizedSnapshot = $this->normalizeSnapshot($snapshot); + $previous = $this->normalizeSnapshot($previousSnapshot ?? $this->captureArtworkSnapshot($artwork)); + + $this->rateLimitCheck($userId, $artwork->id); + + return DB::transaction(function () use ($artwork, $normalizedSnapshot, $previous, $userId, $changeNote): ArtworkVersion { + $this->ensureBaselineVersion($artwork, $previous, $userId); + + $nextNumber = max((int) $artwork->versions()->max('version_number'), 0) + 1; + $meta = $this->snapshotArtworkMeta($normalizedSnapshot); + + $artwork->versions()->update(['is_current' => false]); + + $version = ArtworkVersion::create([ + 'artwork_id' => $artwork->id, + 'version_number' => $nextNumber, + 'file_path' => $meta['file_path'], + 'file_hash' => $meta['hash'], + 'width' => $meta['width'], + 'height' => $meta['height'], + 'file_size' => $meta['file_size'], + 'change_note' => $changeNote, + 'snapshot_json' => $normalizedSnapshot, + 'is_current' => true, + ]); + + $needsReapproval = $this->shouldRequireReapprovalFromSnapshots($previous, $normalizedSnapshot); + + $artwork->update([ + 'current_version_id' => $version->id, + 'version_count' => $nextNumber, + 'version_updated_at' => now(), + 'requires_reapproval' => $needsReapproval, + ]); + + $this->applyRankingProtection($artwork); + + ArtworkVersionEvent::create([ + 'artwork_id' => $artwork->id, + 'user_id' => $userId, + 'action' => 'create_version', + 'version_id' => $version->id, + ]); + + $this->incrementRateLimitCounters($userId, $artwork->id); + + return $version; + }); + } + + /** + * Apply a stored snapshot back onto the artwork row and artwork_files table. + * + * @param array $snapshot + */ + public function applySnapshot(Artwork $artwork, array $snapshot): void + { + $normalizedSnapshot = $this->normalizeSnapshot($snapshot); + $meta = $this->snapshotArtworkMeta($normalizedSnapshot); + + DB::transaction(function () use ($artwork, $normalizedSnapshot, $meta): void { + DB::table('artwork_files') + ->where('artwork_id', $artwork->id) + ->delete(); + + $rows = collect($normalizedSnapshot['files'] ?? []) + ->filter(fn (array $row): bool => ($row['variant'] ?? '') !== '' && ($row['path'] ?? '') !== '') + ->map(fn (array $row): array => [ + 'artwork_id' => $artwork->id, + 'variant' => (string) $row['variant'], + 'path' => (string) $row['path'], + 'mime' => (string) ($row['mime'] ?? 'application/octet-stream'), + 'size' => (int) ($row['size'] ?? 0), + ]) + ->values() + ->all(); + + if ($rows !== []) { + DB::table('artwork_files')->insert($rows); + } + + $artwork->update([ + 'file_name' => $meta['file_name'], + 'file_path' => $meta['file_path'], + 'hash' => $meta['hash'], + 'file_ext' => $meta['file_ext'], + 'thumb_ext' => $meta['thumb_ext'], + 'file_size' => $meta['file_size'], + 'mime_type' => $meta['mime_type'], + 'width' => $meta['width'], + 'height' => $meta['height'], + ]); + }); + } + /** * Create a new version for an artwork after a file replacement. * @@ -67,63 +207,22 @@ final class ArtworkVersioningService int $userId, ?string $changeNote = null, ): ArtworkVersion { - // 1. Rate limit check - $this->rateLimitCheck($userId, $artwork->id); + $snapshot = [ + 'artwork' => [ + 'file_name' => (string) ($artwork->file_name ?? 'artwork'), + 'file_path' => $filePath, + 'hash' => $fileHash, + 'file_ext' => (string) ($artwork->file_ext ?? ''), + 'thumb_ext' => (string) ($artwork->thumb_ext ?? ''), + 'file_size' => $fileSize, + 'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'), + 'width' => $width, + 'height' => $height, + ], + 'files' => [], + ]; - // 2. Reject identical file - if ($artwork->hash === $fileHash) { - throw new \RuntimeException('The uploaded file is identical to the current version. No new version created.'); - } - - return DB::transaction(function () use ( - $artwork, $filePath, $fileHash, $width, $height, $fileSize, $userId, $changeNote - ): ArtworkVersion { - // 3. Determine next version number - $nextNumber = ($artwork->version_count ?? 1) + 1; - - // 4. Mark all previous versions as not current - $artwork->versions()->update(['is_current' => false]); - - // 5. Insert new version row - $version = ArtworkVersion::create([ - 'artwork_id' => $artwork->id, - 'version_number' => $nextNumber, - 'file_path' => $filePath, - 'file_hash' => $fileHash, - 'width' => $width, - 'height' => $height, - 'file_size' => $fileSize, - 'change_note' => $changeNote, - 'is_current' => true, - ]); - - // 6. Check whether moderation re-review is required - $needsReapproval = $this->shouldRequireReapproval($artwork, $width, $height); - - // 7. Update artwork metadata (no engagement data touched) - $artwork->update([ - 'current_version_id' => $version->id, - 'version_count' => $nextNumber, - 'version_updated_at' => now(), - 'requires_reapproval' => $needsReapproval, - ]); - - // 8. Ranking protection — apply small decay - $this->applyRankingProtection($artwork); - - // 9. Audit log - ArtworkVersionEvent::create([ - 'artwork_id' => $artwork->id, - 'user_id' => $userId, - 'action' => 'create_version', - 'version_id' => $version->id, - ]); - - // 10. Increment hourly/daily counters for rate limiting - $this->incrementRateLimitCounters($userId, $artwork->id); - - return $version; - }); + return $this->createVersionFromSnapshot($artwork, $snapshot, $userId, $changeNote); } /** @@ -139,15 +238,33 @@ final class ArtworkVersioningService Artwork $artwork, int $userId, ): ArtworkVersion { - return $this->createNewVersion( + $previousSnapshot = $this->captureArtworkSnapshot($artwork); + $snapshot = is_array($version->snapshot_json) + ? $this->normalizeSnapshot($version->snapshot_json) + : $this->normalizeSnapshot([ + 'artwork' => array_merge($previousSnapshot['artwork'] ?? [], [ + 'file_name' => (string) ($artwork->file_name ?? 'artwork'), + 'file_path' => $version->file_path, + 'hash' => $version->file_hash, + 'file_ext' => (string) ($artwork->file_ext ?? ''), + 'thumb_ext' => (string) ($artwork->thumb_ext ?? ''), + 'file_size' => (int) $version->file_size, + 'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'), + 'width' => (int) $version->width, + 'height' => (int) $version->height, + ]), + 'files' => $previousSnapshot['files'] ?? [], + ]); + + $this->applySnapshot($artwork, $snapshot); + $artwork->refresh(); + + return $this->createVersionFromSnapshot( $artwork, - $version->file_path, - $version->file_hash, - (int) $version->width, - (int) $version->height, - (int) $version->file_size, + $snapshot, $userId, "Restored from version {$version->version_number}", + $previousSnapshot, ); } @@ -170,6 +287,26 @@ final class ArtworkVersioningService || $heightChange > self::DIMENSION_CHANGE_THRESHOLD; } + /** + * @param array $previousSnapshot + * @param array $newSnapshot + */ + public function shouldRequireReapprovalFromSnapshots(array $previousSnapshot, array $newSnapshot): bool + { + $previous = $this->snapshotArtworkMeta($previousSnapshot); + $next = $this->snapshotArtworkMeta($newSnapshot); + + if (($previous['width'] ?? 0) <= 0 || ($previous['height'] ?? 0) <= 0) { + return false; + } + + $widthChange = abs($next['width'] - $previous['width']) / max($previous['width'], 1); + $heightChange = abs($next['height'] - $previous['height']) / max($previous['height'], 1); + + return $widthChange > self::DIMENSION_CHANGE_THRESHOLD + || $heightChange > self::DIMENSION_CHANGE_THRESHOLD; + } + /** * Apply a small protective decay (7 %) to ranking and heat scores. * @@ -245,4 +382,103 @@ final class ArtworkVersioningService Cache::put($dayKey, 1, 86400); } } + + /** + * @param array|null $snapshot + */ + private function ensureBaselineVersion(Artwork $artwork, ?array $snapshot, int $userId): ?ArtworkVersion + { + if ($artwork->versions()->exists()) { + return null; + } + + $normalizedSnapshot = $this->normalizeSnapshot($snapshot ?? $this->captureArtworkSnapshot($artwork)); + $meta = $this->snapshotArtworkMeta($normalizedSnapshot); + $baselineNumber = max(1, (int) ($artwork->version_count ?? 1)); + + $version = ArtworkVersion::create([ + 'artwork_id' => $artwork->id, + 'version_number' => $baselineNumber, + 'file_path' => $meta['file_path'], + 'file_hash' => $meta['hash'], + 'width' => $meta['width'], + 'height' => $meta['height'], + 'file_size' => $meta['file_size'], + 'change_note' => 'Baseline snapshot', + 'snapshot_json' => $normalizedSnapshot, + 'is_current' => true, + ]); + + $artwork->update([ + 'current_version_id' => $version->id, + 'version_count' => $baselineNumber, + 'version_updated_at' => now(), + ]); + + ArtworkVersionEvent::create([ + 'artwork_id' => $artwork->id, + 'user_id' => $userId, + 'action' => 'baseline_snapshot', + 'version_id' => $version->id, + ]); + + return $version; + } + + /** + * @param array $snapshot + * @return array{file_name: string, file_path: string, hash: string, file_ext: string, thumb_ext: string, file_size: int, mime_type: string, width: int, height: int} + */ + private function snapshotArtworkMeta(array $snapshot): array + { + $normalized = $this->normalizeSnapshot($snapshot); + $artwork = $normalized['artwork']; + + return [ + 'file_name' => (string) ($artwork['file_name'] ?? 'artwork'), + 'file_path' => (string) ($artwork['file_path'] ?? ''), + 'hash' => (string) ($artwork['hash'] ?? ''), + 'file_ext' => (string) ($artwork['file_ext'] ?? ''), + 'thumb_ext' => (string) ($artwork['thumb_ext'] ?? ''), + 'file_size' => (int) ($artwork['file_size'] ?? 0), + 'mime_type' => (string) ($artwork['mime_type'] ?? 'application/octet-stream'), + 'width' => max(0, (int) ($artwork['width'] ?? 0)), + 'height' => max(0, (int) ($artwork['height'] ?? 0)), + ]; + } + + /** + * @param array|null $snapshot + * @return array{artwork: array, files: array>} + */ + private function normalizeSnapshot(?array $snapshot): array + { + $artwork = is_array($snapshot['artwork'] ?? null) ? $snapshot['artwork'] : []; + $files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : []; + + return [ + 'artwork' => [ + 'file_name' => (string) ($artwork['file_name'] ?? 'artwork'), + 'file_path' => (string) ($artwork['file_path'] ?? ''), + 'hash' => (string) ($artwork['hash'] ?? ''), + 'file_ext' => (string) ($artwork['file_ext'] ?? ''), + 'thumb_ext' => (string) ($artwork['thumb_ext'] ?? ''), + 'file_size' => (int) ($artwork['file_size'] ?? 0), + 'mime_type' => (string) ($artwork['mime_type'] ?? 'application/octet-stream'), + 'width' => max(0, (int) ($artwork['width'] ?? 0)), + 'height' => max(0, (int) ($artwork['height'] ?? 0)), + ], + 'files' => collect($files) + ->filter(fn ($file): bool => is_array($file)) + ->map(fn (array $file): array => [ + 'variant' => (string) ($file['variant'] ?? ''), + 'path' => (string) ($file['path'] ?? ''), + 'mime' => (string) ($file['mime'] ?? 'application/octet-stream'), + 'size' => (int) ($file['size'] ?? 0), + ]) + ->filter(fn (array $file): bool => $file['variant'] !== '' && $file['path'] !== '') + ->values() + ->all(), + ]; + } } diff --git a/app/Services/Studio/CreatorStudioContentService.php b/app/Services/Studio/CreatorStudioContentService.php index fa595629..02340695 100644 --- a/app/Services/Studio/CreatorStudioContentService.php +++ b/app/Services/Studio/CreatorStudioContentService.php @@ -53,6 +53,7 @@ final class CreatorStudioContentService $bucket = $fixedBucket ?: $this->normalizeBucket((string) ($filters['bucket'] ?? 'all')); $search = trim((string) ($filters['q'] ?? '')); $sort = $this->normalizeSort((string) ($filters['sort'] ?? 'updated_desc')); + $contentType = (string) ($filters['content_type'] ?? 'all'); $category = (string) ($filters['category'] ?? 'all'); $tag = trim((string) ($filters['tag'] ?? '')); $visibility = (string) ($filters['visibility'] ?? 'all'); @@ -63,7 +64,7 @@ final class CreatorStudioContentService $items = $module === 'all' ? SupportCollection::make($this->providers())->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, $this->providerBucket($bucket), 200)) - : $this->provider($module)?->items($user, $this->providerBucket($bucket), 240) ?? SupportCollection::make(); + : $this->provider($module)?->items($user, $this->providerBucket($bucket), 0) ?? SupportCollection::make(); if ($bucket === 'featured') { $items = $items->filter(fn (array $item): bool => (bool) ($item['featured'] ?? false)); @@ -91,6 +92,19 @@ final class CreatorStudioContentService }); } + $artworkFilterItems = null; + + if ($module === 'artworks') { + $artworkFilterItems = $items->values(); + } + + if ($module === 'artworks' && $contentType !== 'all') { + $items = $items->filter(function (array $item) use ($contentType): bool { + return SupportCollection::make($item['taxonomies']['content_types'] ?? []) + ->contains(fn (array $entry): bool => (string) ($entry['slug'] ?? '') === $contentType); + }); + } + if ($module === 'artworks' && $category !== 'all') { $items = $items->filter(function (array $item) use ($category): bool { return SupportCollection::make($item['taxonomies']['categories'] ?? []) @@ -141,6 +155,7 @@ final class CreatorStudioContentService 'bucket' => $bucket, 'q' => $search, 'sort' => $sort, + 'content_type' => $contentType, 'category' => $category, 'tag' => $tag, 'visibility' => $visibility, @@ -174,12 +189,13 @@ final class CreatorStudioContentService ['value' => 'title_asc', 'label' => 'Title A-Z'], ], 'advanced_filters' => $this->advancedFilters($module, $items, [ + 'content_type' => $contentType, 'category' => $category, 'tag' => $tag, 'visibility' => $visibility, 'activity_state' => $activityState, 'stale' => $stale, - ]), + ], $artworkFilterItems), ]; } @@ -323,34 +339,65 @@ final class CreatorStudioContentService /** * @param array $currentFilters */ - private function advancedFilters(string $module, SupportCollection $items, array $currentFilters): array + private function advancedFilters(string $module, SupportCollection $items, array $currentFilters, ?SupportCollection $optionItems = null): array { return match ($module) { - 'artworks' => [ - [ - 'key' => 'category', - 'label' => 'Category', - 'type' => 'select', - 'value' => $currentFilters['category'] ?? 'all', - 'options' => array_merge([ - ['value' => 'all', 'label' => 'All categories'], - ], $items - ->flatMap(fn (array $item) => $item['taxonomies']['categories'] ?? []) - ->unique('slug') - ->sortBy('name') - ->map(fn (array $entry): array => [ - 'value' => (string) ($entry['slug'] ?? ''), - 'label' => (string) ($entry['name'] ?? 'Category'), - ])->values()->all()), - ], - [ - 'key' => 'tag', - 'label' => 'Tag', - 'type' => 'search', - 'value' => $currentFilters['tag'] ?? '', - 'placeholder' => 'Filter by tag', - ], - ], + 'artworks' => (function () use ($items, $currentFilters, $optionItems): array { + $optionItems = $optionItems ?? $items; + $selectedContentType = $currentFilters['content_type'] ?? 'all'; + + $contentTypeOptions = array_merge([ + ['value' => 'all', 'label' => 'All content types'], + ], $optionItems + ->flatMap(fn (array $item) => $item['taxonomies']['content_types'] ?? []) + ->unique('slug') + ->sortBy('name') + ->map(fn (array $entry): array => [ + 'value' => (string) ($entry['slug'] ?? ''), + 'label' => (string) ($entry['name'] ?? 'Content type'), + ])->values()->all()); + + $categoryItems = $selectedContentType === 'all' + ? $optionItems + : $optionItems->filter(function (array $item) use ($selectedContentType): bool { + return SupportCollection::make($item['taxonomies']['content_types'] ?? []) + ->contains(fn (array $entry): bool => (string) ($entry['slug'] ?? '') === $selectedContentType); + }); + + return [ + [ + 'key' => 'content_type', + 'label' => 'Content Type', + 'type' => 'select', + 'value' => $currentFilters['content_type'] ?? 'all', + 'options' => $contentTypeOptions, + ], + [ + 'key' => 'category', + 'label' => 'Category', + 'type' => 'select', + 'value' => $currentFilters['category'] ?? 'all', + 'options' => array_merge([ + ['value' => 'all', 'label' => 'All categories', 'content_type_slug' => 'all'], + ], $categoryItems + ->flatMap(fn (array $item) => $item['taxonomies']['categories'] ?? []) + ->unique('slug') + ->sortBy('name') + ->map(fn (array $entry): array => [ + 'value' => (string) ($entry['slug'] ?? ''), + 'label' => (string) ($entry['name'] ?? 'Category'), + 'content_type_slug' => (string) ($entry['content_type_slug'] ?? 'all'), + ])->values()->all()), + ], + [ + 'key' => 'tag', + 'label' => 'Tag', + 'type' => 'search', + 'value' => $currentFilters['tag'] ?? '', + 'placeholder' => 'Filter by tag', + ], + ]; + })(), 'collections' => [[ 'key' => 'visibility', 'label' => 'Visibility', diff --git a/app/Services/Studio/Providers/ArtworkStudioProvider.php b/app/Services/Studio/Providers/ArtworkStudioProvider.php index e1fb8717..2f657c06 100644 --- a/app/Services/Studio/Providers/ArtworkStudioProvider.php +++ b/app/Services/Studio/Providers/ArtworkStudioProvider.php @@ -7,6 +7,7 @@ namespace App\Services\Studio\Providers; use App\Models\Artwork; use App\Models\ArtworkStats; use App\Models\User; +use App\Services\Artworks\ArtworkPublicationService; use App\Services\Studio\Contracts\CreatorStudioProvider; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; @@ -14,6 +15,10 @@ use Illuminate\Support\Facades\DB; final class ArtworkStudioProvider implements CreatorStudioProvider { + public function __construct( + private readonly ArtworkPublicationService $publicationService, + ) {} + public function key(): string { return 'artworks'; @@ -41,6 +46,8 @@ final class ArtworkStudioProvider implements CreatorStudioProvider public function summary(User $user): array { + $this->publicationService->publishDueScheduledForUser((int) $user->id); + $baseQuery = Artwork::query()->withTrashed()->where('user_id', $user->id); $count = (clone $baseQuery) @@ -90,12 +97,15 @@ final class ArtworkStudioProvider implements CreatorStudioProvider public function items(User $user, string $bucket = 'all', int $limit = 200): Collection { + $this->publicationService->publishDueScheduledForUser((int) $user->id); + $query = Artwork::query() ->withTrashed() ->where('user_id', $user->id) ->with([ 'stats', 'categories', + 'categories.contentType', 'tags', 'features' => function ($query): void { $query->where('is_active', true) @@ -106,8 +116,11 @@ final class ArtworkStudioProvider implements CreatorStudioProvider }); }, ]) - ->orderByDesc('updated_at') - ->limit($limit); + ->orderByDesc('updated_at'); + + if ($limit > 0) { + $query->limit($limit); + } if ($bucket === 'drafts') { $query->whereNull('deleted_at') @@ -134,6 +147,8 @@ final class ArtworkStudioProvider implements CreatorStudioProvider public function topItems(User $user, int $limit = 5): Collection { + $this->publicationService->publishDueScheduledForUser((int) $user->id); + return Artwork::query() ->where('user_id', $user->id) ->whereNull('deleted_at') @@ -226,10 +241,21 @@ final class ArtworkStudioProvider implements CreatorStudioProvider + ((int) ($stats?->comments_count ?? 0) * 3) + ((int) ($stats?->shares_count ?? 0) * 2), 'taxonomies' => [ + 'content_types' => $artwork->categories + ->map(fn ($entry) => $entry->contentType) + ->filter() + ->unique('id') + ->map(fn ($entry): array => [ + 'id' => (int) $entry->id, + 'name' => (string) $entry->name, + 'slug' => (string) $entry->slug, + ])->values()->all(), 'categories' => $artwork->categories->map(fn ($entry): array => [ 'id' => (int) $entry->id, 'name' => (string) $entry->name, 'slug' => (string) $entry->slug, + 'content_type' => (string) ($entry->contentType?->name ?? ''), + 'content_type_slug' => (string) ($entry->contentType?->slug ?? ''), ])->values()->all(), 'tags' => $artwork->tags->map(fn ($entry): array => [ 'id' => (int) $entry->id, diff --git a/app/Services/Studio/Providers/CardStudioProvider.php b/app/Services/Studio/Providers/CardStudioProvider.php index 46973e61..f7de478a 100644 --- a/app/Services/Studio/Providers/CardStudioProvider.php +++ b/app/Services/Studio/Providers/CardStudioProvider.php @@ -92,8 +92,11 @@ final class CardStudioProvider implements CreatorStudioProvider ->withTrashed() ->where('user_id', $user->id) ->with(['category', 'tags']) - ->orderByDesc('updated_at') - ->limit($limit); + ->orderByDesc('updated_at'); + + if ($limit > 0) { + $query->limit($limit); + } if ($bucket === 'drafts') { $query->whereNull('deleted_at')->where('status', NovaCard::STATUS_DRAFT); diff --git a/app/Services/Studio/Providers/CollectionStudioProvider.php b/app/Services/Studio/Providers/CollectionStudioProvider.php index fb152b8f..7d3ad7ea 100644 --- a/app/Services/Studio/Providers/CollectionStudioProvider.php +++ b/app/Services/Studio/Providers/CollectionStudioProvider.php @@ -101,8 +101,11 @@ final class CollectionStudioProvider implements CreatorStudioProvider ->withTrashed() ->where('user_id', $user->id) ->with(['user.profile', 'coverArtwork']) - ->orderByDesc('updated_at') - ->limit($limit); + ->orderByDesc('updated_at'); + + if ($limit > 0) { + $query->limit($limit); + } if ($bucket === 'drafts') { $query->whereNull('deleted_at') diff --git a/app/Services/Studio/Providers/StoryStudioProvider.php b/app/Services/Studio/Providers/StoryStudioProvider.php index 6f5053c9..0c424f59 100644 --- a/app/Services/Studio/Providers/StoryStudioProvider.php +++ b/app/Services/Studio/Providers/StoryStudioProvider.php @@ -83,8 +83,11 @@ final class StoryStudioProvider implements CreatorStudioProvider $query = Story::query() ->where('creator_id', $user->id) ->with(['tags']) - ->orderByDesc('updated_at') - ->limit($limit); + ->orderByDesc('updated_at'); + + if ($limit > 0) { + $query->limit($limit); + } if ($bucket === 'drafts') { $query->whereIn('status', ['draft', 'pending_review', 'rejected']); diff --git a/resources/js/components/Studio/StudioContentBrowser.jsx b/resources/js/components/Studio/StudioContentBrowser.jsx index 1e874633..75f69b7d 100644 --- a/resources/js/components/Studio/StudioContentBrowser.jsx +++ b/resources/js/components/Studio/StudioContentBrowser.jsx @@ -45,6 +45,28 @@ function itemReadiness(item) { return item?.workflow?.readiness ?? null } +function buildPaginationPages(current, last) { + if (last <= 1) return [1] + if (last <= 7) { + return Array.from({ length: last }, (_, index) => index + 1) + } + + const pages = new Set([1, 2, current - 1, current, current + 1, last - 1, last]) + const sorted = [...pages] + .filter((page) => page >= 1 && page <= last) + .sort((left, right) => left - right) + + const result = [] + for (let index = 0; index < sorted.length; index += 1) { + if (index > 0 && sorted[index] - sorted[index - 1] > 1) { + result.push('ellipsis') + } + result.push(sorted[index]) + } + + return result +} + function bulkErrorMessage(payload, fallback = 'Bulk action failed.') { if (Array.isArray(payload?.errors) && payload.errors.length > 0) { return payload.errors[0] @@ -284,6 +306,24 @@ function ListRow({ item, onExecuteAction, busyKey }) { ) } +function materializeFilter(filter, pendingFilters) { + if (filter?.key !== 'category') { + return filter + } + + const selectedContentType = pendingFilters?.content_type || 'all' + const options = Array.isArray(filter.options) + ? filter.options.filter((option) => option.value === 'all' + || selectedContentType === 'all' + || option.content_type_slug === selectedContentType) + : filter.options + + return { + ...filter, + options, + } +} + function AdvancedFilterControl({ filter, onChange, value }) { const controlValue = value ?? filter.value @@ -337,6 +377,7 @@ export default function StudioContentBrowser({ q: '', bucket: 'all', sort: 'updated_desc', + content_type: 'all', category: 'all', tag: '', }) @@ -362,6 +403,12 @@ export default function StudioContentBrowser({ const allVisibleSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id)) const selectedOnPage = selectedIds.filter((id) => selectableIds.includes(id)) const visibleTotal = Math.max(0, Number(meta.total || 0) - optimisticRemovedIds.length) + const currentPage = Math.max(1, Number(meta.current_page || 1)) + const lastPage = Math.max(1, Number(meta.last_page || 1)) + const perPage = Math.max(1, Number(meta.per_page || visibleItems.length || 24)) + const rangeStart = visibleTotal === 0 ? 0 : ((currentPage - 1) * perPage) + 1 + const rangeEnd = visibleTotal === 0 ? 0 : Math.min(visibleTotal, rangeStart + Math.max(visibleItems.length, 1) - 1) + const paginationPages = buildPaginationPages(currentPage, lastPage) const filterControlCount = 1 + (hideModuleFilter ? 0 : 1) + (hideBucketFilter ? 0 : 1) + 1 + advancedFilters.length + 1 const filterGridClass = filterControlCount <= 4 ? 'xl:grid-cols-4' @@ -396,10 +443,11 @@ export default function StudioContentBrowser({ q: filters.q || '', bucket: filters.bucket || 'all', sort: filters.sort || 'updated_desc', + content_type: filters.content_type || 'all', category: filters.category || 'all', tag: filters.tag || '', }) - }, [filters.q, filters.bucket, filters.sort, filters.category, filters.tag]) + }, [filters.q, filters.bucket, filters.sort, filters.content_type, filters.category, filters.tag]) const updateQuery = (patch) => { const next = { @@ -439,10 +487,20 @@ export default function StudioContentBrowser({ } const setPendingFilter = (key, value) => { - setPendingFilters((current) => ({ - ...current, - [key]: value, - })) + setPendingFilters((current) => { + if (key === 'content_type') { + return { + ...current, + content_type: value, + category: 'all', + } + } + + return { + ...current, + [key]: value, + } + }) } const submitSearch = () => { @@ -450,6 +508,7 @@ export default function StudioContentBrowser({ q: pendingFilters.q, bucket: pendingFilters.bucket, sort: pendingFilters.sort, + content_type: pendingFilters.content_type, category: pendingFilters.category, tag: pendingFilters.tag, }) @@ -818,13 +877,16 @@ export default function StudioContentBrowser({ - {advancedFilters.map((filter) => ( + {advancedFilters.map((filter) => { + const resolvedFilter = materializeFilter(filter, pendingFilters) + + return ( { - if (key === 'category' || key === 'tag') { + if (key === 'content_type' || key === 'category' || key === 'tag') { setPendingFilter(key, value) return } @@ -832,7 +894,8 @@ export default function StudioContentBrowser({ updateQuery({ [key]: value }) }} /> - ))} + ) + })}
{viewMode === 'table' && supportsArtworkBulk && ( @@ -1071,28 +1134,85 @@ export default function StudioContentBrowser({ )} -
- +
+
+
+
Creator Studio
+

+ {visibleTotal > 0 + ? <>Showing {rangeStart.toLocaleString()}-{rangeEnd.toLocaleString()} of {visibleTotal.toLocaleString()} + : 'No items to display'} +

+
- Creator Studio + {lastPage > 1 && ( + + )} +
where('username', '[A-Za-z0-9_-]{3,20}') ->name('api.profile.journey'); +// Public profile AI biography (read, included in profile payload via journey endpoint) +Route::middleware(['web', 'throttle:social-read']) + ->get('profile/{username}/ai-biography', [\App\Http\Controllers\Api\ProfileAiBiographyController::class, 'show']) + ->where('username', '[A-Za-z0-9_-]{3,20}') + ->name('api.profile.ai-biography'); + +// Creator-facing AI biography management (authenticated) +Route::middleware(['web', 'auth'])->prefix('creator/profile/ai-biography')->name('api.creator.ai-biography.')->group(function () { + Route::get('/', [\App\Http\Controllers\Api\AiBiographyController::class, 'status'])->name('status'); + Route::post('/generate', [\App\Http\Controllers\Api\AiBiographyController::class, 'generate'])->name('generate')->middleware('throttle:5,1'); + Route::post('/regenerate', [\App\Http\Controllers\Api\AiBiographyController::class, 'regenerate'])->name('regenerate')->middleware('throttle:5,1'); + Route::patch('/', [\App\Http\Controllers\Api\AiBiographyController::class, 'update'])->name('update')->middleware('throttle:20,1'); + Route::post('/hide', [\App\Http\Controllers\Api\AiBiographyController::class, 'hide'])->name('hide'); + Route::post('/show', [\App\Http\Controllers\Api\AiBiographyController::class, 'show'])->name('show'); +}); + Route::middleware(['web', 'throttle:social-read']) ->get('comments', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'comments']) ->name('api.social.comments.index'); @@ -143,6 +159,7 @@ Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group Route::post('artworks/{id}/ai/events', [\App\Http\Controllers\Studio\StudioArtworkAiAssistApiController::class, 'event'])->whereNumber('id')->name('artworks.ai.events'); Route::post('artworks/{id}/ai/regenerate', [\App\Http\Controllers\Studio\StudioArtworkAiAssistApiController::class, 'regenerate'])->whereNumber('id')->name('artworks.ai.regenerate'); Route::post('artworks/{id}/replace-file', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'replaceFile'])->whereNumber('id')->name('artworks.replaceFile'); + Route::post('artworks/{id}/revise-media', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'reviseMedia'])->whereNumber('id')->name('artworks.reviseMedia'); // Versioning Route::get('artworks/{id}/versions', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'versions'])->whereNumber('id')->name('artworks.versions'); Route::post('artworks/{id}/restore/{version_id}', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'restoreVersion'])->whereNumber('id')->whereNumber('version_id')->name('artworks.restoreVersion'); diff --git a/tests/Feature/StudioTest.php b/tests/Feature/StudioTest.php index e29bfe6c..7f702368 100644 --- a/tests/Feature/StudioTest.php +++ b/tests/Feature/StudioTest.php @@ -3,6 +3,8 @@ use App\Models\User; use App\Models\Artwork; use App\Models\ArtworkStats; +use App\Models\Category; +use App\Models\ContentType; use Illuminate\Support\Facades\DB; use Inertia\Testing\AssertableInertia; @@ -92,6 +94,83 @@ test('studio artworks page loads', function () { ->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioArtworks')->where('title', 'Artworks')); }); +test('studio artworks listing is not truncated before pagination', function () { + Artwork::withoutEvents(fn () => Artwork::factory()->count(245)->create([ + 'user_id' => $this->user->id, + 'is_public' => true, + 'published_at' => now(), + 'deleted_at' => null, + ])); + + $this->get('/studio/artworks') + ->assertStatus(200) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Studio/StudioArtworks') + ->where('summary.count', 245) + ->where('listing.meta.total', 245) + ->where('listing.meta.last_page', 11) + ->has('listing.items', 24)); +}); + +test('studio artworks can be filtered by content type', function () { + $photography = ContentType::query()->create([ + 'name' => 'Photography', + 'slug' => 'photography', + 'order' => 1, + 'hide_from_menu' => false, + ]); + + $skins = ContentType::query()->create([ + 'name' => 'Skins', + 'slug' => 'skins', + 'order' => 2, + 'hide_from_menu' => false, + ]); + + $nature = Category::query()->create([ + 'content_type_id' => $photography->id, + 'name' => 'Nature', + 'slug' => 'nature', + 'is_active' => true, + 'sort_order' => 1, + ]); + + $winamp = Category::query()->create([ + 'content_type_id' => $skins->id, + 'name' => 'Winamp', + 'slug' => 'winamp', + 'is_active' => true, + 'sort_order' => 1, + ]); + + $photoArtwork = studioArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Photo Work', + 'is_public' => true, + 'published_at' => now(), + ]); + $photoArtwork->categories()->attach($nature->id); + + $skinArtwork = studioArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Skin Work', + 'is_public' => true, + 'published_at' => now(), + ]); + $skinArtwork->categories()->attach($winamp->id); + + $this->get('/studio/artworks?content_type=photography') + ->assertStatus(200) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Studio/StudioArtworks') + ->where('listing.filters.content_type', 'photography') + ->where('listing.meta.total', 1) + ->where('listing.items.0.title', 'Photo Work') + ->where('listing.advanced_filters.0.key', 'content_type') + ->has('listing.advanced_filters.1.options', 2) + ->where('listing.advanced_filters.1.options.1.value', 'nature')); +}); + test('studio drafts page loads', function () { $this->get('/studio/artworks/drafts') ->assertRedirect('/studio/drafts');