Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class BackfillArtworkPrimaryCategoryCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:backfill-primary-category
|
||||
{--dry-run : Report without writing (default unless --apply is passed)}
|
||||
{--report : Print category-count distribution and conflicts}
|
||||
{--apply : Write primary_category_id for artworks with exactly one category}
|
||||
{--force : Overwrite existing primary_category_id values}';
|
||||
|
||||
protected $description = 'Backfill artworks.primary_category_id from unique artwork_category memberships';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$apply = (bool) $this->option('apply');
|
||||
$force = (bool) $this->option('force');
|
||||
$report = (bool) $this->option('report') || ! $apply;
|
||||
|
||||
if ($report) {
|
||||
$this->printDistribution();
|
||||
}
|
||||
|
||||
$conflicts = $this->conflictArtworkIds();
|
||||
$zeros = $this->zeroCategoryArtworkIds();
|
||||
$eligible = $this->eligibleArtworkIds($force);
|
||||
|
||||
$this->info('Zero-category artworks: '.$zeros->count());
|
||||
$this->info('Single-category eligible for backfill: '.$eligible->count());
|
||||
$this->info('Multi-category conflicts (not auto-resolved): '.$conflicts->count());
|
||||
|
||||
if ($conflicts->isNotEmpty()) {
|
||||
$this->warn('Multi-category artwork IDs (first 50): '.$conflicts->take(50)->implode(', '));
|
||||
}
|
||||
|
||||
if (! $apply) {
|
||||
$this->comment('Dry run only. Re-run with --apply to write primary_category_id for single-category artworks.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach ($eligible->chunk(500) as $chunk) {
|
||||
$rows = DB::table('artwork_category')
|
||||
->select('artwork_id', 'category_id')
|
||||
->whereIn('artwork_id', $chunk->all())
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
foreach ($rows as $artworkId => $memberships) {
|
||||
if ($memberships->count() !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = Artwork::query()->where('id', (int) $artworkId);
|
||||
if (! $force) {
|
||||
$query->whereNull('primary_category_id');
|
||||
}
|
||||
|
||||
$updated += $query->update([
|
||||
'primary_category_id' => (int) $memberships->first()->category_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Updated {$updated} artwork(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function printDistribution(): void
|
||||
{
|
||||
$distribution = DB::query()
|
||||
->fromSub(function ($query): void {
|
||||
$query->from('artwork_category')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as category_count'))
|
||||
->groupBy('artwork_id');
|
||||
}, 'x')
|
||||
->select('category_count', DB::raw('COUNT(*) as artworks'))
|
||||
->groupBy('category_count')
|
||||
->orderBy('category_count')
|
||||
->get();
|
||||
|
||||
$this->table(['category_count', 'artworks'], $distribution->map(fn ($row): array => [
|
||||
(int) $row->category_count,
|
||||
(int) $row->artworks,
|
||||
])->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function eligibleArtworkIds(bool $force)
|
||||
{
|
||||
$query = DB::table('artwork_category')
|
||||
->select('artwork_id')
|
||||
->groupBy('artwork_id')
|
||||
->havingRaw('COUNT(*) = 1');
|
||||
|
||||
if (! $force) {
|
||||
$query->whereIn('artwork_id', Artwork::query()->whereNull('primary_category_id')->select('id'));
|
||||
}
|
||||
|
||||
return $query->pluck('artwork_id')->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function conflictArtworkIds()
|
||||
{
|
||||
return DB::table('artwork_category')
|
||||
->select('artwork_id')
|
||||
->groupBy('artwork_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->pluck('artwork_id')
|
||||
->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function zeroCategoryArtworkIds()
|
||||
{
|
||||
return Artwork::query()
|
||||
->whereDoesntHave('categories')
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Artworks\ArtworkCreateRequest;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Models\Category;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -29,9 +30,12 @@ class ArtworkController extends Controller
|
||||
$user = $request->user();
|
||||
$data = $request->validated();
|
||||
|
||||
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
|
||||
? (int) $data['category']
|
||||
: null;
|
||||
$categoryId = isset($data['primary_category_id']) && ctype_digit((string) $data['primary_category_id'])
|
||||
? (int) $data['primary_category_id']
|
||||
: (isset($data['category']) && ctype_digit((string) $data['category'])
|
||||
? (int) $data['category']
|
||||
: null);
|
||||
$secondaryCategoryIds = array_values(array_map('intval', $data['secondary_category_ids'] ?? []));
|
||||
|
||||
$result = $drafts->createDraft(
|
||||
$user,
|
||||
@@ -40,6 +44,7 @@ class ArtworkController extends Controller
|
||||
$categoryId,
|
||||
(bool) ($data['is_mature'] ?? false),
|
||||
$data['group'] ?? null,
|
||||
$secondaryCategoryIds,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -174,7 +174,7 @@ final class DiscoveryNegativeSignalController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void
|
||||
{
|
||||
@@ -182,10 +182,9 @@ final class DiscoveryNegativeSignalController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryId = DB::table('artwork_category')
|
||||
->where('artwork_id', $artworkId)
|
||||
->orderBy('category_id')
|
||||
->value('category_id');
|
||||
$categoryId = DB::table('artworks')
|
||||
->where('id', $artworkId)
|
||||
->value('primary_category_id');
|
||||
|
||||
DB::table('user_discovery_events')->insert([
|
||||
'event_id' => (string) Str::uuid(),
|
||||
|
||||
@@ -5,52 +5,58 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Uploads\UploadCancelRequest;
|
||||
use App\Http\Requests\Uploads\UploadChunkRequest;
|
||||
use App\Http\Requests\Uploads\UploadFinishRequest;
|
||||
use App\Http\Requests\Uploads\UploadInitRequest;
|
||||
use App\Http\Requests\Uploads\UploadChunkRequest;
|
||||
use App\Http\Requests\Uploads\UploadCancelRequest;
|
||||
use App\Http\Requests\Uploads\UploadStatusRequest;
|
||||
use App\Jobs\GenerateDerivativesJob;
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Jobs\GenerateDerivativesJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\Tag;
|
||||
use App\Notifications\NewArtworkReviewNotification;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use App\Services\Uploads\UploadChunkService;
|
||||
use App\Services\Uploads\UploadCancelService;
|
||||
use App\Services\Uploads\UploadAuditService;
|
||||
use App\Services\Uploads\UploadPipelineService;
|
||||
use App\Services\Uploads\UploadQuotaService;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Uploads\UploadSessionStatus;
|
||||
use App\Services\Uploads\UploadStatusService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use Carbon\Carbon;
|
||||
use App\Uploads\Jobs\VirusScanJob;
|
||||
use App\Uploads\Services\PublishService;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\ArtworkAttributionService;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\Moderation\ArtworkUploadPolicy;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use App\Services\Uploads\UploadAuditService;
|
||||
use App\Services\Uploads\UploadCancelService;
|
||||
use App\Services\Uploads\UploadChunkService;
|
||||
use App\Services\Uploads\UploadPipelineService;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Uploads\UploadQuotaService;
|
||||
use App\Services\Uploads\UploadSessionStatus;
|
||||
use App\Services\Uploads\UploadStatusService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Uploads\Exceptions\UploadNotFoundException;
|
||||
use App\Uploads\Exceptions\UploadOwnershipException;
|
||||
use App\Uploads\Exceptions\UploadPublishValidationException;
|
||||
use App\Uploads\Jobs\VirusScanJob;
|
||||
use App\Uploads\Services\ArchiveInspectorService;
|
||||
use App\Uploads\Services\DraftQuotaService;
|
||||
use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Uploads\Services\PublishService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
final class UploadController extends Controller
|
||||
{
|
||||
@@ -59,8 +65,7 @@ final class UploadController extends Controller
|
||||
UploadPipelineService $pipeline,
|
||||
UploadQuotaService $quota,
|
||||
UploadAuditService $audit
|
||||
)
|
||||
{
|
||||
) {
|
||||
$user = $request->user();
|
||||
|
||||
try {
|
||||
@@ -251,6 +256,7 @@ final class UploadController extends Controller
|
||||
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
|
||||
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
|
||||
}
|
||||
|
||||
return UploadSessionStatus::PROCESSED;
|
||||
});
|
||||
|
||||
@@ -305,7 +311,7 @@ final class UploadController extends Controller
|
||||
'realpath' => $chunkFile->getRealPath(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -333,7 +339,7 @@ final class UploadController extends Controller
|
||||
'total_bytes' => $result->totalBytes,
|
||||
'progress' => $result->progress,
|
||||
], Response::HTTP_OK);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
logger()->warning('Upload chunk failed', [
|
||||
'session_id' => (string) $request->input('session_id'),
|
||||
'error' => $e->getMessage(),
|
||||
@@ -383,7 +389,7 @@ final class UploadController extends Controller
|
||||
'session_id' => $result['session_id'],
|
||||
'status' => $result['status'],
|
||||
], Response::HTTP_OK);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
logger()->warning('Upload cancel failed', [
|
||||
'session_id' => (string) $request->input('session_id'),
|
||||
'error' => $e->getMessage(),
|
||||
@@ -512,6 +518,7 @@ final class UploadController extends Controller
|
||||
return response()->json($response, Response::HTTP_OK);
|
||||
} catch (Throwable $e) {
|
||||
logger()->error('Upload preload failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -538,6 +545,8 @@ final class UploadController extends Controller
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
|
||||
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'tags' => ['nullable', 'array'],
|
||||
'license' => ['nullable', 'string'],
|
||||
@@ -547,7 +556,7 @@ final class UploadController extends Controller
|
||||
$this->ensureValidArtworkDescription($validated);
|
||||
|
||||
$updates = [];
|
||||
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
|
||||
foreach (['title', 'category_id', 'secondary_category_ids', 'description', 'tags', 'license', 'nsfw'] as $field) {
|
||||
if (array_key_exists($field, $validated)) {
|
||||
$updates[$field] = $validated[$field];
|
||||
}
|
||||
@@ -557,7 +566,7 @@ final class UploadController extends Controller
|
||||
foreach ($updates as $field => $value) {
|
||||
$current = $upload->{$field} ?? null;
|
||||
|
||||
if ($field === 'tags') {
|
||||
if ($field === 'tags' || $field === 'secondary_category_ids') {
|
||||
$current = $current ? json_decode((string) $current, true) : null;
|
||||
}
|
||||
|
||||
@@ -575,6 +584,10 @@ final class UploadController extends Controller
|
||||
$dirty['tags'] = json_encode($dirty['tags']);
|
||||
}
|
||||
|
||||
if (array_key_exists('secondary_category_ids', $dirty)) {
|
||||
$dirty['secondary_category_ids'] = json_encode($dirty['secondary_category_ids']);
|
||||
}
|
||||
|
||||
if (! empty($dirty)) {
|
||||
$dirty['updated_at'] = now();
|
||||
DB::table('uploads')->where('id', $id)->update($dirty);
|
||||
@@ -616,23 +629,26 @@ final class UploadController extends Controller
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
|
||||
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
// Scheduled-publishing fields
|
||||
'mode' => ['nullable', 'string', 'in:now,schedule'],
|
||||
'publish_at' => ['nullable', 'string', 'date'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'mode' => ['nullable', 'string', 'in:now,schedule'],
|
||||
'publish_at' => ['nullable', 'string', 'date'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'group' => ['nullable', 'string', 'max:90'],
|
||||
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
|
||||
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
|
||||
@@ -649,21 +665,21 @@ final class UploadController extends Controller
|
||||
|
||||
$this->ensureValidArtworkDescription($validated);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
$visibility = $validated['visibility'] ?? 'public';
|
||||
|
||||
// Resolve the UTC publish_at datetime for schedule mode
|
||||
$publishAt = null;
|
||||
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
|
||||
try {
|
||||
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
|
||||
$publishAt = Carbon::parse($validated['publish_at'])->utc();
|
||||
// Must be at least 1 minute in the future (server-side guard)
|
||||
if ($publishAt->lte(now()->addMinute())) {
|
||||
return response()->json([
|
||||
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
@@ -696,22 +712,25 @@ final class UploadController extends Controller
|
||||
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
|
||||
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
|
||||
}
|
||||
$artwork->slug = Str::limit($slugBase, 160, '');
|
||||
$artwork->slug = Str::limit($slugBase, 160, '');
|
||||
$artwork->artwork_timezone = $validated['timezone'] ?? null;
|
||||
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id;
|
||||
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id;
|
||||
|
||||
// Sync category if provided
|
||||
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
|
||||
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
$categoryId = isset($validated['primary_category_id'])
|
||||
? (int) $validated['primary_category_id']
|
||||
: (isset($validated['category']) ? (int) $validated['category'] : null);
|
||||
$secondaryCategoryIds = array_values(array_map('intval', $validated['secondary_category_ids'] ?? []));
|
||||
if ($categoryId) {
|
||||
app(ArtworkCategoryService::class)
|
||||
->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false);
|
||||
}
|
||||
|
||||
// Sync tags if provided
|
||||
if (!empty($validated['tags']) && is_array($validated['tags'])) {
|
||||
if (! empty($validated['tags']) && is_array($validated['tags'])) {
|
||||
$tagIds = [];
|
||||
foreach ($validated['tags'] as $tagSlug) {
|
||||
$tag = \App\Models\Tag::firstOrCreate(
|
||||
$tag = Tag::firstOrCreate(
|
||||
['slug' => Str::slug($tagSlug)],
|
||||
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
|
||||
);
|
||||
@@ -727,56 +746,95 @@ final class UploadController extends Controller
|
||||
|
||||
if ($mode === 'schedule' && $publishAt) {
|
||||
// Scheduled: store publish_at but don't make public yet
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->publish_at = $publishAt;
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->publish_at = $publishAt;
|
||||
$artwork->artwork_status = 'scheduled';
|
||||
$artwork->published_at = null;
|
||||
$artwork->published_at = null;
|
||||
$artwork->save();
|
||||
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'scheduled',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'publish_at' => $publishAt->toISOString(),
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'scheduled',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'publish_at' => $publishAt->toISOString(),
|
||||
'published_at' => null,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
// Publish immediately
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = ($visibility !== 'private');
|
||||
$artwork->is_approved = true;
|
||||
$artwork->published_at = now();
|
||||
// New and suspicious accounts are quarantined. Never let the client
|
||||
// decide that an artwork is approved.
|
||||
$policy = $uploadPolicy->assess($user, $artwork);
|
||||
if ($policy['requires_review']) {
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = false;
|
||||
$artwork->approval_source = null;
|
||||
$artwork->artwork_status = 'review';
|
||||
$artwork->published_at = null;
|
||||
$artwork->publish_at = null;
|
||||
$artwork->moderated_at = null;
|
||||
$artwork->moderated_by = null;
|
||||
$artwork->moderation_note = implode(', ', $policy['reasons']);
|
||||
$artwork->save();
|
||||
|
||||
$notifyEmail = trim((string) config('uploads.moderation.notify_email', ''));
|
||||
if ($notifyEmail !== '') {
|
||||
try {
|
||||
Notification::route('mail', $notifyEmail)->notify(
|
||||
new NewArtworkReviewNotification($artwork, $user, $policy['reasons'])
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// Email failure must not expose the artwork.
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'submitted_for_review',
|
||||
'message' => 'Upload received and queued for moderation.',
|
||||
'review_reasons' => $policy['reasons'],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
// Publish immediately for trusted users only.
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = ($visibility !== 'private');
|
||||
$artwork->is_approved = true;
|
||||
$artwork->approval_source = 'trusted_auto';
|
||||
$artwork->published_at = now();
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->publish_at = null;
|
||||
$artwork->publish_at = null;
|
||||
$artwork->save();
|
||||
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
// Record upload activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: (int) $user->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
ActivityEvent::record(
|
||||
actorId: (int) $user->id,
|
||||
type: ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id);
|
||||
} catch (\Throwable) {}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'published',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'published',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'published_at' => optional($artwork->published_at)->toISOString(),
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
@@ -785,11 +843,11 @@ final class UploadController extends Controller
|
||||
$upload = $publishService->publish($id, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'upload_id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'success' => true,
|
||||
'upload_id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'published_at' => optional($upload->published_at)->toISOString(),
|
||||
'final_path' => (string) ($upload->final_path ?? ''),
|
||||
'final_path' => (string) ($upload->final_path ?? ''),
|
||||
], Response::HTTP_OK);
|
||||
} catch (UploadOwnershipException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
|
||||
@@ -808,7 +866,10 @@ final class UploadController extends Controller
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
|
||||
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
|
||||
use App\Http\Requests\Manage\ManageArtworkEditRequest;
|
||||
use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
|
||||
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ManageController extends Controller
|
||||
{
|
||||
@@ -17,14 +19,12 @@ class ManageController extends Controller
|
||||
$userId = $request->user()->id;
|
||||
$perPage = 50;
|
||||
|
||||
$categorySub = DB::table('artwork_category as ac')
|
||||
->join('categories as c', 'ac.category_id', '=', 'c.id')
|
||||
->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name'))
|
||||
->groupBy('ac.artwork_id');
|
||||
$categorySub = DB::table('categories as c')
|
||||
->select('c.id as category_id', 'c.name as category_name');
|
||||
|
||||
$query = DB::table('artworks as a')
|
||||
->leftJoinSub($categorySub, 'cat', function ($join) {
|
||||
$join->on('a.id', '=', 'cat.artwork_id');
|
||||
$join->on('a.primary_category_id', '=', 'cat.category_id');
|
||||
})
|
||||
->leftJoin('artwork_stats as s', 'a.id', '=', 's.artwork_id')
|
||||
->where('a.user_id', $userId)
|
||||
@@ -52,8 +52,8 @@ class ManageController extends Controller
|
||||
{
|
||||
$artwork = $request->artwork();
|
||||
|
||||
$selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id');
|
||||
$artwork->category = $selectedCategory;
|
||||
$selectedCategory = (int) ($artwork->primary_category_id ?: DB::table('artwork_category')->where('artwork_id', (int) $id)->value('category_id'));
|
||||
$artwork->category = $selectedCategory ?: null;
|
||||
|
||||
$categories = DB::table('categories')
|
||||
->where('content_type_id', 0)
|
||||
@@ -64,7 +64,7 @@ class ManageController extends Controller
|
||||
return view('manage.edit', [
|
||||
'artwork' => $artwork,
|
||||
'categories' => $categories,
|
||||
'page_title' => 'Edit Artwork: ' . ($artwork->title ?? ''),
|
||||
'page_title' => 'Edit Artwork: '.($artwork->title ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -91,14 +91,14 @@ class ManageController extends Controller
|
||||
$update['fname'] = basename($attPath);
|
||||
}
|
||||
|
||||
DB::table('artworks')->where('id', (int)$id)->update($update);
|
||||
DB::table('artworks')->where('id', (int) $id)->update($update);
|
||||
|
||||
if (isset($data['section'])) {
|
||||
DB::table('artwork_category')->where('artwork_id', (int)$id)->delete();
|
||||
DB::table('artwork_category')->insert([
|
||||
'artwork_id' => (int)$id,
|
||||
'category_id' => (int)$data['section'],
|
||||
]);
|
||||
$model = Artwork::query()->find((int) $id);
|
||||
if ($model) {
|
||||
app(ArtworkCategoryService::class)
|
||||
->sync($model, (int) $data['section'], [], 'moderator');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
|
||||
@@ -108,14 +108,14 @@ class ManageController extends Controller
|
||||
{
|
||||
$artwork = $request->artwork();
|
||||
|
||||
if (!empty($artwork->fname)) {
|
||||
Storage::delete('public/uploads/attachments/' . $artwork->fname);
|
||||
if (! empty($artwork->fname)) {
|
||||
Storage::delete('public/uploads/attachments/'.$artwork->fname);
|
||||
}
|
||||
if (!empty($artwork->picture)) {
|
||||
Storage::delete('public/uploads/artworks/' . $artwork->picture);
|
||||
if (! empty($artwork->picture)) {
|
||||
Storage::delete('public/uploads/artworks/'.$artwork->picture);
|
||||
}
|
||||
|
||||
DB::table('artworks')->where('id', (int)$id)->delete();
|
||||
DB::table('artworks')->where('id', (int) $id)->delete();
|
||||
|
||||
return redirect()->route('manage')->with('status', 'Artwork deleted.');
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkDownload;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TodayDownloadsController extends Controller
|
||||
{
|
||||
@@ -35,16 +36,16 @@ class TodayDownloadsController extends Controller
|
||||
$art = $row->artwork ?? null;
|
||||
// If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it
|
||||
if (! $art && isset($row->artwork_id)) {
|
||||
$art = \App\Models\Artwork::find($row->artwork_id);
|
||||
$art = Artwork::find($row->artwork_id);
|
||||
}
|
||||
|
||||
$name = $art->title ?? null;
|
||||
$picture = $art->file_name ?? null;
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null; // legacy encoding unavailable; leave null
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
$categoryId = $art->resolvedPrimaryCategory()?->id;
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id ?? null,
|
||||
|
||||
@@ -6,27 +6,34 @@ namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkVersion;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ArtworkVersion;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Notifications\NewArtworkReviewNotification;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Services\ArtworkAttributionService;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use App\Services\TagService;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkVersioningService;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\Moderation\ArtworkUploadPolicy;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use App\Services\TagService;
|
||||
use App\Services\Uploads\UploadDerivativesService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
@@ -45,6 +52,7 @@ final class StudioArtworksApiController extends Controller
|
||||
private readonly TagService $tagService,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
private readonly ArtworkPublicationService $artworkPublication,
|
||||
private readonly ArtworkCategoryService $artworkCategories,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -74,9 +82,9 @@ final class StudioArtworksApiController extends Controller
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -88,14 +96,14 @@ final class StudioArtworksApiController extends Controller
|
||||
public function bulk(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
|
||||
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
|
||||
'artwork_ids' => 'required|array|min:1|max:200',
|
||||
'artwork_ids.*' => 'integer',
|
||||
'params' => 'sometimes|array',
|
||||
'params' => 'sometimes|array',
|
||||
'params.category_id' => 'sometimes|integer|exists:categories,id',
|
||||
'params.tag_ids' => 'sometimes|array',
|
||||
'params.tag_ids.*' => 'integer|exists:tags,id',
|
||||
'confirm' => 'required_if:action,delete|string',
|
||||
'params.tag_ids' => 'sometimes|array',
|
||||
'params.tag_ids.*' => 'integer|exists:tags,id',
|
||||
'confirm' => 'required_if:action,delete|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -127,23 +135,26 @@ final class StudioArtworksApiController extends Controller
|
||||
* PUT /api/studio/artworks/{id}
|
||||
* Update artwork details (title, description, visibility).
|
||||
*/
|
||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse
|
||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$evolution = app(ArtworkEvolutionService::class);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'description' => 'sometimes|nullable|string|max:5000',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'visibility' => 'sometimes|string|in:public,unlisted,private',
|
||||
'mode' => 'sometimes|string|in:now,schedule',
|
||||
'publish_at' => 'sometimes|nullable|string|date',
|
||||
'timezone' => 'sometimes|nullable|string|max:64',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'visibility' => 'sometimes|string|in:public,unlisted,private',
|
||||
'mode' => 'sometimes|string|in:now,schedule',
|
||||
'publish_at' => 'sometimes|nullable|string|date',
|
||||
'timezone' => 'sometimes|nullable|string|max:64',
|
||||
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
||||
'primary_category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
||||
'secondary_category_ids' => 'sometimes|array|max:'.(int) config('categories.max_secondary_categories', 5),
|
||||
'secondary_category_ids.*' => 'integer|exists:categories,id',
|
||||
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
|
||||
'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30),
|
||||
'tags.*' => 'string|max:64',
|
||||
'tags' => 'sometimes|array|max:'.(int) config('tags.max_user_tags', 30),
|
||||
'tags.*' => 'string|max:64',
|
||||
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
||||
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
||||
'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
||||
@@ -210,14 +221,18 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
// Extract tags and category before updating core fields
|
||||
$tags = $validated['tags'] ?? null;
|
||||
$categoryId = $validated['category_id'] ?? null;
|
||||
$categoryId = $validated['primary_category_id'] ?? $validated['category_id'] ?? null;
|
||||
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated)
|
||||
? array_values(array_map('intval', $validated['secondary_category_ids'] ?? []))
|
||||
: [];
|
||||
$hasSecondaryCategoryUpdate = array_key_exists('secondary_category_ids', $validated);
|
||||
$contentTypeId = $validated['content_type_id'] ?? null;
|
||||
$evolutionPayload = [
|
||||
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
|
||||
'relation_type' => $validated['evolution_relation_type'] ?? null,
|
||||
'note' => $validated['evolution_note'] ?? null,
|
||||
];
|
||||
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
|
||||
unset($validated['tags'], $validated['category_id'], $validated['primary_category_id'], $validated['secondary_category_ids'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
|
||||
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
|
||||
|
||||
$validated['visibility'] = $visibility;
|
||||
@@ -231,9 +246,17 @@ final class StudioArtworksApiController extends Controller
|
||||
$validated['artwork_status'] = 'scheduled';
|
||||
} else {
|
||||
$validated['is_public'] = $visibility !== Artwork::VISIBILITY_PRIVATE;
|
||||
$validated['is_approved'] = true;
|
||||
$policy = $uploadPolicy->assess($request->user(), $artwork);
|
||||
$validated['is_approved'] = ! $policy['requires_review'];
|
||||
$validated['approval_source'] = $policy['requires_review'] ? null : 'trusted_auto';
|
||||
$validated['publish_at'] = null;
|
||||
$validated['artwork_status'] = 'published';
|
||||
$validated['artwork_status'] = $policy['requires_review'] ? 'review' : 'published';
|
||||
|
||||
if ($policy['requires_review']) {
|
||||
$validated['is_public'] = false;
|
||||
$validated['published_at'] = null;
|
||||
$validated['moderation_note'] = implode(', ', $policy['reasons']);
|
||||
}
|
||||
|
||||
if (($validated['is_public'] ?? false) && ! $artwork->published_at) {
|
||||
$validated['published_at'] = now();
|
||||
@@ -249,10 +272,19 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
$artwork->update($validated);
|
||||
$requiresReview = ($policy['requires_review'] ?? false) === true;
|
||||
|
||||
// Sync category
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([(int) $categoryId]);
|
||||
if ($categoryId !== null || $hasSecondaryCategoryUpdate) {
|
||||
try {
|
||||
$this->artworkCategories->sync(
|
||||
$artwork,
|
||||
$categoryId !== null ? (int) $categoryId : (int) $artwork->primary_category_id,
|
||||
$hasSecondaryCategoryUpdate ? $secondaryCategoryIds : ($categoryId !== null ? [] : null),
|
||||
(string) ($validated['category_source'] ?? $artwork->category_source ?? 'manual'),
|
||||
);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->json(['errors' => $exception->errors()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync tags through the shared tag service so pivot source/usage rules stay valid.
|
||||
@@ -291,24 +323,40 @@ final class StudioArtworksApiController extends Controller
|
||||
// Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
|
||||
$this->searchIndexer->update($artwork);
|
||||
|
||||
if ($requiresReview) {
|
||||
$notifyEmail = trim((string) config('uploads.moderation.notify_email', ''));
|
||||
if ($notifyEmail !== '') {
|
||||
try {
|
||||
Notification::route('mail', $notifyEmail)->notify(
|
||||
new NewArtworkReviewNotification($artwork, $request->user(), $policy['reasons'])
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Keep the artwork private if notification delivery fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload relationships for response
|
||||
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$artwork->load(['categories.contentType', 'primaryCategory.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'status' => $requiresReview ? 'submitted_for_review' : 'published',
|
||||
'message' => $requiresReview ? 'Artwork saved and queued for moderation.' : 'Artwork updated successfully.',
|
||||
'review_reasons' => $requiresReview ? $policy['reasons'] : [],
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
|
||||
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
|
||||
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'artwork_timezone' => $artwork->artwork_timezone,
|
||||
'slug' => $artwork->slug,
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'slug' => $artwork->slug,
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
|
||||
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($contributorId): int => (int) $contributorId)->values()->all(),
|
||||
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
|
||||
@@ -317,11 +365,18 @@ final class StudioArtworksApiController extends Controller
|
||||
'is_primary' => (bool) $contributor->is_primary,
|
||||
])->values()->all(),
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
'title_source' => $artwork->title_source ?: 'manual',
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'primary_category_id' => $primaryCategory?->id,
|
||||
'secondary_category_ids' => $artwork->categories
|
||||
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
'title_source' => $artwork->title_source ?: 'manual',
|
||||
'description_source' => $artwork->description_source ?: 'manual',
|
||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||
'category_source' => $artwork->category_source ?: 'manual',
|
||||
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
|
||||
],
|
||||
@@ -423,18 +478,18 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'slug' => $artwork->slug,
|
||||
],
|
||||
'analytics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
|
||||
],
|
||||
]);
|
||||
@@ -449,28 +504,28 @@ final class StudioArtworksApiController extends Controller
|
||||
$stats = $artwork->stats ?? null;
|
||||
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'created_at' => $artwork->created_at?->toIso8601String(),
|
||||
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
|
||||
'category' => $artwork->categories->first()?->name,
|
||||
'category_slug' => $artwork->categories->first()?->slug,
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'created_at' => $artwork->created_at?->toIso8601String(),
|
||||
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
|
||||
'category' => $artwork->resolvedPrimaryCategory()?->name,
|
||||
'category_slug' => $artwork->resolvedPrimaryCategory()?->slug,
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -499,7 +554,7 @@ final class StudioArtworksApiController extends Controller
|
||||
$previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork);
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
|
||||
'change_note' => 'sometimes|nullable|string|max:500',
|
||||
]);
|
||||
|
||||
@@ -510,43 +565,43 @@ final class StudioArtworksApiController extends Controller
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getRealPath();
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
|
||||
// Reject identical files early (before any disk writes)
|
||||
if ($artwork->hash === $hash) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'The uploaded file is identical to the current version.',
|
||||
'error' => 'The uploaded file is identical to the current version.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
|
||||
$derivatives = app(UploadDerivativesService::class);
|
||||
$storage = app(UploadStorageService::class);
|
||||
$artworkFiles = app(ArtworkFileRepository::class);
|
||||
|
||||
// 1. Store original on disk (preserve extension when possible)
|
||||
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$originalPath = $originalAsset['local_path'];
|
||||
$origFilename = basename($originalPath);
|
||||
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$originalPath = $originalAsset['local_path'];
|
||||
$origFilename = basename($originalPath);
|
||||
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
|
||||
|
||||
// 2. Generate thumbnails (xs/sm/md/lg/xl)
|
||||
$publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
||||
foreach ($publicAssets as $variant => $asset) {
|
||||
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
|
||||
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash.'.webp');
|
||||
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
|
||||
}
|
||||
|
||||
// 3. Get new dimensions
|
||||
$dims = @getimagesize($tempPath);
|
||||
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
|
||||
$dims = @getimagesize($tempPath);
|
||||
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
|
||||
$height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
|
||||
$size = (int) filesize($originalPath);
|
||||
$size = (int) filesize($originalPath);
|
||||
|
||||
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
|
||||
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
|
||||
@@ -559,7 +614,7 @@ final class StudioArtworksApiController extends Controller
|
||||
if ($clientName !== '') {
|
||||
$clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION));
|
||||
if ($clientExt === '' && $origExt !== '') {
|
||||
$clientName .= '.' . $origExt;
|
||||
$clientName .= '.'.$origExt;
|
||||
}
|
||||
|
||||
$displayFileName = $clientName;
|
||||
@@ -570,11 +625,11 @@ final class StudioArtworksApiController extends Controller
|
||||
'file_path' => '',
|
||||
'file_size' => $size,
|
||||
'mime_type' => $origMime,
|
||||
'hash' => $hash,
|
||||
'file_ext' => $origExt,
|
||||
'hash' => $hash,
|
||||
'file_ext' => $origExt,
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
]);
|
||||
|
||||
// 5. Create version record from the new full media snapshot.
|
||||
@@ -593,7 +648,7 @@ final class StudioArtworksApiController extends Controller
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -601,16 +656,17 @@ final class StudioArtworksApiController extends Controller
|
||||
$this->purgeCdnCache($artwork, $hash);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'success' => true,
|
||||
'version_number' => $version->version_number,
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
] + $this->mediaPayload($artwork));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('replaceFile: processing error', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'File processing failed: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,8 +734,8 @@ final class StudioArtworksApiController extends Controller
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
}
|
||||
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$derivatives = app(UploadDerivativesService::class);
|
||||
$storage = app(UploadStorageService::class);
|
||||
$cleanupLocalPaths = [];
|
||||
$cleanupObjectPaths = [];
|
||||
|
||||
@@ -766,7 +822,7 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
$allScreenshotDescriptors = [];
|
||||
foreach (array_values(array_merge($existingScreenshotDescriptors, $newScreenshotDescriptors)) as $index => $descriptor) {
|
||||
$descriptor['variant'] = 'shot' . ($index + 1);
|
||||
$descriptor['variant'] = 'shot'.($index + 1);
|
||||
$allScreenshotDescriptors[] = $descriptor;
|
||||
}
|
||||
|
||||
@@ -862,7 +918,7 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Revision update failed: ' . $exception->getMessage(),
|
||||
'error' => 'Revision update failed: '.$exception->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -873,27 +929,27 @@ final class StudioArtworksApiController extends Controller
|
||||
*/
|
||||
public function versions(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
],
|
||||
'versions' => $versions->map(fn (ArtworkVersion $v) => [
|
||||
'id' => $v->id,
|
||||
'id' => $v->id,
|
||||
'version_number' => $v->version_number,
|
||||
'file_path' => $v->file_path,
|
||||
'file_hash' => $v->file_hash,
|
||||
'width' => $v->width,
|
||||
'height' => $v->height,
|
||||
'file_size' => $v->file_size,
|
||||
'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''),
|
||||
'change_note' => $v->change_note,
|
||||
'is_current' => $v->is_current,
|
||||
'created_at' => $v->created_at?->toIso8601String(),
|
||||
'file_path' => $v->file_path,
|
||||
'file_hash' => $v->file_hash,
|
||||
'width' => $v->width,
|
||||
'height' => $v->height,
|
||||
'file_size' => $v->file_size,
|
||||
'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''),
|
||||
'change_note' => $v->change_note,
|
||||
'is_current' => $v->is_current,
|
||||
'created_at' => $v->created_at?->toIso8601String(),
|
||||
])->values(),
|
||||
]);
|
||||
}
|
||||
@@ -918,17 +974,18 @@ final class StudioArtworksApiController extends Controller
|
||||
// Reindex
|
||||
try {
|
||||
$this->searchIndexer->update($artwork);
|
||||
} catch (\Throwable) {}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'success' => true,
|
||||
'version_number' => $newVersion->version_number,
|
||||
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
|
||||
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
|
||||
] + $this->mediaPayload($artwork));
|
||||
} catch (TooManyRequestsHttpException $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
|
||||
return response()->json(['success' => false, 'error' => 'Restore failed: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,7 +995,7 @@ final class StudioArtworksApiController extends Controller
|
||||
* This is best-effort; failures are logged but never fatal.
|
||||
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
|
||||
*/
|
||||
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
|
||||
private function purgeCdnCache(Artwork $artwork, string $oldHash): void
|
||||
{
|
||||
try {
|
||||
$this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [
|
||||
@@ -951,7 +1008,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function snapshotFilesByVariant(array $snapshot): array
|
||||
@@ -963,7 +1020,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, array{path: string, mime: string, size: int}>
|
||||
*/
|
||||
private function currentPublicAssetsFromSnapshot(array $snapshot): array
|
||||
@@ -981,7 +1038,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $file
|
||||
* @param array<string, mixed>|null $file
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function existingVariantDescriptor(?array $file, string $fallbackVariant): ?array
|
||||
@@ -1002,7 +1059,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $stored
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function storedOriginalDescriptor(array $stored, string $variant): array
|
||||
@@ -1028,7 +1085,7 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
|
||||
if ($candidateExt === '' && $ext !== '') {
|
||||
$candidate .= '.' . ltrim($ext, '.');
|
||||
$candidate .= '.'.ltrim($ext, '.');
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
@@ -1058,7 +1115,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function screenshotAssetsFromSnapshot(array $snapshot): array
|
||||
@@ -1070,11 +1127,11 @@ final class StudioArtworksApiController extends Controller
|
||||
->values()
|
||||
->map(function (array $file, int $index) use ($base): array {
|
||||
$path = trim((string) ($file['path'] ?? ''), '/');
|
||||
$url = $base . '/' . $path;
|
||||
$url = $base.'/'.$path;
|
||||
|
||||
return [
|
||||
'id' => (string) ($file['variant'] ?? ('shot' . ($index + 1))),
|
||||
'label' => 'Screenshot ' . ($index + 1),
|
||||
'id' => (string) ($file['variant'] ?? ('shot'.($index + 1))),
|
||||
'label' => 'Screenshot '.($index + 1),
|
||||
'url' => $url,
|
||||
'thumb_url' => $url,
|
||||
'mime_type' => (string) ($file['mime'] ?? 'image/jpeg'),
|
||||
|
||||
@@ -5,26 +5,27 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Group;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Group;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use App\Services\GroupMembershipService;
|
||||
use App\Services\GroupService;
|
||||
use App\Services\Studio\CreatorStudioActivityService;
|
||||
use App\Services\Studio\CreatorStudioAnalyticsService;
|
||||
use App\Services\Studio\CreatorStudioAssetService;
|
||||
use App\Services\Studio\CreatorStudioCalendarService;
|
||||
use App\Services\Studio\CreatorStudioChallengeService;
|
||||
use App\Services\Studio\CreatorStudioCommentService;
|
||||
use App\Services\Studio\CreatorStudioContentService;
|
||||
use App\Services\Studio\CreatorStudioFollowersService;
|
||||
use App\Services\Studio\CreatorStudioGrowthService;
|
||||
use App\Services\Studio\CreatorStudioActivityService;
|
||||
use App\Services\Studio\CreatorStudioInboxService;
|
||||
use App\Services\Studio\CreatorStudioOverviewService;
|
||||
use App\Services\Studio\CreatorStudioPreferenceService;
|
||||
use App\Services\Studio\CreatorStudioChallengeService;
|
||||
use App\Services\Studio\CreatorStudioSearchService;
|
||||
use App\Services\Studio\CreatorStudioScheduledService;
|
||||
use App\Services\Studio\CreatorStudioSearchService;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\CoverUrl;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -132,7 +133,7 @@ final class StudioController extends Controller
|
||||
|
||||
public function uploadQueue(Request $request): Response
|
||||
{
|
||||
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
|
||||
$queue = app(UploadQueueService::class)->listPayload(
|
||||
$request->user(),
|
||||
$request->only(['batch_id', 'status', 'sort'])
|
||||
);
|
||||
@@ -361,7 +362,7 @@ final class StudioController extends Controller
|
||||
'cover_url' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()) : null,
|
||||
'cover_position' => (int) ($user->cover_position ?? 50),
|
||||
'followers' => (int) ($user->statistics?->followers_count ?? 0),
|
||||
'profile_url' => '/@' . strtolower((string) $user->username),
|
||||
'profile_url' => '/@'.strtolower((string) $user->username),
|
||||
'social_links' => $socialLinks,
|
||||
],
|
||||
'moduleSummaries' => $this->content->moduleSummaries($user),
|
||||
@@ -449,13 +450,13 @@ final class StudioController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = $user->artworks()
|
||||
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
|
||||
->with(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
|
||||
->findOrFail($id);
|
||||
|
||||
$artwork = app(ArtworkPublicationService::class)->publishIfDue($artwork);
|
||||
$artwork->loadMissing(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
$artwork->loadMissing(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
$availableGroups = app(GroupService::class)->studioOptionsForUser($user);
|
||||
$membershipService = app(GroupMembershipService::class);
|
||||
$contributorOptionsByGroup = [];
|
||||
@@ -470,30 +471,30 @@ final class StudioController extends Controller
|
||||
}
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkEdit', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'artwork_timezone' => $artwork->artwork_timezone,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'download_url' => route('art.download', ['id' => $artwork->id]),
|
||||
'file_name' => $artwork->file_name,
|
||||
'file_ext' => $artwork->file_ext,
|
||||
'file_size' => $artwork->file_size,
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'mime_type' => $artwork->mime_type,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'download_url' => route('art.download', ['id' => $artwork->id]),
|
||||
'file_name' => $artwork->file_name,
|
||||
'file_ext' => $artwork->file_ext,
|
||||
'file_size' => $artwork->file_size,
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'mime_type' => $artwork->mime_type,
|
||||
'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id),
|
||||
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
|
||||
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(),
|
||||
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
|
||||
@@ -502,19 +503,27 @@ final class StudioController extends Controller
|
||||
'is_primary' => (bool) $contributor->is_primary,
|
||||
])->values()->all(),
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'primary_category_id' => $primaryCategory?->id,
|
||||
'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id,
|
||||
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
|
||||
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
'ai_status' => $artwork->ai_status,
|
||||
'title_source' => $artwork->title_source ?: 'manual',
|
||||
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
|
||||
'secondary_category_ids' => $artwork->categories
|
||||
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
'max_secondary_categories' => (int) config('categories.max_secondary_categories', 5),
|
||||
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug, 'is_primary' => (int) $c->id === (int) ($primaryCategory?->id ?? 0)])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
'ai_status' => $artwork->ai_status,
|
||||
'title_source' => $artwork->title_source ?: 'manual',
|
||||
'description_source' => $artwork->description_source ?: 'manual',
|
||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||
'category_source' => $artwork->category_source ?: 'manual',
|
||||
'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user),
|
||||
// Versioning
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
],
|
||||
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
|
||||
@@ -538,19 +547,19 @@ final class StudioController extends Controller
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkAnalytics', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
],
|
||||
'analytics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
|
||||
],
|
||||
]);
|
||||
@@ -569,16 +578,16 @@ final class StudioController extends Controller
|
||||
$data = $this->analytics->overview($user, $rangeDays);
|
||||
|
||||
return Inertia::render('Studio/StudioAnalytics', [
|
||||
'totals' => $data['totals'],
|
||||
'topContent' => $data['top_content'],
|
||||
'moduleBreakdown' => $data['module_breakdown'],
|
||||
'viewsTrend' => $data['views_trend'],
|
||||
'engagementTrend' => $data['engagement_trend'],
|
||||
'totals' => $data['totals'],
|
||||
'topContent' => $data['top_content'],
|
||||
'moduleBreakdown' => $data['module_breakdown'],
|
||||
'viewsTrend' => $data['views_trend'],
|
||||
'engagementTrend' => $data['engagement_trend'],
|
||||
'publishingTimeline' => $data['publishing_timeline'],
|
||||
'comparison' => $data['comparison'],
|
||||
'insightBlocks' => $data['insight_blocks'],
|
||||
'rangeDays' => $data['range_days'],
|
||||
'recentComments' => $this->overview->recentComments($user, 8),
|
||||
'comparison' => $data['comparison'],
|
||||
'insightBlocks' => $data['insight_blocks'],
|
||||
'rangeDays' => $data['range_days'],
|
||||
'recentComments' => $this->overview->recentComments($user, 8),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -586,16 +595,16 @@ final class StudioController extends Controller
|
||||
{
|
||||
return ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) {
|
||||
return [
|
||||
'id' => $ct->id,
|
||||
'id' => $ct->id,
|
||||
'name' => $ct->name,
|
||||
'slug' => $ct->slug,
|
||||
'categories' => $ct->rootCategories->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'slug' => $c->slug,
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'slug' => $c->slug,
|
||||
'children' => $c->children->map(fn ($ch) => [
|
||||
'id' => $ch->id,
|
||||
'id' => $ch->id,
|
||||
'name' => $ch->name,
|
||||
'slug' => $ch->slug,
|
||||
])->values()->all(),
|
||||
@@ -642,11 +651,11 @@ final class StudioController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $base . '/' . $path;
|
||||
$url = $base.'/'.$path;
|
||||
|
||||
return [
|
||||
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
|
||||
'label' => 'Screenshot ' . ($index + 1),
|
||||
'id' => (string) ($row->variant ?? ('shot'.($index + 1))),
|
||||
'label' => 'Screenshot '.($index + 1),
|
||||
'url' => $url,
|
||||
'thumb_url' => $url,
|
||||
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkDownload;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -54,7 +57,7 @@ class TodayDownloadsController extends Controller
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$art = $row->artwork ?? null;
|
||||
if (! $art && isset($row->artwork_id)) {
|
||||
$art = \App\Models\Artwork::find($row->artwork_id);
|
||||
$art = Artwork::find($row->artwork_id);
|
||||
}
|
||||
|
||||
if (! $art) {
|
||||
@@ -75,9 +78,9 @@ class TodayDownloadsController extends Controller
|
||||
$picture = $art->file_name ?? null;
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null;
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$primaryCategory = $art->categories->first();
|
||||
$primaryCategory = $art->resolvedPrimaryCategory();
|
||||
$categoryId = $primaryCategory->id ?? null;
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
@@ -98,7 +101,7 @@ class TodayDownloadsController extends Controller
|
||||
'category_slug' => $categorySlug,
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
|
||||
'avatar_url' => AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'published_at' => $art->published_at,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
@@ -7,17 +8,19 @@ use App\Enums\ReactionType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Models\Category;
|
||||
use App\Services\ArtworkNavigationService;
|
||||
use App\Services\ArtworkRelatedService;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Services\GroupService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Inertia\Inertia;
|
||||
@@ -29,6 +32,7 @@ final class ArtworkPageController extends Controller
|
||||
private readonly GroupService $groups,
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
private readonly ArtworkNavigationService $navigation,
|
||||
private readonly ArtworkRelatedService $relatedArtworks,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response|InertiaResponse
|
||||
@@ -39,6 +43,7 @@ final class ArtworkPageController extends Controller
|
||||
if (! $raw) {
|
||||
// Artwork never existed → contextual 404
|
||||
$suggestions = app(ErrorSuggestionService::class);
|
||||
|
||||
return response(view('errors.contextual.artwork-not-found', [
|
||||
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
|
||||
]), 404);
|
||||
@@ -73,24 +78,25 @@ final class ArtworkPageController extends Controller
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(function (Artwork $a) {
|
||||
$slug = \Illuminate\Support\Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
|
||||
$md = \App\Services\ThumbnailPresenter::present($a, 'md');
|
||||
$slug = Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
|
||||
$md = ThumbnailPresenter::present($a, 'md');
|
||||
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'id' => $a->id,
|
||||
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return response(view('errors.contextual.artwork-not-found', [
|
||||
'message' => 'This artwork is not publicly available.',
|
||||
'isForbidden' => true,
|
||||
'creatorArtworks' => $creatorArtworks,
|
||||
'creatorUsername' => $creatorUsername,
|
||||
'message' => 'This artwork is not publicly available.',
|
||||
'isForbidden' => true,
|
||||
'creatorArtworks' => $creatorArtworks,
|
||||
'creatorUsername' => $creatorUsername,
|
||||
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
|
||||
]), 403);
|
||||
}
|
||||
@@ -101,9 +107,15 @@ final class ArtworkPageController extends Controller
|
||||
// distinct responses. Reusing that model avoids a second identical
|
||||
// primary-key lookup on the public path.
|
||||
$artwork = $raw;
|
||||
$artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat']);
|
||||
$artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'primaryCategory.contentType', 'primaryCategory.parent.contentType', 'tags', 'stats', 'awardStat']);
|
||||
|
||||
$this->loadCategoryAncestors($artwork->categories);
|
||||
$this->loadCategoryAncestors(
|
||||
$artwork->categories
|
||||
->concat(collect([$artwork->resolvedPrimaryCategory()]))
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values()
|
||||
);
|
||||
|
||||
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($canonicalSlug === '') {
|
||||
@@ -152,37 +164,7 @@ final class ArtworkPageController extends Controller
|
||||
'xl' => $thumbXl,
|
||||
], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray();
|
||||
|
||||
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
|
||||
$tagIds = $artwork->tags->pluck('id')->filter()->values();
|
||||
|
||||
$related = Artwork::query()
|
||||
->with(['user', 'group', 'categories.contentType'])
|
||||
->whereKeyNot($artwork->id)
|
||||
->public()
|
||||
->published()
|
||||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, $request->user()))
|
||||
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
|
||||
$query->where('user_id', $artwork->user_id);
|
||||
|
||||
if ($artwork->group_id) {
|
||||
$query->orWhere('group_id', $artwork->group_id);
|
||||
}
|
||||
|
||||
if ($categoryIds->isNotEmpty()) {
|
||||
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
|
||||
$categoryQuery->whereIn('categories.id', $categoryIds->all());
|
||||
});
|
||||
}
|
||||
|
||||
if ($tagIds->isNotEmpty()) {
|
||||
$query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void {
|
||||
$tagQuery->whereIn('tags.id', $tagIds->all());
|
||||
});
|
||||
}
|
||||
})
|
||||
->latest('published_at')
|
||||
->limit(12)
|
||||
->get()
|
||||
$related = $this->relatedArtworks->related($artwork, $request->user(), 12)
|
||||
->map(function (Artwork $item): array {
|
||||
$itemSlug = Str::slug((string) ($item->slug ?: $item->title));
|
||||
if ($itemSlug === '') {
|
||||
@@ -201,7 +183,7 @@ final class ArtworkPageController extends Controller
|
||||
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
|
||||
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||
'thumb' => $sm['url'] ?? null,
|
||||
'thumb_srcset' => ($sm['url'] ?? '') . ' 320w, ' . ($md['url'] ?? '') . ' 640w',
|
||||
'thumb_srcset' => ($sm['url'] ?? '').' 320w, '.($md['url'] ?? '').' 640w',
|
||||
], $item, request()->user());
|
||||
})
|
||||
->values()
|
||||
@@ -210,20 +192,19 @@ final class ArtworkPageController extends Controller
|
||||
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
|
||||
$viewerId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
|
||||
|
||||
|
||||
return Inertia::render('ArtworkPage', [
|
||||
'artwork' => $artworkData,
|
||||
'navigation' => $navigationData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'related' => $related,
|
||||
'canonicalUrl' => $canonical,
|
||||
'groupSummary' => $groupSummary,
|
||||
'artwork' => $artworkData,
|
||||
'navigation' => $navigationData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'related' => $related,
|
||||
'canonicalUrl' => $canonical,
|
||||
'groupSummary' => $groupSummary,
|
||||
'isAuthenticated' => $viewerId !== null,
|
||||
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId),
|
||||
'seo' => $seo,
|
||||
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId),
|
||||
'seo' => $seo,
|
||||
])->rootView('artworks.show');
|
||||
}
|
||||
|
||||
@@ -245,7 +226,7 @@ final class ArtworkPageController extends Controller
|
||||
|
||||
$totals = [];
|
||||
foreach (ReactionType::cases() as $type) {
|
||||
$slug = $type->value;
|
||||
$slug = $type->value;
|
||||
$count = (int) ($rows[$slug]->total ?? 0);
|
||||
|
||||
$mine = false;
|
||||
@@ -261,7 +242,7 @@ final class ArtworkPageController extends Controller
|
||||
'emoji' => $type->emoji(),
|
||||
'label' => $type->label(),
|
||||
'count' => $count,
|
||||
'mine' => $mine,
|
||||
'mine' => $mine,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -282,7 +263,7 @@ final class ArtworkPageController extends Controller
|
||||
->values();
|
||||
|
||||
if ($missingParentIds->isNotEmpty()) {
|
||||
$fetchedParents = \App\Models\Category::query()
|
||||
$fetchedParents = Category::query()
|
||||
->with('contentType')
|
||||
->whereIn('id', $missingParentIds->all())
|
||||
->get()
|
||||
@@ -308,12 +289,7 @@ final class ArtworkPageController extends Controller
|
||||
*/
|
||||
private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array
|
||||
{
|
||||
$primaryCategory = $artwork->categories
|
||||
->sortBy(fn ($category) => [
|
||||
(int) ($category->sort_order ?? 0),
|
||||
(string) ($category->name ?? ''),
|
||||
])
|
||||
->first();
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
|
||||
if ($primaryCategory === null) {
|
||||
return [
|
||||
@@ -338,7 +314,7 @@ final class ArtworkPageController extends Controller
|
||||
if ($contentTypeSlug !== '' && $contentTypeName !== '') {
|
||||
$breadcrumbs[] = [
|
||||
'name' => $contentTypeName,
|
||||
'url' => url('/' . $contentTypeSlug),
|
||||
'url' => url('/'.$contentTypeSlug),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -355,7 +331,7 @@ final class ArtworkPageController extends Controller
|
||||
$pathSegments[] = $slug;
|
||||
$breadcrumbs[] = [
|
||||
'name' => $name,
|
||||
'url' => url('/' . $contentTypeSlug . '/' . implode('/', $pathSegments)),
|
||||
'url' => url('/'.$contentTypeSlug.'/'.implode('/', $pathSegments)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ final class ArtworkCreateRequest extends FormRequest
|
||||
'title' => 'required|string|max:150',
|
||||
'description' => 'nullable|string',
|
||||
'category' => 'nullable|integer|exists:categories,id',
|
||||
'primary_category_id' => 'nullable|integer|exists:categories,id',
|
||||
'secondary_category_ids' => 'nullable|array|max:'.(int) config('categories.max_secondary_categories', 5),
|
||||
'secondary_category_ids.*' => 'integer|exists:categories,id',
|
||||
'tags' => 'nullable|string|max:200',
|
||||
'license' => 'nullable|boolean',
|
||||
'is_mature' => 'nullable|boolean',
|
||||
@@ -45,7 +48,7 @@ final class ArtworkCreateRequest extends FormRequest
|
||||
|
||||
private function denyAsNotFound(): void
|
||||
{
|
||||
throw new NotFoundHttpException();
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
private function logUnauthorized(string $reason): void
|
||||
|
||||
@@ -23,10 +23,13 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
|
||||
'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])],
|
||||
'description' => ['sometimes', 'nullable', 'string', 'max:5000'],
|
||||
'description_mode' => ['sometimes', Rule::in(['replace', 'append'])],
|
||||
'tags' => ['sometimes', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'tags' => ['sometimes', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])],
|
||||
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
'primary_category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
'secondary_category_ids' => ['sometimes', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
|
||||
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'content_type_id' => ['sometimes', 'nullable', 'integer', 'exists:content_types,id'],
|
||||
'similar_actions' => ['sometimes', 'array', 'max:10'],
|
||||
'similar_actions.*.artwork_id' => ['required_with:similar_actions', 'integer'],
|
||||
@@ -42,4 +45,4 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRelation;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Models\World;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
@@ -12,6 +13,7 @@ use App\Services\Worlds\WorldRewardService;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArtworkResource extends JsonResource
|
||||
{
|
||||
@@ -23,7 +25,7 @@ class ArtworkResource extends JsonResource
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world', 'worldRewardGrants.world']);
|
||||
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world']);
|
||||
|
||||
$md = ThumbnailPresenter::present($this->resource, 'md');
|
||||
$lg = ThumbnailPresenter::present($this->resource, 'lg');
|
||||
@@ -31,18 +33,16 @@ class ArtworkResource extends JsonResource
|
||||
$sq = ThumbnailPresenter::present($this->resource, 'sq');
|
||||
$screenshots = $this->resolveScreenshotAssets();
|
||||
|
||||
$canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title));
|
||||
$canonicalSlug = Str::slug((string) ($this->slug ?: $this->title));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) $this->id;
|
||||
}
|
||||
|
||||
$followerCount = 0;
|
||||
if (!empty($this->user?->id)) {
|
||||
if ($this->hasTable('user_statistics')) {
|
||||
$followerCount = (int) DB::table('user_statistics')
|
||||
->where('user_id', (int) $this->user->id)
|
||||
->value('followers_count');
|
||||
}
|
||||
if (! empty($this->user?->id)) {
|
||||
$followerCount = (int) DB::table('user_statistics')
|
||||
->where('user_id', (int) $this->user->id)
|
||||
->value('followers_count');
|
||||
|
||||
// Legacy fallback for environments where new tables are unavailable.
|
||||
if (($followerCount <= 0) && $this->hasTable('friends_list')) {
|
||||
@@ -61,9 +61,9 @@ class ArtworkResource extends JsonResource
|
||||
$viewerAward = null;
|
||||
$isOwner = $viewerId > 0 && $viewerId === (int) ($this->user?->id ?? 0);
|
||||
|
||||
$bookmarksCount = $this->hasTable('artwork_bookmarks')
|
||||
? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count()
|
||||
: 0;
|
||||
$bookmarksCount = (int) DB::table('artwork_bookmarks')
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->count();
|
||||
|
||||
if ($viewerId > 0) {
|
||||
if ($this->hasTable('artwork_likes')) {
|
||||
@@ -73,19 +73,17 @@ class ArtworkResource extends JsonResource
|
||||
->exists();
|
||||
}
|
||||
|
||||
if ($this->hasTable('artwork_bookmarks')) {
|
||||
$isBookmarked = DB::table('artwork_bookmarks')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
}
|
||||
$isBookmarked = DB::table('artwork_bookmarks')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
|
||||
$isFavorited = DB::table('artwork_favourites')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
|
||||
if (!empty($this->user?->id)) {
|
||||
if (! empty($this->user?->id)) {
|
||||
if ($this->hasTable('user_followers')) {
|
||||
$isFollowing = DB::table('user_followers')
|
||||
->where('user_id', (int) $this->user->id)
|
||||
@@ -100,7 +98,7 @@ class ArtworkResource extends JsonResource
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($this->group?->id) && $this->hasTable('group_follows')) {
|
||||
if (! empty($this->group?->id) && $this->hasTable('group_follows')) {
|
||||
$isFollowingGroup = DB::table('group_follows')
|
||||
->where('group_id', (int) $this->group->id)
|
||||
->where('user_id', $viewerId)
|
||||
@@ -125,7 +123,7 @@ class ArtworkResource extends JsonResource
|
||||
'id' => (int) ($user->id ?? 0),
|
||||
'name' => html_entity_decode((string) ($user->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'username' => (string) ($user->username ?? ''),
|
||||
'profile_url' => ! empty($user->username) ? '/@' . $user->username : null,
|
||||
'profile_url' => ! empty($user->username) ? '/@'.$user->username : null,
|
||||
'avatar_url' => $user->profile?->avatar_url,
|
||||
];
|
||||
};
|
||||
@@ -150,7 +148,7 @@ class ArtworkResource extends JsonResource
|
||||
'slug' => (string) ($this->user?->username ?? ''),
|
||||
'headline' => '',
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'profile_url' => $this->user?->username ? '/@'.$this->user->username : null,
|
||||
'followers_count' => $followerCount,
|
||||
'follow_url' => null,
|
||||
'unfollow_url' => null,
|
||||
@@ -179,6 +177,8 @@ class ArtworkResource extends JsonResource
|
||||
'title' => $decode($this->title),
|
||||
'description' => $decode($this->description),
|
||||
'description_html' => $this->renderDescriptionHtml(),
|
||||
'download_status' => (string) ($this->download_status ?? 'unknown'),
|
||||
'download_source' => $this->download_source,
|
||||
'dimensions' => [
|
||||
'width' => (int) ($this->width ?? 0),
|
||||
'height' => (int) ($this->height ?? 0),
|
||||
@@ -201,7 +201,7 @@ class ArtworkResource extends JsonResource
|
||||
'id' => (int) ($this->user?->id ?? 0),
|
||||
'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'username' => (string) ($this->user?->username ?? ''),
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'profile_url' => $this->user?->username ? '/@'.$this->user->username : null,
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'level' => (int) ($this->user?->level ?? 1),
|
||||
'rank' => (string) ($this->user?->rank ?? 'Newbie'),
|
||||
@@ -258,20 +258,40 @@ class ArtworkResource extends JsonResource
|
||||
'maturity' => app(ArtworkMaturityService::class)->presentation($this->resource, $request->user()),
|
||||
'evolution' => app(ArtworkEvolutionService::class)->publicPayload($this->resource, $request->user()),
|
||||
'world_participation' => $this->resolveWorldParticipation(),
|
||||
'categories' => $this->categories->map(fn ($category) => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
'url' => $category->contentType ? $category->url : null,
|
||||
'parent' => $category->parent ? [
|
||||
'id' => (int) $category->parent->id,
|
||||
'slug' => (string) $category->parent->slug,
|
||||
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
|
||||
'url' => $category->parent->contentType ? $category->parent->url : null,
|
||||
] : null,
|
||||
])->values(),
|
||||
'category' => ($primary = $this->resolvedPrimaryCategory()) ? [
|
||||
'id' => (int) $primary->id,
|
||||
'slug' => (string) $primary->slug,
|
||||
'name' => html_entity_decode((string) $primary->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($primary->contentType?->slug ?? ''),
|
||||
'url' => $primary->contentType ? $primary->url : null,
|
||||
'is_primary' => true,
|
||||
] : null,
|
||||
'primary_category' => ($primary = $this->resolvedPrimaryCategory()) ? [
|
||||
'id' => (int) $primary->id,
|
||||
'slug' => (string) $primary->slug,
|
||||
'name' => html_entity_decode((string) $primary->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($primary->contentType?->slug ?? ''),
|
||||
'url' => $primary->contentType ? $primary->url : null,
|
||||
] : null,
|
||||
'categories' => $this->categories->map(function ($category) {
|
||||
$primaryId = (int) ($this->primary_category_id ?? 0);
|
||||
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
'url' => $category->contentType ? $category->url : null,
|
||||
'is_primary' => $primaryId > 0 && (int) $category->id === $primaryId,
|
||||
'parent' => $category->parent ? [
|
||||
'id' => (int) $category->parent->id,
|
||||
'slug' => (string) $category->parent->slug,
|
||||
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
|
||||
'url' => $category->parent->contentType ? $category->parent->url : null,
|
||||
] : null,
|
||||
];
|
||||
})->values(),
|
||||
'tags' => $this->tags->map(fn ($tag) => [
|
||||
'id' => (int) $tag->id,
|
||||
'slug' => (string) $tag->slug,
|
||||
@@ -282,10 +302,6 @@ class ArtworkResource extends JsonResource
|
||||
|
||||
private function resolveScreenshotAssets(): array
|
||||
{
|
||||
if (! $this->hasTable('artwork_files')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('artwork_files')
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->where('variant', 'like', 'shot%')
|
||||
@@ -296,9 +312,9 @@ class ArtworkResource extends JsonResource
|
||||
$url = $this->objectUrl($path);
|
||||
|
||||
return [
|
||||
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
|
||||
'id' => (string) ($row->variant ?? ('shot'.($index + 1))),
|
||||
'variant' => (string) ($row->variant ?? ''),
|
||||
'label' => 'Screenshot ' . ($index + 1),
|
||||
'label' => 'Screenshot '.($index + 1),
|
||||
'url' => $url,
|
||||
'thumb_url' => $url,
|
||||
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
|
||||
@@ -319,7 +335,7 @@ class ArtworkResource extends JsonResource
|
||||
|
||||
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
|
||||
|
||||
return $base . '/' . $trimmedPath;
|
||||
return $base.'/'.$trimmedPath;
|
||||
}
|
||||
|
||||
private function renderDescriptionHtml(): string
|
||||
@@ -342,73 +358,75 @@ class ArtworkResource extends JsonResource
|
||||
$items = collect();
|
||||
$participationWorlds = collect();
|
||||
|
||||
if ($this->hasTable('world_relations') && $this->hasTable('worlds')) {
|
||||
$relations = WorldRelation::query()
|
||||
->with('world')
|
||||
->where('related_type', WorldRelation::TYPE_ARTWORK)
|
||||
->where('related_id', (int) $this->id)
|
||||
->get()
|
||||
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
|
||||
->values();
|
||||
$relations = WorldRelation::query()
|
||||
->with('world')
|
||||
->where('related_type', WorldRelation::TYPE_ARTWORK)
|
||||
->where('related_id', (int) $this->id)
|
||||
->get()
|
||||
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
|
||||
->values();
|
||||
|
||||
$participationWorlds = $participationWorlds->concat($relations->pluck('world')->filter());
|
||||
$participationWorlds = $participationWorlds->concat(
|
||||
$relations->pluck('world')->filter()
|
||||
);
|
||||
|
||||
$items = $items->concat(
|
||||
$relations->map(function (WorldRelation $relation): array {
|
||||
$world = $relation->world;
|
||||
$items = $items->concat(
|
||||
$relations->map(function (WorldRelation $relation): array {
|
||||
$world = $relation->world;
|
||||
|
||||
return [
|
||||
'world_id' => (int) $relation->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => 'Part of ' . $world->title,
|
||||
'status' => 'curated',
|
||||
'status_label' => 'Curated',
|
||||
'tone' => 'curated',
|
||||
'sort_priority' => 1,
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
return [
|
||||
'world_id' => (int) $relation->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => 'Part of '.$world->title,
|
||||
'status' => 'curated',
|
||||
'status_label' => 'Curated',
|
||||
'tone' => 'curated',
|
||||
'sort_priority' => 1,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
if ($this->hasTable('world_submissions')) {
|
||||
$liveSubmissions = $this->worldSubmissions
|
||||
->filter(function (WorldSubmission $submission): bool {
|
||||
return (string) $submission->status === WorldSubmission::STATUS_LIVE
|
||||
&& $submission->world !== null
|
||||
&& $submission->world->isPubliclyVisible();
|
||||
})
|
||||
->values();
|
||||
$liveSubmissions = $this->worldSubmissions
|
||||
->filter(function (WorldSubmission $submission): bool {
|
||||
return (string) $submission->status === WorldSubmission::STATUS_LIVE
|
||||
&& $submission->world !== null
|
||||
&& $submission->world->isPubliclyVisible();
|
||||
})
|
||||
->values();
|
||||
|
||||
$participationWorlds = $participationWorlds->concat($liveSubmissions->pluck('world')->filter());
|
||||
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
|
||||
$participationWorlds = $participationWorlds->concat(
|
||||
$liveSubmissions->pluck('world')->filter()
|
||||
);
|
||||
|
||||
$items = $items->concat(
|
||||
$liveSubmissions->map(function (WorldSubmission $submission): array {
|
||||
$world = $submission->world;
|
||||
$isFeatured = (bool) $submission->is_featured;
|
||||
World::primeCanonicalEditionIds(
|
||||
$participationWorlds->pluck('recurrence_key')->all()
|
||||
);
|
||||
|
||||
return [
|
||||
'world_id' => (int) $submission->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title,
|
||||
'status' => (string) $submission->status,
|
||||
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
|
||||
'tone' => $isFeatured ? 'featured' : 'community',
|
||||
'sort_priority' => $isFeatured ? 0 : 2,
|
||||
];
|
||||
})
|
||||
);
|
||||
} elseif ($participationWorlds->isNotEmpty()) {
|
||||
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
|
||||
}
|
||||
$items = $items->concat(
|
||||
$liveSubmissions->map(function (WorldSubmission $submission): array {
|
||||
$world = $submission->world;
|
||||
$isFeatured = (bool) $submission->is_featured;
|
||||
|
||||
if ($this->hasTable('world_reward_grants')) {
|
||||
$items = $items->concat(app(WorldRewardService::class)->artworkRewardBadges($this->resource));
|
||||
}
|
||||
return [
|
||||
'world_id' => (int) $submission->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ').$world->title,
|
||||
'status' => (string) $submission->status,
|
||||
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
|
||||
'tone' => $isFeatured ? 'featured' : 'community',
|
||||
'sort_priority' => $isFeatured ? 0 : 2,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
$items = $items->concat(
|
||||
app(WorldRewardService::class)
|
||||
->artworkRewardBadges($this->resource)
|
||||
);
|
||||
|
||||
return $items
|
||||
->sortBy('sort_priority')
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Recommendations\UserInterestProfileService;
|
||||
use App\Services\Recommendations\SessionRecoService;
|
||||
use App\Services\Recommendations\UserInterestProfileService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -30,7 +30,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
public array $backoff = [5, 30, 120];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $eventId,
|
||||
@@ -40,8 +40,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
public readonly string $algoVersion,
|
||||
public readonly string $occurredAt,
|
||||
public readonly array $meta = []
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function handle(UserInterestProfileService $profileService, SessionRecoService $sessionRecoService): void
|
||||
{
|
||||
@@ -70,10 +69,9 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
$eventVersion = (string) config('discovery.event_version', 'event-v1');
|
||||
$eventWeight = (float) ((array) config('discovery.weights', []))[$this->eventType] ?? 1.0;
|
||||
|
||||
$categoryId = DB::table('artwork_category')
|
||||
->where('artwork_id', $this->artworkId)
|
||||
->orderBy('category_id')
|
||||
->value('category_id');
|
||||
$categoryId = DB::table('artworks')
|
||||
->where('id', $this->artworkId)
|
||||
->value('primary_category_id');
|
||||
|
||||
$insertPayload = [
|
||||
'event_id' => $this->eventId,
|
||||
|
||||
+58
-11
@@ -66,6 +66,9 @@ class Artwork extends Model
|
||||
'description',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'download_status',
|
||||
'download_source',
|
||||
'download_checked_at',
|
||||
'hash',
|
||||
'file_ext',
|
||||
'thumb_ext',
|
||||
@@ -81,6 +84,10 @@ class Artwork extends Model
|
||||
'is_public',
|
||||
'visibility',
|
||||
'is_approved',
|
||||
'approval_source',
|
||||
'moderated_at',
|
||||
'moderated_by',
|
||||
'moderation_note',
|
||||
'is_mature',
|
||||
'maturity_level',
|
||||
'maturity_source',
|
||||
@@ -117,6 +124,7 @@ class Artwork extends Model
|
||||
'description_source',
|
||||
'tags_source',
|
||||
'category_source',
|
||||
'primary_category_id',
|
||||
// Versioning
|
||||
'current_version_id',
|
||||
'version_count',
|
||||
@@ -132,6 +140,7 @@ class Artwork extends Model
|
||||
'is_public' => 'boolean',
|
||||
'visibility' => 'string',
|
||||
'is_approved' => 'boolean',
|
||||
'moderated_at' => 'datetime',
|
||||
'is_mature' => 'boolean',
|
||||
'maturity_level' => 'string',
|
||||
'maturity_source' => 'string',
|
||||
@@ -171,6 +180,7 @@ class Artwork extends Model
|
||||
'category_source' => 'string',
|
||||
'version_updated_at' => 'datetime',
|
||||
'requires_reapproval' => 'boolean',
|
||||
'download_checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -449,6 +459,32 @@ class Artwork extends Model
|
||||
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
|
||||
}
|
||||
|
||||
public function primaryCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'primary_category_id');
|
||||
}
|
||||
|
||||
public function resolvedPrimaryCategory(): ?Category
|
||||
{
|
||||
if ($this->relationLoaded('primaryCategory') && $this->getRelation('primaryCategory') instanceof Category) {
|
||||
return $this->getRelation('primaryCategory');
|
||||
}
|
||||
|
||||
$primaryId = $this->primary_category_id !== null ? (int) $this->primary_category_id : 0;
|
||||
if ($primaryId > 0 && $this->relationLoaded('categories')) {
|
||||
$match = $this->categories->firstWhere('id', $primaryId);
|
||||
if ($match instanceof Category) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
if ($primaryId > 0) {
|
||||
return $this->primaryCategory;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function collections(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Collection::class, 'collection_artwork', 'artwork_id', 'collection_id')
|
||||
@@ -559,19 +595,25 @@ class Artwork extends Model
|
||||
*/
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
$this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']);
|
||||
$this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'primaryCategory.contentType', 'stats', 'awardStat']);
|
||||
|
||||
$stat = $this->stats;
|
||||
$awardStat = $this->awardStat;
|
||||
$publishedSortAt = $this->published_at ?? $this->created_at;
|
||||
$sortedCategories = $this->categories->sortBy(
|
||||
fn ($category) => sprintf(
|
||||
'%010d|%s|%010d',
|
||||
(int) ($category->sort_order ?? 999999999),
|
||||
strtolower((string) ($category->name ?? '')),
|
||||
(int) ($category->id ?? 0)
|
||||
)
|
||||
)->values();
|
||||
$primaryCategory = $this->resolvedPrimaryCategory();
|
||||
$sortedCategories = $this->categories
|
||||
->sortBy(function ($category) use ($primaryCategory) {
|
||||
$isPrimary = $primaryCategory && (int) $category->id === (int) $primaryCategory->id;
|
||||
|
||||
return sprintf(
|
||||
'%d|%010d|%s|%010d',
|
||||
$isPrimary ? 0 : 1,
|
||||
(int) ($category->sort_order ?? 999999999),
|
||||
strtolower((string) ($category->name ?? '')),
|
||||
(int) ($category->id ?? 0)
|
||||
);
|
||||
})
|
||||
->values();
|
||||
|
||||
// Orientation derived from pixel dimensions
|
||||
$orientation = 'square';
|
||||
@@ -588,8 +630,6 @@ class Artwork extends Model
|
||||
? $this->width.'x'.$this->height
|
||||
: '';
|
||||
|
||||
// Primary category slug follows the same sort_order-first semantics used by page presenters.
|
||||
$primaryCategory = $sortedCategories->first();
|
||||
$categorySlugs = $sortedCategories
|
||||
->pluck('slug')
|
||||
->filter()
|
||||
@@ -619,6 +659,13 @@ class Artwork extends Model
|
||||
'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase',
|
||||
'published_as_type' => $this->publishedAsType(),
|
||||
'category' => $category,
|
||||
'primary_category_id' => $primaryCategory?->id ? (int) $primaryCategory->id : null,
|
||||
'category_ids' => $this->categories
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all(),
|
||||
'categories' => $categorySlugs,
|
||||
'content_type' => $content_type,
|
||||
'content_types' => $contentTypeSlugs,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Upload extends Model
|
||||
{
|
||||
@@ -23,6 +24,7 @@ class Upload extends Model
|
||||
'title',
|
||||
'slug',
|
||||
'category_id',
|
||||
'secondary_category_ids',
|
||||
'description',
|
||||
'tags',
|
||||
'license',
|
||||
@@ -41,6 +43,7 @@ class Upload extends Model
|
||||
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'secondary_category_ids' => 'array',
|
||||
'nsfw' => 'boolean',
|
||||
'is_scanned' => 'boolean',
|
||||
'has_tags' => 'boolean',
|
||||
@@ -48,4 +51,14 @@ class Upload extends Model
|
||||
'expires_at' => 'datetime',
|
||||
'moderated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Artworks;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class ArtworkCategoryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||
private readonly ArtworkService $artworkService,
|
||||
) {}
|
||||
|
||||
public function maxSecondary(): int
|
||||
{
|
||||
return max(0, (int) config('categories.max_secondary_categories', 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|string>|null $secondaryCategoryIds null keeps existing secondaries that remain compatible
|
||||
*/
|
||||
public function sync(
|
||||
Artwork $artwork,
|
||||
?int $primaryCategoryId,
|
||||
?array $secondaryCategoryIds = [],
|
||||
?string $source = null,
|
||||
bool $reindex = true,
|
||||
): void {
|
||||
$primaryCategoryId = $primaryCategoryId !== null && $primaryCategoryId > 0 ? $primaryCategoryId : null;
|
||||
$secondaryCategoryIds = $secondaryCategoryIds === null
|
||||
? $this->existingSecondaryIds($artwork, $primaryCategoryId)
|
||||
: $this->normalizeIds($secondaryCategoryIds);
|
||||
|
||||
if ($primaryCategoryId === null) {
|
||||
if ($secondaryCategoryIds !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['A primary category is required when additional categories are selected.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->persist($artwork, null, [], $source, $reindex);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$secondaryCategoryIds = array_values(array_filter(
|
||||
$secondaryCategoryIds,
|
||||
static fn (int $id): bool => $id !== $primaryCategoryId,
|
||||
));
|
||||
|
||||
if (count($secondaryCategoryIds) > $this->maxSecondary()) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => [sprintf('You can add at most %d additional categories.', $this->maxSecondary())],
|
||||
]);
|
||||
}
|
||||
|
||||
$requestedIds = array_values(array_unique([$primaryCategoryId, ...$secondaryCategoryIds]));
|
||||
$categories = Category::query()
|
||||
->whereIn('id', $requestedIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
if ($categories->count() !== count($requestedIds)) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['One or more selected categories do not exist.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$primary = $categories->get($primaryCategoryId);
|
||||
if (! $primary) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['Choose a valid primary category.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $primary->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['The primary category is not available.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$contentTypeId = (int) $primary->content_type_id;
|
||||
|
||||
foreach ($secondaryCategoryIds as $secondaryId) {
|
||||
$category = $categories->get($secondaryId);
|
||||
if (! $category || ! $category->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => ['One or more additional categories are not available.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ((int) $category->content_type_id !== $contentTypeId) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => ['Additional categories must belong to the same content type as the primary category.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->persist($artwork, $primaryCategoryId, $secondaryCategoryIds, $source, $reindex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function presentCategory(Category $category, bool $isPrimary): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'slug' => (string) $category->slug,
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
'url' => $category->contentType ? $category->url : null,
|
||||
'is_primary' => $isPrimary,
|
||||
'parent' => $category->parent ? [
|
||||
'id' => (int) $category->parent->id,
|
||||
'slug' => (string) $category->parent->slug,
|
||||
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
|
||||
'url' => $category->parent->contentType ? $category->parent->url : null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|string> $ids
|
||||
* @return list<int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$value = (int) $id;
|
||||
if ($value > 0 && ! in_array($value, $normalized, true)) {
|
||||
$normalized[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function existingSecondaryIds(Artwork $artwork, ?int $primaryCategoryId): array
|
||||
{
|
||||
return $artwork->categories()
|
||||
->pluck('categories.id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->reject(static fn (int $id): bool => $primaryCategoryId !== null && $id === $primaryCategoryId)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $secondaryCategoryIds
|
||||
*/
|
||||
private function persist(
|
||||
Artwork $artwork,
|
||||
?int $primaryCategoryId,
|
||||
array $secondaryCategoryIds,
|
||||
?string $source,
|
||||
bool $reindex,
|
||||
): void {
|
||||
$membershipIds = $primaryCategoryId === null
|
||||
? []
|
||||
: array_values(array_unique([$primaryCategoryId, ...$secondaryCategoryIds]));
|
||||
|
||||
DB::transaction(function () use ($artwork, $primaryCategoryId, $membershipIds, $source): void {
|
||||
$artwork->categories()->sync($membershipIds);
|
||||
$artwork->primary_category_id = $primaryCategoryId;
|
||||
|
||||
if ($source !== null) {
|
||||
$artwork->category_source = $source;
|
||||
}
|
||||
|
||||
$artwork->save();
|
||||
});
|
||||
|
||||
$artwork->unsetRelation('categories');
|
||||
$artwork->unsetRelation('primaryCategory');
|
||||
|
||||
if (! $reindex) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::afterCommit(function () use ($artwork): void {
|
||||
$this->artworkService->clearArtworkCache($artwork->fresh() ?? $artwork);
|
||||
Cache::forget('categories.directory.v1');
|
||||
$this->searchIndexer->update($artwork);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,16 @@ final class ArtworkDraftService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly ArtworkCategoryService $categories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null): ArtworkDraftResult
|
||||
/**
|
||||
* @param list<int> $secondaryCategoryIds
|
||||
*/
|
||||
public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null, array $secondaryCategoryIds = []): ArtworkDraftResult
|
||||
{
|
||||
return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier) {
|
||||
return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier, $secondaryCategoryIds) {
|
||||
$slug = $this->makeSlug($title);
|
||||
$group = $this->resolveGroup($user, $groupIdentifier);
|
||||
|
||||
@@ -50,9 +54,8 @@ final class ArtworkDraftService
|
||||
'artwork_status' => 'draft',
|
||||
]);
|
||||
|
||||
// Attach the selected category to the artwork pivot table
|
||||
if ($categoryId !== null && \App\Models\Category::where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
if ($categoryId !== null || $secondaryCategoryIds !== []) {
|
||||
$this->categories->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false);
|
||||
}
|
||||
|
||||
if ($group) {
|
||||
|
||||
@@ -140,7 +140,7 @@ class CollectionAiCurationService
|
||||
->first();
|
||||
|
||||
$label = $tag?->name
|
||||
?: ($artwork->categories->first()?->name)
|
||||
?: ($artwork->resolvedPrimaryCategory()?->name)
|
||||
?: ($artwork->stats?->views ? 'Popular highlights' : 'Curated picks');
|
||||
|
||||
if (! isset($themeBuckets[$label])) {
|
||||
@@ -673,7 +673,7 @@ class CollectionAiCurationService
|
||||
$artworks = $this->candidateArtworks($collection, $draft, 36);
|
||||
$themes = $this->topThemes($artworks);
|
||||
$categories = $artworks
|
||||
->map(fn (Artwork $artwork) => $artwork->categories->first()?->name)
|
||||
->map(fn (Artwork $artwork) => $artwork->resolvedPrimaryCategory()?->name)
|
||||
->filter()
|
||||
->countBy()
|
||||
->sortDesc();
|
||||
|
||||
@@ -95,7 +95,7 @@ class CollectionLinkService
|
||||
'label' => (string) $artwork->title,
|
||||
'description' => collect([
|
||||
$artwork->user?->username ? '@' . strtolower((string) $artwork->user->username) : null,
|
||||
$artwork->categories->first()?->contentType?->name,
|
||||
$artwork->resolvedPrimaryCategory()?->contentType?->name,
|
||||
])->filter()->join(' • ') ?: 'Published artwork',
|
||||
])
|
||||
->values()
|
||||
@@ -343,7 +343,7 @@ class CollectionLinkService
|
||||
'title' => (string) $entity->title,
|
||||
'subtitle' => collect([
|
||||
$entity->user?->username ? '@' . strtolower((string) $entity->user->username) : null,
|
||||
$entity->categories->first()?->contentType?->name,
|
||||
$entity->resolvedPrimaryCategory()?->contentType?->name,
|
||||
])->filter()->join(' • ') ?: 'Artwork',
|
||||
'description' => $link->relationship_type ?: 'Linked artwork',
|
||||
'url' => route('art.show', [
|
||||
|
||||
@@ -861,6 +861,14 @@ class CollectionService
|
||||
? $collections
|
||||
: new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections));
|
||||
|
||||
// The profile producer already eager-loads these relations. Keep the
|
||||
// mapper safe for direct callers as well, so the card loop never
|
||||
// triggers relation lazy-loading one collection at a time.
|
||||
$collectionList->loadMissing([
|
||||
'user:id,username,name',
|
||||
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
]);
|
||||
|
||||
$collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
||||
$hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer);
|
||||
|
||||
@@ -879,12 +887,16 @@ class CollectionService
|
||||
->all()
|
||||
: [];
|
||||
|
||||
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) {
|
||||
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds, $hideMatureCovers) {
|
||||
// Manual collections have their explicit cover eager-loaded. The
|
||||
// already bulk-loaded first artwork map is the exact fallback
|
||||
// used by resolvedCoverArtwork() when that cover is absent or not
|
||||
// visible, so no per-card relation query is needed here.
|
||||
$resolvedCover = $collection->isSmart()
|
||||
? $this->smartCollections->firstArtwork($collection, $ownerView)
|
||||
: $collection->resolvedCoverArtwork(! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer));
|
||||
: ($collection->relationLoaded('coverArtwork') ? $collection->coverArtwork : null);
|
||||
$fallbackCover = $firstArtworkMap->get((int) $collection->id);
|
||||
$cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer))
|
||||
$cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, $hideMatureCovers)
|
||||
? $resolvedCover
|
||||
: $fallbackCover;
|
||||
$summary = $collection->summary ?? $collection->description;
|
||||
@@ -1264,7 +1276,7 @@ class CollectionService
|
||||
|
||||
private function mapArtworkPayload(Artwork $artwork, array $extra = []): array
|
||||
{
|
||||
$category = $artwork->categories->first();
|
||||
$category = $artwork->resolvedPrimaryCategory();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $artwork->stats;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Group;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -23,6 +24,7 @@ class GroupArtworkReviewService
|
||||
private readonly GroupHistoryService $history,
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly GroupMembershipService $memberships,
|
||||
private readonly ArtworkCategoryService $artworkCategories,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -108,6 +110,10 @@ class GroupArtworkReviewService
|
||||
'group_reviewed_at' => now(),
|
||||
'group_review_notes' => $notes,
|
||||
'is_approved' => true,
|
||||
'approval_source' => 'moderator',
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $actor->id,
|
||||
'moderation_note' => $notes,
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now(),
|
||||
'publish_at' => null,
|
||||
@@ -363,9 +369,20 @@ class GroupArtworkReviewService
|
||||
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $actor->id;
|
||||
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $actor->id;
|
||||
|
||||
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
|
||||
if ($categoryId > 0 && Category::query()->where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
$categoryId = isset($validated['primary_category_id'])
|
||||
? (int) $validated['primary_category_id']
|
||||
: (isset($validated['category']) ? (int) $validated['category'] : null);
|
||||
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated)
|
||||
? array_values(array_map('intval', (array) $validated['secondary_category_ids']))
|
||||
: [];
|
||||
if ($categoryId > 0) {
|
||||
$this->artworkCategories->sync(
|
||||
$artwork,
|
||||
$categoryId,
|
||||
$secondaryCategoryIds,
|
||||
'user',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (array_key_exists('tags', $validated) && is_array($validated['tags'])) {
|
||||
@@ -395,4 +412,4 @@ class GroupArtworkReviewService
|
||||
{
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ final class RSSFeedBuilder
|
||||
|
||||
// Primary category from eagerly loaded relation (avoid N+1)
|
||||
$primaryCategory = ($artwork->relationLoaded('categories'))
|
||||
? $artwork->categories->first()
|
||||
? $artwork->resolvedPrimaryCategory()
|
||||
: null;
|
||||
|
||||
// Build HTML description embedded in CDATA
|
||||
|
||||
@@ -201,7 +201,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
? 'scheduled'
|
||||
: ((bool) $artwork->is_public ? 'published' : 'draft'));
|
||||
|
||||
$category = $artwork->categories->first();
|
||||
$category = $artwork->resolvedPrimaryCategory();
|
||||
$visibility = $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE);
|
||||
|
||||
return [
|
||||
|
||||
@@ -211,9 +211,13 @@ final class StudioAiAssistService
|
||||
}
|
||||
|
||||
$categoryId = $this->resolveCategoryId($payload);
|
||||
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $payload)
|
||||
? array_values(array_map('intval', (array) $payload['secondary_category_ids']))
|
||||
: null;
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
app(\App\Services\Artworks\ArtworkCategoryService::class)
|
||||
->sync($artwork, $categoryId, $secondaryCategoryIds, 'ai_applied', false);
|
||||
$artwork->category_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'category';
|
||||
@@ -294,10 +298,10 @@ final class StudioAiAssistService
|
||||
*/
|
||||
public function payloadFor(Artwork $artwork): array
|
||||
{
|
||||
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType']);
|
||||
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType', 'primaryCategory.contentType']);
|
||||
$assist = $artwork->artworkAiAssist;
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
|
||||
if (! $assist) {
|
||||
return [
|
||||
@@ -515,6 +519,13 @@ final class StudioAiAssistService
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'primary_category_id' => $primaryCategory?->id,
|
||||
'secondary_category_ids' => $artwork->categories
|
||||
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'sources' => [
|
||||
'title' => $artwork->title_source ?: 'manual',
|
||||
@@ -530,6 +541,10 @@ final class StudioAiAssistService
|
||||
*/
|
||||
private function resolveCategoryId(array $payload): ?int
|
||||
{
|
||||
if (isset($payload['primary_category_id']) && $payload['primary_category_id'] !== null) {
|
||||
return (int) $payload['primary_category_id'];
|
||||
}
|
||||
|
||||
if (isset($payload['category_id']) && $payload['category_id'] !== null) {
|
||||
return (int) $payload['category_id'];
|
||||
}
|
||||
|
||||
@@ -49,8 +49,9 @@ final class StudioAiCategoryMapper
|
||||
$selectedCategory,
|
||||
$this->confidenceForModel($categoryScores, $selectedCategory->id),
|
||||
$categoryScores
|
||||
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id)
|
||||
->take(3)
|
||||
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id
|
||||
|| (int) $row['model']->content_type_id !== (int) $selectedCategory->content_type_id)
|
||||
->take(max(0, (int) config('categories.max_secondary_categories', 5)))
|
||||
->map(fn (array $row): array => $this->serializeCategory($row['model'], $row['confidence']))
|
||||
->all()
|
||||
) : null,
|
||||
@@ -245,6 +246,7 @@ final class StudioAiCategoryMapper
|
||||
'root_category_id' => (int) $rootCategory->id,
|
||||
'sub_category_id' => $category->parent_id ? (int) $category->id : null,
|
||||
'alternatives' => array_values($alternatives),
|
||||
'secondary_categories' => array_values($alternatives),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,8 @@ final class StudioBulkActionService
|
||||
throw new \InvalidArgumentException('category_id required for change_category');
|
||||
}
|
||||
|
||||
$artwork->categories()->sync([(int) $params['category_id']]);
|
||||
app(\App\Services\Artworks\ArtworkCategoryService::class)
|
||||
->sync($artwork, (int) $params['category_id'], [], 'moderator');
|
||||
}
|
||||
|
||||
private function addTags(Artwork $artwork, array $params): void
|
||||
|
||||
@@ -608,7 +608,7 @@ final class WorldSubmissionService
|
||||
'thumbnail_url' => $artwork->thumbUrl('md'),
|
||||
'creator_name' => (string) ($artwork->user?->name ?: $artwork->user?->username ?: ''),
|
||||
'meta' => array_values(array_filter([
|
||||
$artwork->categories->first()?->name,
|
||||
$artwork->resolvedPrimaryCategory()?->name,
|
||||
$views > 0 ? number_format($views) . ' views' : null,
|
||||
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
|
||||
])),
|
||||
|
||||
Reference in New Issue
Block a user