Improve creator studio browsing and versioning
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user