Improve creator studio browsing and versioning

This commit is contained in:
2026-04-16 15:01:15 +02:00
parent 56eaa3bcbf
commit cdd42a0186
12 changed files with 728 additions and 140 deletions

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Group; use App\Models\Group;
use App\Models\ContentType; use App\Models\ContentType;
use App\Services\ArtworkEvolutionService; use App\Services\ArtworkEvolutionService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\GroupMembershipService; use App\Services\GroupMembershipService;
use App\Services\GroupService; use App\Services\GroupService;
use App\Services\Studio\CreatorStudioAnalyticsService; use App\Services\Studio\CreatorStudioAnalyticsService;
@@ -28,6 +29,7 @@ use App\Support\CoverUrl;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -74,7 +76,7 @@ final class StudioController extends Controller
public function content(Request $request): Response public function content(Request $request): Response
{ {
$prefs = $this->preferences->forUser($request->user()); $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']; $listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioContentIndex', [ return Inertia::render('Studio/StudioContentIndex', [
@@ -92,7 +94,7 @@ final class StudioController extends Controller
{ {
$provider = $this->content->provider('artworks'); $provider = $this->content->provider('artworks');
$prefs = $this->preferences->forUser($request->user()); $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']; $listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [ 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']) ->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
->findOrFail($id); ->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(); $primaryCategory = $artwork->categories->first();
$availableGroups = app(GroupService::class)->studioOptionsForUser($user); $availableGroups = app(GroupService::class)->studioOptionsForUser($user);
$membershipService = app(GroupMembershipService::class); $membershipService = app(GroupMembershipService::class);
@@ -455,11 +460,15 @@ final class StudioController extends Controller
'artwork_timezone' => $artwork->artwork_timezone, 'artwork_timezone' => $artwork->artwork_timezone,
'thumb_url' => $artwork->thumbUrl('md'), 'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'), 'thumb_url_lg' => $artwork->thumbUrl('lg'),
'download_url' => route('art.download', ['id' => $artwork->id]),
'file_name' => $artwork->file_name, 'file_name' => $artwork->file_name,
'file_ext' => $artwork->file_ext,
'file_size' => $artwork->file_size, 'file_size' => $artwork->file_size,
'width' => $artwork->width, 'width' => $artwork->width,
'height' => $artwork->height, 'height' => $artwork->height,
'mime_type' => $artwork->mime_type, 'mime_type' => $artwork->mime_type,
'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id),
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
'group_slug' => $artwork->group?->slug, 'group_slug' => $artwork->group?->slug,
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id), '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_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', 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();
}
} }

View File

@@ -9,6 +9,7 @@ use App\Models\Artwork;
use App\Models\Collection; use App\Models\Collection;
use App\Models\NovaCard; use App\Models\NovaCard;
use App\Models\Story; use App\Models\Story;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\CollectionLifecycleService; use App\Services\CollectionLifecycleService;
use App\Services\NovaCards\NovaCardPublishService; use App\Services\NovaCards\NovaCardPublishService;
use App\Services\StoryPublicationService; use App\Services\StoryPublicationService;
@@ -20,6 +21,7 @@ final class StudioScheduleApiController extends Controller
{ {
public function __construct( public function __construct(
private readonly CreatorStudioContentService $content, private readonly CreatorStudioContentService $content,
private readonly ArtworkPublicationService $artworkPublication,
private readonly NovaCardPublishService $cards, private readonly NovaCardPublishService $cards,
private readonly CollectionLifecycleService $collections, private readonly CollectionLifecycleService $collections,
private readonly StoryPublicationService $stories, private readonly StoryPublicationService $stories,
@@ -68,13 +70,7 @@ final class StudioScheduleApiController extends Controller
->where('user_id', $userId) ->where('user_id', $userId)
->findOrFail($id); ->findOrFail($id);
$artwork->forceFill([ $this->artworkPublication->publishNow($artwork);
'artwork_status' => 'published',
'publish_at' => null,
'artwork_timezone' => null,
'published_at' => now(),
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
])->save();
} }
private function unscheduleArtwork(int $userId, int $id): void private function unscheduleArtwork(int $userId, int $id): void

View File

@@ -26,6 +26,7 @@ class ArtworkVersion extends Model
'height', 'height',
'file_size', 'file_size',
'change_note', 'change_note',
'snapshot_json',
'is_current', 'is_current',
]; ];
@@ -35,6 +36,7 @@ class ArtworkVersion extends Model
'width' => 'integer', 'width' => 'integer',
'height' => 'integer', 'height' => 'integer',
'file_size' => 'integer', 'file_size' => 'integer',
'snapshot_json' => 'array',
]; ];
public function artwork(): BelongsTo public function artwork(): BelongsTo

View File

@@ -7,8 +7,6 @@ namespace App\Services;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkVersion; use App\Models\ArtworkVersion;
use App\Models\ArtworkVersionEvent; use App\Models\ArtworkVersionEvent;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; 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<string, mixed>, files: array<int, array<string, mixed>>}
*/
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<string, mixed> $snapshot
* @param array<string, mixed>|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<string, mixed> $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. * Create a new version for an artwork after a file replacement.
* *
@@ -67,63 +207,22 @@ final class ArtworkVersioningService
int $userId, int $userId,
?string $changeNote = null, ?string $changeNote = null,
): ArtworkVersion { ): ArtworkVersion {
// 1. Rate limit check $snapshot = [
$this->rateLimitCheck($userId, $artwork->id); 'artwork' => [
'file_name' => (string) ($artwork->file_name ?? 'artwork'),
// 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_path' => $filePath,
'file_hash' => $fileHash, '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, 'width' => $width,
'height' => $height, 'height' => $height,
'file_size' => $fileSize, ],
'change_note' => $changeNote, 'files' => [],
'is_current' => true, ];
]);
// 6. Check whether moderation re-review is required return $this->createVersionFromSnapshot($artwork, $snapshot, $userId, $changeNote);
$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;
});
} }
/** /**
@@ -139,15 +238,33 @@ final class ArtworkVersioningService
Artwork $artwork, Artwork $artwork,
int $userId, int $userId,
): ArtworkVersion { ): 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, $artwork,
$version->file_path, $snapshot,
$version->file_hash,
(int) $version->width,
(int) $version->height,
(int) $version->file_size,
$userId, $userId,
"Restored from version {$version->version_number}", "Restored from version {$version->version_number}",
$previousSnapshot,
); );
} }
@@ -170,6 +287,26 @@ final class ArtworkVersioningService
|| $heightChange > self::DIMENSION_CHANGE_THRESHOLD; || $heightChange > self::DIMENSION_CHANGE_THRESHOLD;
} }
/**
* @param array<string, mixed> $previousSnapshot
* @param array<string, mixed> $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. * Apply a small protective decay (7 %) to ranking and heat scores.
* *
@@ -245,4 +382,103 @@ final class ArtworkVersioningService
Cache::put($dayKey, 1, 86400); Cache::put($dayKey, 1, 86400);
} }
} }
/**
* @param array<string, mixed>|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<string, mixed> $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<string, mixed>|null $snapshot
* @return array{artwork: array<string, mixed>, files: array<int, array<string, mixed>>}
*/
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(),
];
}
} }

View File

@@ -53,6 +53,7 @@ final class CreatorStudioContentService
$bucket = $fixedBucket ?: $this->normalizeBucket((string) ($filters['bucket'] ?? 'all')); $bucket = $fixedBucket ?: $this->normalizeBucket((string) ($filters['bucket'] ?? 'all'));
$search = trim((string) ($filters['q'] ?? '')); $search = trim((string) ($filters['q'] ?? ''));
$sort = $this->normalizeSort((string) ($filters['sort'] ?? 'updated_desc')); $sort = $this->normalizeSort((string) ($filters['sort'] ?? 'updated_desc'));
$contentType = (string) ($filters['content_type'] ?? 'all');
$category = (string) ($filters['category'] ?? 'all'); $category = (string) ($filters['category'] ?? 'all');
$tag = trim((string) ($filters['tag'] ?? '')); $tag = trim((string) ($filters['tag'] ?? ''));
$visibility = (string) ($filters['visibility'] ?? 'all'); $visibility = (string) ($filters['visibility'] ?? 'all');
@@ -63,7 +64,7 @@ final class CreatorStudioContentService
$items = $module === 'all' $items = $module === 'all'
? SupportCollection::make($this->providers())->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, $this->providerBucket($bucket), 200)) ? 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') { if ($bucket === 'featured') {
$items = $items->filter(fn (array $item): bool => (bool) ($item['featured'] ?? false)); $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') { if ($module === 'artworks' && $category !== 'all') {
$items = $items->filter(function (array $item) use ($category): bool { $items = $items->filter(function (array $item) use ($category): bool {
return SupportCollection::make($item['taxonomies']['categories'] ?? []) return SupportCollection::make($item['taxonomies']['categories'] ?? [])
@@ -141,6 +155,7 @@ final class CreatorStudioContentService
'bucket' => $bucket, 'bucket' => $bucket,
'q' => $search, 'q' => $search,
'sort' => $sort, 'sort' => $sort,
'content_type' => $contentType,
'category' => $category, 'category' => $category,
'tag' => $tag, 'tag' => $tag,
'visibility' => $visibility, 'visibility' => $visibility,
@@ -174,12 +189,13 @@ final class CreatorStudioContentService
['value' => 'title_asc', 'label' => 'Title A-Z'], ['value' => 'title_asc', 'label' => 'Title A-Z'],
], ],
'advanced_filters' => $this->advancedFilters($module, $items, [ 'advanced_filters' => $this->advancedFilters($module, $items, [
'content_type' => $contentType,
'category' => $category, 'category' => $category,
'tag' => $tag, 'tag' => $tag,
'visibility' => $visibility, 'visibility' => $visibility,
'activity_state' => $activityState, 'activity_state' => $activityState,
'stale' => $stale, 'stale' => $stale,
]), ], $artworkFilterItems),
]; ];
} }
@@ -323,24 +339,54 @@ final class CreatorStudioContentService
/** /**
* @param array<string, string> $currentFilters * @param array<string, string> $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) { return match ($module) {
'artworks' => [ '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', 'key' => 'category',
'label' => 'Category', 'label' => 'Category',
'type' => 'select', 'type' => 'select',
'value' => $currentFilters['category'] ?? 'all', 'value' => $currentFilters['category'] ?? 'all',
'options' => array_merge([ 'options' => array_merge([
['value' => 'all', 'label' => 'All categories'], ['value' => 'all', 'label' => 'All categories', 'content_type_slug' => 'all'],
], $items ], $categoryItems
->flatMap(fn (array $item) => $item['taxonomies']['categories'] ?? []) ->flatMap(fn (array $item) => $item['taxonomies']['categories'] ?? [])
->unique('slug') ->unique('slug')
->sortBy('name') ->sortBy('name')
->map(fn (array $entry): array => [ ->map(fn (array $entry): array => [
'value' => (string) ($entry['slug'] ?? ''), 'value' => (string) ($entry['slug'] ?? ''),
'label' => (string) ($entry['name'] ?? 'Category'), 'label' => (string) ($entry['name'] ?? 'Category'),
'content_type_slug' => (string) ($entry['content_type_slug'] ?? 'all'),
])->values()->all()), ])->values()->all()),
], ],
[ [
@@ -350,7 +396,8 @@ final class CreatorStudioContentService
'value' => $currentFilters['tag'] ?? '', 'value' => $currentFilters['tag'] ?? '',
'placeholder' => 'Filter by tag', 'placeholder' => 'Filter by tag',
], ],
], ];
})(),
'collections' => [[ 'collections' => [[
'key' => 'visibility', 'key' => 'visibility',
'label' => 'Visibility', 'label' => 'Visibility',

View File

@@ -7,6 +7,7 @@ namespace App\Services\Studio\Providers;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkStats; use App\Models\ArtworkStats;
use App\Models\User; use App\Models\User;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\Studio\Contracts\CreatorStudioProvider; use App\Services\Studio\Contracts\CreatorStudioProvider;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -14,6 +15,10 @@ use Illuminate\Support\Facades\DB;
final class ArtworkStudioProvider implements CreatorStudioProvider final class ArtworkStudioProvider implements CreatorStudioProvider
{ {
public function __construct(
private readonly ArtworkPublicationService $publicationService,
) {}
public function key(): string public function key(): string
{ {
return 'artworks'; return 'artworks';
@@ -41,6 +46,8 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
public function summary(User $user): array public function summary(User $user): array
{ {
$this->publicationService->publishDueScheduledForUser((int) $user->id);
$baseQuery = Artwork::query()->withTrashed()->where('user_id', $user->id); $baseQuery = Artwork::query()->withTrashed()->where('user_id', $user->id);
$count = (clone $baseQuery) $count = (clone $baseQuery)
@@ -90,12 +97,15 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection public function items(User $user, string $bucket = 'all', int $limit = 200): Collection
{ {
$this->publicationService->publishDueScheduledForUser((int) $user->id);
$query = Artwork::query() $query = Artwork::query()
->withTrashed() ->withTrashed()
->where('user_id', $user->id) ->where('user_id', $user->id)
->with([ ->with([
'stats', 'stats',
'categories', 'categories',
'categories.contentType',
'tags', 'tags',
'features' => function ($query): void { 'features' => function ($query): void {
$query->where('is_active', true) $query->where('is_active', true)
@@ -106,8 +116,11 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
}); });
}, },
]) ])
->orderByDesc('updated_at') ->orderByDesc('updated_at');
->limit($limit);
if ($limit > 0) {
$query->limit($limit);
}
if ($bucket === 'drafts') { if ($bucket === 'drafts') {
$query->whereNull('deleted_at') $query->whereNull('deleted_at')
@@ -134,6 +147,8 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
public function topItems(User $user, int $limit = 5): Collection public function topItems(User $user, int $limit = 5): Collection
{ {
$this->publicationService->publishDueScheduledForUser((int) $user->id);
return Artwork::query() return Artwork::query()
->where('user_id', $user->id) ->where('user_id', $user->id)
->whereNull('deleted_at') ->whereNull('deleted_at')
@@ -226,10 +241,21 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
+ ((int) ($stats?->comments_count ?? 0) * 3) + ((int) ($stats?->comments_count ?? 0) * 3)
+ ((int) ($stats?->shares_count ?? 0) * 2), + ((int) ($stats?->shares_count ?? 0) * 2),
'taxonomies' => [ '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 => [ 'categories' => $artwork->categories->map(fn ($entry): array => [
'id' => (int) $entry->id, 'id' => (int) $entry->id,
'name' => (string) $entry->name, 'name' => (string) $entry->name,
'slug' => (string) $entry->slug, 'slug' => (string) $entry->slug,
'content_type' => (string) ($entry->contentType?->name ?? ''),
'content_type_slug' => (string) ($entry->contentType?->slug ?? ''),
])->values()->all(), ])->values()->all(),
'tags' => $artwork->tags->map(fn ($entry): array => [ 'tags' => $artwork->tags->map(fn ($entry): array => [
'id' => (int) $entry->id, 'id' => (int) $entry->id,

View File

@@ -92,8 +92,11 @@ final class CardStudioProvider implements CreatorStudioProvider
->withTrashed() ->withTrashed()
->where('user_id', $user->id) ->where('user_id', $user->id)
->with(['category', 'tags']) ->with(['category', 'tags'])
->orderByDesc('updated_at') ->orderByDesc('updated_at');
->limit($limit);
if ($limit > 0) {
$query->limit($limit);
}
if ($bucket === 'drafts') { if ($bucket === 'drafts') {
$query->whereNull('deleted_at')->where('status', NovaCard::STATUS_DRAFT); $query->whereNull('deleted_at')->where('status', NovaCard::STATUS_DRAFT);

View File

@@ -101,8 +101,11 @@ final class CollectionStudioProvider implements CreatorStudioProvider
->withTrashed() ->withTrashed()
->where('user_id', $user->id) ->where('user_id', $user->id)
->with(['user.profile', 'coverArtwork']) ->with(['user.profile', 'coverArtwork'])
->orderByDesc('updated_at') ->orderByDesc('updated_at');
->limit($limit);
if ($limit > 0) {
$query->limit($limit);
}
if ($bucket === 'drafts') { if ($bucket === 'drafts') {
$query->whereNull('deleted_at') $query->whereNull('deleted_at')

View File

@@ -83,8 +83,11 @@ final class StoryStudioProvider implements CreatorStudioProvider
$query = Story::query() $query = Story::query()
->where('creator_id', $user->id) ->where('creator_id', $user->id)
->with(['tags']) ->with(['tags'])
->orderByDesc('updated_at') ->orderByDesc('updated_at');
->limit($limit);
if ($limit > 0) {
$query->limit($limit);
}
if ($bucket === 'drafts') { if ($bucket === 'drafts') {
$query->whereIn('status', ['draft', 'pending_review', 'rejected']); $query->whereIn('status', ['draft', 'pending_review', 'rejected']);

View File

@@ -45,6 +45,28 @@ function itemReadiness(item) {
return item?.workflow?.readiness ?? null 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.') { function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
if (Array.isArray(payload?.errors) && payload.errors.length > 0) { if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
return payload.errors[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 }) { function AdvancedFilterControl({ filter, onChange, value }) {
const controlValue = value ?? filter.value const controlValue = value ?? filter.value
@@ -337,6 +377,7 @@ export default function StudioContentBrowser({
q: '', q: '',
bucket: 'all', bucket: 'all',
sort: 'updated_desc', sort: 'updated_desc',
content_type: 'all',
category: 'all', category: 'all',
tag: '', tag: '',
}) })
@@ -362,6 +403,12 @@ export default function StudioContentBrowser({
const allVisibleSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id)) const allVisibleSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id))
const selectedOnPage = selectedIds.filter((id) => selectableIds.includes(id)) const selectedOnPage = selectedIds.filter((id) => selectableIds.includes(id))
const visibleTotal = Math.max(0, Number(meta.total || 0) - optimisticRemovedIds.length) 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 filterControlCount = 1 + (hideModuleFilter ? 0 : 1) + (hideBucketFilter ? 0 : 1) + 1 + advancedFilters.length + 1
const filterGridClass = filterControlCount <= 4 const filterGridClass = filterControlCount <= 4
? 'xl:grid-cols-4' ? 'xl:grid-cols-4'
@@ -396,10 +443,11 @@ export default function StudioContentBrowser({
q: filters.q || '', q: filters.q || '',
bucket: filters.bucket || 'all', bucket: filters.bucket || 'all',
sort: filters.sort || 'updated_desc', sort: filters.sort || 'updated_desc',
content_type: filters.content_type || 'all',
category: filters.category || 'all', category: filters.category || 'all',
tag: filters.tag || '', 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 updateQuery = (patch) => {
const next = { const next = {
@@ -439,10 +487,20 @@ export default function StudioContentBrowser({
} }
const setPendingFilter = (key, value) => { const setPendingFilter = (key, value) => {
setPendingFilters((current) => ({ setPendingFilters((current) => {
if (key === 'content_type') {
return {
...current,
content_type: value,
category: 'all',
}
}
return {
...current, ...current,
[key]: value, [key]: value,
})) }
})
} }
const submitSearch = () => { const submitSearch = () => {
@@ -450,6 +508,7 @@ export default function StudioContentBrowser({
q: pendingFilters.q, q: pendingFilters.q,
bucket: pendingFilters.bucket, bucket: pendingFilters.bucket,
sort: pendingFilters.sort, sort: pendingFilters.sort,
content_type: pendingFilters.content_type,
category: pendingFilters.category, category: pendingFilters.category,
tag: pendingFilters.tag, tag: pendingFilters.tag,
}) })
@@ -818,13 +877,16 @@ export default function StudioContentBrowser({
</select> </select>
</label> </label>
{advancedFilters.map((filter) => ( {advancedFilters.map((filter) => {
const resolvedFilter = materializeFilter(filter, pendingFilters)
return (
<AdvancedFilterControl <AdvancedFilterControl
key={filter.key} key={filter.key}
filter={filter} filter={resolvedFilter}
value={filter.key === 'category' || filter.key === 'tag' ? pendingFilters[filter.key] : undefined} value={filter.key === 'content_type' || filter.key === 'category' || filter.key === 'tag' ? pendingFilters[filter.key] : undefined}
onChange={(key, value) => { onChange={(key, value) => {
if (key === 'category' || key === 'tag') { if (key === 'content_type' || key === 'category' || key === 'tag') {
setPendingFilter(key, value) setPendingFilter(key, value)
return return
} }
@@ -832,7 +894,8 @@ export default function StudioContentBrowser({
updateQuery({ [key]: value }) updateQuery({ [key]: value })
}} }}
/> />
))} )
})}
<div className="flex items-end"> <div className="flex items-end">
<button <button
@@ -891,7 +954,7 @@ export default function StudioContentBrowser({
<p> <p>
Showing <span className="font-semibold text-white">{visibleItems.length}</span> of <span className="font-semibold text-white">{visibleTotal.toLocaleString()}</span> items Showing <span className="font-semibold text-white">{visibleItems.length}</span> of <span className="font-semibold text-white">{visibleTotal.toLocaleString()}</span> items
</p> </p>
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p> <p>Page {currentPage} of {lastPage}</p>
</div> </div>
{viewMode === 'table' && supportsArtworkBulk && ( {viewMode === 'table' && supportsArtworkBulk && (
@@ -1071,28 +1134,85 @@ export default function StudioContentBrowser({
</section> </section>
)} )}
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300"> <div className="rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-300">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">Creator Studio</div>
<p className="text-sm text-slate-400">
{visibleTotal > 0
? <>Showing <span className="font-semibold text-white">{rangeStart.toLocaleString()}-{rangeEnd.toLocaleString()}</span> of <span className="font-semibold text-white">{visibleTotal.toLocaleString()}</span></>
: 'No items to display'}
</p>
</div>
{lastPage > 1 && (
<nav aria-label="Studio pagination" className="flex flex-col gap-3 lg:items-end">
<div className="flex flex-wrap items-center gap-2">
<button <button
type="button" type="button"
disabled={(meta.current_page || 1) <= 1} disabled={currentPage <= 1}
onClick={() => updateQuery({ page: Math.max(1, (meta.current_page || 1) - 1) })} onClick={() => updateQuery({ page: 1 })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-40" className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-slate-300 transition hover:border-white/20 hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-40"
>
<i className="fa-solid fa-angles-left" />
<span className="hidden sm:inline">First</span>
</button>
<button
type="button"
disabled={currentPage <= 1}
onClick={() => updateQuery({ page: Math.max(1, currentPage - 1) })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-slate-300 transition hover:border-white/20 hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-40"
> >
<i className="fa-solid fa-arrow-left" /> <i className="fa-solid fa-arrow-left" />
Previous <span>Previous</span>
</button> </button>
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Creator Studio</span> <div className="flex flex-wrap items-center gap-1.5">
{paginationPages.map((page, index) => page === 'ellipsis' ? (
<span key={`ellipsis-${index}`} className="inline-flex h-10 min-w-[2.5rem] items-center justify-center text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
...
</span>
) : (
<button
key={page}
type="button"
aria-current={page === currentPage ? 'page' : undefined}
onClick={() => updateQuery({ page })}
className={`inline-flex h-10 min-w-[2.5rem] items-center justify-center rounded-2xl border px-3 text-sm font-semibold transition ${page === currentPage ? 'border-sky-400/30 bg-sky-300/15 text-white shadow-[0_10px_24px_rgba(14,165,233,0.16)]' : 'border-white/10 text-slate-300 hover:border-white/20 hover:bg-white/[0.04]'}`}
>
{page}
</button>
))}
</div>
<button <button
type="button" type="button"
disabled={(meta.current_page || 1) >= (meta.last_page || 1)} disabled={currentPage >= lastPage}
onClick={() => updateQuery({ page: (meta.current_page || 1) + 1 })} onClick={() => updateQuery({ page: currentPage + 1 })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-40" className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-slate-300 transition hover:border-white/20 hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-40"
> >
Next <span>Next</span>
<i className="fa-solid fa-arrow-right" /> <i className="fa-solid fa-arrow-right" />
</button> </button>
<button
type="button"
disabled={currentPage >= lastPage}
onClick={() => updateQuery({ page: lastPage })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-slate-300 transition hover:border-white/20 hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-40"
>
<span className="hidden sm:inline">Last</span>
<i className="fa-solid fa-angles-right" />
</button>
</div>
<p className="text-xs text-slate-500">
Jump by page number or use first/last for longer queues.
</p>
</nav>
)}
</div>
</div> </div>
<ConfirmDangerModal <ConfirmDangerModal

View File

@@ -91,6 +91,22 @@ Route::middleware(['web', 'throttle:social-read'])
->where('username', '[A-Za-z0-9_-]{3,20}') ->where('username', '[A-Za-z0-9_-]{3,20}')
->name('api.profile.journey'); ->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']) Route::middleware(['web', 'throttle:social-read'])
->get('comments', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'comments']) ->get('comments', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'comments'])
->name('api.social.comments.index'); ->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/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}/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}/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 // Versioning
Route::get('artworks/{id}/versions', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'versions'])->whereNumber('id')->name('artworks.versions'); 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'); Route::post('artworks/{id}/restore/{version_id}', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'restoreVersion'])->whereNumber('id')->whereNumber('version_id')->name('artworks.restoreVersion');

View File

@@ -3,6 +3,8 @@
use App\Models\User; use App\Models\User;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkStats; use App\Models\ArtworkStats;
use App\Models\Category;
use App\Models\ContentType;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia; 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')); ->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 () { test('studio drafts page loads', function () {
$this->get('/studio/artworks/drafts') $this->get('/studio/artworks/drafts')
->assertRedirect('/studio/drafts'); ->assertRedirect('/studio/drafts');