optimizations
This commit is contained in:
145
app/Http/Controllers/Settings/CollectionAiController.php
Normal file
145
app/Http/Controllers/Settings/CollectionAiController.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Collection;
|
||||
use App\Services\CollectionAiCurationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CollectionAiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionAiCurationService $ai,
|
||||
) {
|
||||
}
|
||||
|
||||
public function suggestTitle(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestTitle($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestSummary(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestSummary($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestCover(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestCover($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestGrouping(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestGrouping($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestRelatedArtworks(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestRelatedArtworks($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestTags(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestTags($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestSeoDescription(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestSeoDescription($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function explainSmartRules(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->explainSmartRules($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestSplitThemes(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestSplitThemes($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestMergeIdea(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestMergeIdea($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function detectWeakMetadata(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->detectWeakMetadata($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestStaleRefresh(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestStaleRefresh($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestCampaignFit(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestCampaignFit($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggestRelatedCollectionsToLink(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'suggestion' => $this->ai->suggestRelatedCollectionsToLink($collection->loadMissing('user'), (array) $request->input('draft', [])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
342
app/Http/Controllers/Settings/CollectionInsightsController.php
Normal file
342
app/Http/Controllers/Settings/CollectionInsightsController.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Collections\CollectionBulkActionsRequest;
|
||||
use App\Http\Requests\Collections\CollectionOwnerSearchRequest;
|
||||
use App\Http\Requests\Collections\CollectionTargetActionRequest;
|
||||
use App\Http\Requests\Collections\UpdateCollectionWorkflowRequest;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionHistory;
|
||||
use App\Services\CollectionAiOperationsService;
|
||||
use App\Services\CollectionAnalyticsService;
|
||||
use App\Services\CollectionBackgroundJobService;
|
||||
use App\Services\CollectionBulkActionService;
|
||||
use App\Services\CollectionCanonicalService;
|
||||
use App\Services\CollectionDashboardService;
|
||||
use App\Services\CollectionHistoryService;
|
||||
use App\Services\CollectionHealthService;
|
||||
use App\Services\CollectionMergeService;
|
||||
use App\Services\CollectionSearchService;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\CollectionWorkflowService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CollectionInsightsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionDashboardService $dashboard,
|
||||
private readonly CollectionAnalyticsService $analytics,
|
||||
private readonly CollectionHistoryService $history,
|
||||
private readonly CollectionAiOperationsService $aiOperations,
|
||||
private readonly CollectionBackgroundJobService $backgroundJobs,
|
||||
private readonly CollectionBulkActionService $bulkActions,
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionSearchService $search,
|
||||
private readonly CollectionHealthService $health,
|
||||
private readonly CollectionWorkflowService $workflow,
|
||||
private readonly CollectionCanonicalService $canonical,
|
||||
private readonly CollectionMergeService $merge,
|
||||
) {
|
||||
}
|
||||
|
||||
public function dashboard(Request $request): Response
|
||||
{
|
||||
$payload = $this->dashboard->build($request->user());
|
||||
|
||||
return Inertia::render('Collection/CollectionDashboard', [
|
||||
'summary' => $payload['summary'],
|
||||
'topPerforming' => $this->collections->mapCollectionCardPayloads($payload['top_performing'], true),
|
||||
'needsAttention' => $this->collections->mapCollectionCardPayloads($payload['needs_attention'], true),
|
||||
'expiringCampaigns' => $this->collections->mapCollectionCardPayloads($payload['expiring_campaigns'], true),
|
||||
'healthWarnings' => $payload['health_warnings'],
|
||||
'filterOptions' => [
|
||||
'types' => [
|
||||
Collection::TYPE_PERSONAL,
|
||||
Collection::TYPE_COMMUNITY,
|
||||
Collection::TYPE_EDITORIAL,
|
||||
],
|
||||
'visibilities' => [
|
||||
Collection::VISIBILITY_PUBLIC,
|
||||
Collection::VISIBILITY_UNLISTED,
|
||||
Collection::VISIBILITY_PRIVATE,
|
||||
],
|
||||
'lifecycleStates' => [
|
||||
Collection::LIFECYCLE_DRAFT,
|
||||
Collection::LIFECYCLE_SCHEDULED,
|
||||
Collection::LIFECYCLE_PUBLISHED,
|
||||
Collection::LIFECYCLE_FEATURED,
|
||||
Collection::LIFECYCLE_ARCHIVED,
|
||||
Collection::LIFECYCLE_HIDDEN,
|
||||
Collection::LIFECYCLE_RESTRICTED,
|
||||
Collection::LIFECYCLE_UNDER_REVIEW,
|
||||
Collection::LIFECYCLE_EXPIRED,
|
||||
],
|
||||
'workflowStates' => [
|
||||
Collection::WORKFLOW_DRAFT,
|
||||
Collection::WORKFLOW_IN_REVIEW,
|
||||
Collection::WORKFLOW_APPROVED,
|
||||
Collection::WORKFLOW_PROGRAMMED,
|
||||
Collection::WORKFLOW_ARCHIVED,
|
||||
],
|
||||
'healthStates' => [
|
||||
Collection::HEALTH_HEALTHY,
|
||||
Collection::HEALTH_NEEDS_METADATA,
|
||||
Collection::HEALTH_STALE,
|
||||
Collection::HEALTH_LOW_CONTENT,
|
||||
Collection::HEALTH_BROKEN_ITEMS,
|
||||
Collection::HEALTH_WEAK_COVER,
|
||||
Collection::HEALTH_LOW_ENGAGEMENT,
|
||||
Collection::HEALTH_ATTRIBUTION_INCOMPLETE,
|
||||
Collection::HEALTH_NEEDS_REVIEW,
|
||||
Collection::HEALTH_DUPLICATE_RISK,
|
||||
Collection::HEALTH_MERGE_CANDIDATE,
|
||||
],
|
||||
],
|
||||
'endpoints' => [
|
||||
'managePattern' => route('settings.collections.show', ['collection' => '__COLLECTION__']),
|
||||
'analyticsPattern' => route('settings.collections.analytics', ['collection' => '__COLLECTION__']),
|
||||
'historyPattern' => route('settings.collections.history', ['collection' => '__COLLECTION__']),
|
||||
'healthPattern' => route('settings.collections.health', ['collection' => '__COLLECTION__']),
|
||||
'search' => route('settings.collections.search'),
|
||||
'bulkActions' => route('settings.collections.bulk-actions'),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => 'Collections Dashboard — Skinbase Nova',
|
||||
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
|
||||
'canonical' => route('settings.collections.dashboard'),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function analytics(Request $request, Collection $collection): Response
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
$collection->loadMissing(['user.profile', 'coverArtwork']);
|
||||
|
||||
return Inertia::render('Collection/CollectionAnalytics', [
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
|
||||
'analytics' => $this->analytics->overview($collection, (int) $request->integer('days', 30)),
|
||||
'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]),
|
||||
'dashboardUrl' => route('settings.collections.dashboard'),
|
||||
'seo' => [
|
||||
'title' => sprintf('%s Analytics — Skinbase Nova', $collection->title),
|
||||
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
|
||||
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function history(Request $request, Collection $collection): Response
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
$collection->loadMissing(['user.profile', 'coverArtwork']);
|
||||
|
||||
$history = $this->history->historyFor($collection, (int) $request->integer('per_page', 40));
|
||||
|
||||
return Inertia::render('Collection/CollectionHistory', [
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
|
||||
'history' => $this->history->mapPaginator($history),
|
||||
'canRestoreHistory' => $this->isStaff($request),
|
||||
'dashboardUrl' => route('settings.collections.dashboard'),
|
||||
'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]),
|
||||
'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']),
|
||||
'seo' => [
|
||||
'title' => sprintf('%s History — Skinbase Nova', $collection->title),
|
||||
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
|
||||
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function restoreHistory(Request $request, Collection $collection, CollectionHistory $history): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
abort_unless($this->isStaff($request), 403);
|
||||
|
||||
$collection = $this->history->restore($collection->loadMissing('user'), $history, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'health' => $this->health->summary($collection),
|
||||
'restored_history_entry_id' => (int) $history->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function qualityReview(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'review' => $this->aiOperations->qualityReview($collection->loadMissing(['user.profile', 'coverArtwork'])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(CollectionOwnerSearchRequest $request): JsonResponse
|
||||
{
|
||||
$filters = $request->validated();
|
||||
|
||||
$results = $this->search->ownerSearch($request->user(), $filters, (int) config('collections.v5.search.owner_per_page', 20));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), true),
|
||||
'filters' => $filters,
|
||||
'meta' => [
|
||||
'current_page' => $results->currentPage(),
|
||||
'last_page' => $results->lastPage(),
|
||||
'per_page' => $results->perPage(),
|
||||
'total' => $results->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkActions(CollectionBulkActionsRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
$result = $this->bulkActions->apply($request->user(), $payload);
|
||||
$dashboard = $this->dashboard->build($request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'action' => $result['action'],
|
||||
'count' => $result['count'],
|
||||
'message' => $result['message'],
|
||||
'collections' => $this->collections->mapCollectionCardPayloads($result['collections'], true),
|
||||
'items' => $result['items'],
|
||||
'summary' => $dashboard['summary'],
|
||||
'healthWarnings' => $dashboard['health_warnings'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function health(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection->loadMissing(['user.profile', 'coverArtwork']);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
|
||||
'health' => $this->health->summary($collection),
|
||||
'duplicate_candidates' => $this->collections->mapCollectionCardPayloads($this->merge->duplicateCandidates($collection), true),
|
||||
'history_url' => route('settings.collections.history', ['collection' => $collection->id]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function workflowUpdate(UpdateCollectionWorkflowRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
if (! $this->isStaff($request)) {
|
||||
unset($payload['program_key'], $payload['partner_key'], $payload['experiment_key'], $payload['placement_eligibility']);
|
||||
}
|
||||
|
||||
$collection = $this->workflow->update($collection->loadMissing('user'), $payload, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'health' => $this->health->summary($collection),
|
||||
]);
|
||||
}
|
||||
|
||||
public function qualityRefresh(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'queued' => true,
|
||||
'result' => $this->backgroundJobs->dispatchQualityRefresh($collection->loadMissing('user'), $request->user()),
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'health' => $this->health->summary($collection),
|
||||
]);
|
||||
}
|
||||
|
||||
public function canonicalize(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
|
||||
$this->authorize('update', $target);
|
||||
|
||||
$collection = $this->canonical->designate($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'target' => $this->collections->mapCollectionCardPayloads([$target->fresh()->loadMissing('user')], true)[0],
|
||||
'canonical_target' => $this->collections->mapCollectionCardPayloads([$target->fresh()->loadMissing('user')], true)[0],
|
||||
'duplicate_candidates' => $this->merge->reviewCandidates($collection->fresh()->loadMissing('user'), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function merge(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
|
||||
$this->authorize('update', $target);
|
||||
|
||||
$result = $this->merge->mergeInto($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'source' => $this->collections->mapCollectionDetailPayload($result['source']->loadMissing('user'), true),
|
||||
'target' => $this->collections->mapCollectionDetailPayload($result['target']->loadMissing('user'), true),
|
||||
'attached_artwork_ids' => $result['attached_artwork_ids'],
|
||||
'canonical_target' => $this->collections->mapCollectionCardPayloads([$result['target']->loadMissing('user')], true)[0],
|
||||
'duplicate_candidates' => $this->merge->reviewCandidates($result['source']->fresh()->loadMissing('user'), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function rejectDuplicate(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
|
||||
$this->authorize('update', $target);
|
||||
|
||||
$collection = $this->merge->rejectCandidate($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'duplicate_candidates' => $this->merge->reviewCandidates($collection->fresh()->loadMissing('user'), true),
|
||||
'canonical_target' => $collection->canonical_collection_id
|
||||
? $this->collections->mapCollectionCardPayloads([
|
||||
Collection::query()->findOrFail((int) $collection->canonical_collection_id)->loadMissing('user'),
|
||||
], true)[0]
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function isStaff(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return $user !== null && ($user->isAdmin() || $user->isModerator());
|
||||
}
|
||||
}
|
||||
519
app/Http/Controllers/Settings/CollectionManageController.php
Normal file
519
app/Http/Controllers/Settings/CollectionManageController.php
Normal file
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Collections\AttachCollectionArtworksRequest;
|
||||
use App\Http\Requests\Collections\ReorderCollectionArtworksRequest;
|
||||
use App\Http\Requests\Collections\ReorderProfileCollectionsRequest;
|
||||
use App\Http\Requests\Collections\SmartCollectionRulesRequest;
|
||||
use App\Http\Requests\Collections\StoreCollectionRequest;
|
||||
use App\Http\Requests\Collections\UpdateCollectionCampaignRequest;
|
||||
use App\Http\Requests\Collections\UpdateCollectionEntityLinksRequest;
|
||||
use App\Http\Requests\Collections\UpdateCollectionRequest;
|
||||
use App\Http\Requests\Collections\UpdateCollectionLifecycleRequest;
|
||||
use App\Http\Requests\Collections\UpdateCollectionLinkedCollectionsRequest;
|
||||
use App\Http\Requests\Collections\UpdateCollectionPresentationRequest;
|
||||
use App\Http\Requests\Collections\UpdateCollectionSeriesRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Services\CollectionCollaborationService;
|
||||
use App\Services\CollectionCampaignService;
|
||||
use App\Services\CollectionCommentService;
|
||||
use App\Services\CollectionLinkService;
|
||||
use App\Services\CollectionLinkedCollectionsService;
|
||||
use App\Services\CollectionMergeService;
|
||||
use App\Services\CollectionSeriesService;
|
||||
use App\Services\CollectionSubmissionService;
|
||||
use App\Services\CollectionService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CollectionManageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionCampaignService $campaigns,
|
||||
private readonly CollectionCollaborationService $collaborators,
|
||||
private readonly CollectionSubmissionService $submissions,
|
||||
private readonly CollectionCommentService $comments,
|
||||
private readonly CollectionLinkService $entityLinks,
|
||||
private readonly CollectionLinkedCollectionsService $linkedCollections,
|
||||
private readonly CollectionMergeService $merge,
|
||||
private readonly CollectionSeriesService $series,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->authorize('create', Collection::class);
|
||||
|
||||
$initialMode = $request->query('mode') === Collection::MODE_SMART
|
||||
? Collection::MODE_SMART
|
||||
: Collection::MODE_MANUAL;
|
||||
|
||||
return Inertia::render('Collection/CollectionManage', [
|
||||
'mode' => 'create',
|
||||
'collection' => null,
|
||||
'layoutModules' => $this->collections->getLayoutModuleDefinitions(),
|
||||
'attachedArtworks' => [],
|
||||
'availableArtworks' => [],
|
||||
'smartPreview' => null,
|
||||
'smartRuleOptions' => $this->collections->getSmartRuleOptions($request->user()),
|
||||
'initialMode' => $initialMode,
|
||||
'featuredLimit' => (int) config('collections.featured_limit', 3),
|
||||
'owner' => $this->ownerPayload($request),
|
||||
'members' => [],
|
||||
'submissions' => [],
|
||||
'comments' => [],
|
||||
'duplicateCandidates' => [],
|
||||
'canonicalTarget' => null,
|
||||
'inviteExpiryDays' => (int) config('collections.invites.expires_after_days', 7),
|
||||
'endpoints' => [
|
||||
'store' => route('settings.collections.store'),
|
||||
'smartPreview' => route('settings.collections.smart.preview'),
|
||||
'profileCollections' => route('profile.tab', [
|
||||
'username' => strtolower((string) $request->user()->username),
|
||||
'tab' => 'collections',
|
||||
]),
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, Collection $collection)
|
||||
{
|
||||
$this->authorize('manageArtworks', $collection);
|
||||
$collection->loadMissing(['user.profile', 'coverArtwork']);
|
||||
|
||||
return Inertia::render('Collection/CollectionManage', [
|
||||
'mode' => 'edit',
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
|
||||
'layoutModules' => $this->collections->mapCollectionDetailPayload($collection, true)['layout_modules'],
|
||||
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
|
||||
'availableArtworks' => $this->collections->getAvailableArtworkOptions($collection, $request->user()),
|
||||
'smartPreview' => $collection->isSmart() && is_array($collection->smart_rules_json)
|
||||
? $this->collections->previewSmartCollection($request->user(), $collection->smart_rules_json)
|
||||
: null,
|
||||
'smartRuleOptions' => $this->collections->getSmartRuleOptions($request->user()),
|
||||
'initialMode' => $collection->mode,
|
||||
'featuredLimit' => (int) config('collections.featured_limit', 3),
|
||||
'owner' => $this->ownerPayload($request),
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
|
||||
'comments' => $this->comments->mapComments($collection, $request->user()),
|
||||
'duplicateCandidates' => $this->merge->reviewCandidates($collection->loadMissing('user'), true),
|
||||
'canonicalTarget' => $collection->canonical_collection_id
|
||||
? $this->collections->mapCollectionCardPayloads([
|
||||
Collection::query()->findOrFail((int) $collection->canonical_collection_id)->loadMissing('user'),
|
||||
], true)[0]
|
||||
: null,
|
||||
'linkedCollections' => $this->collections->mapCollectionCardPayloads($this->linkedCollections->linkedCollections($collection), true),
|
||||
'linkedCollectionOptions' => $this->collections->mapCollectionCardPayloads($this->linkedCollections->manageableLinkOptions($collection, $request->user()), true),
|
||||
'entityLinks' => $this->entityLinks->links($collection, false),
|
||||
'entityLinkOptions' => $this->entityLinks->manageableOptions($collection),
|
||||
'inviteExpiryDays' => (int) config('collections.invites.expires_after_days', 7),
|
||||
'endpoints' => [
|
||||
'update' => route('settings.collections.update', ['collection' => $collection->id]),
|
||||
'updatePresentation' => route('settings.collections.presentation', ['collection' => $collection->id]),
|
||||
'updateCampaign' => route('settings.collections.campaign', ['collection' => $collection->id]),
|
||||
'updateSeries' => route('settings.collections.series', ['collection' => $collection->id]),
|
||||
'updateLifecycle' => route('settings.collections.lifecycle', ['collection' => $collection->id]),
|
||||
'syncLinkedCollections' => route('settings.collections.linked.sync', ['collection' => $collection->id]),
|
||||
'syncEntityLinks' => route('settings.collections.entity-links.sync', ['collection' => $collection->id]),
|
||||
'delete' => route('settings.collections.destroy', ['collection' => $collection->id]),
|
||||
'attach' => route('settings.collections.artworks.attach', ['collection' => $collection->id]),
|
||||
'reorder' => route('settings.collections.artworks.reorder', ['collection' => $collection->id]),
|
||||
'available' => route('settings.collections.artworks.available', ['collection' => $collection->id]),
|
||||
'removePattern' => route('settings.collections.artworks.remove', ['collection' => $collection->id, 'artwork' => '__ARTWORK__']),
|
||||
'edit' => route('settings.collections.edit', ['collection' => $collection->id]),
|
||||
'feature' => route('settings.collections.feature', ['collection' => $collection->id]),
|
||||
'unfeature' => route('settings.collections.unfeature', ['collection' => $collection->id]),
|
||||
'smartPreview' => route('settings.collections.smart.preview'),
|
||||
'aiSuggestTitle' => route('settings.collections.ai.suggest-title', ['collection' => $collection->id]),
|
||||
'aiSuggestSummary' => route('settings.collections.ai.suggest-summary', ['collection' => $collection->id]),
|
||||
'aiSuggestCover' => route('settings.collections.ai.suggest-cover', ['collection' => $collection->id]),
|
||||
'aiSuggestGrouping' => route('settings.collections.ai.suggest-grouping', ['collection' => $collection->id]),
|
||||
'aiSuggestRelatedArtworks' => route('settings.collections.ai.suggest-related-artworks', ['collection' => $collection->id]),
|
||||
'aiSuggestTags' => route('settings.collections.ai.suggest-tags', ['collection' => $collection->id]),
|
||||
'aiSuggestSeoDescription' => route('settings.collections.ai.suggest-seo-description', ['collection' => $collection->id]),
|
||||
'aiExplainSmartRules' => route('settings.collections.ai.explain-smart-rules', ['collection' => $collection->id]),
|
||||
'aiSuggestSplitThemes' => route('settings.collections.ai.suggest-split-themes', ['collection' => $collection->id]),
|
||||
'aiSuggestMergeIdea' => route('settings.collections.ai.suggest-merge-idea', ['collection' => $collection->id]),
|
||||
'aiQualityReview' => route('settings.collections.ai.quality-review', ['collection' => $collection->id]),
|
||||
'updateSmartRules' => route('settings.collections.smart.rules', ['collection' => $collection->id]),
|
||||
'inviteMember' => route('settings.collections.members.store', ['collection' => $collection->id]),
|
||||
'memberUpdatePattern' => route('settings.collections.members.update', ['collection' => $collection->id, 'member' => '__MEMBER__']),
|
||||
'memberTransferPattern' => route('settings.collections.members.transfer', ['collection' => $collection->id, 'member' => '__MEMBER__']),
|
||||
'memberDeletePattern' => route('settings.collections.members.destroy', ['collection' => $collection->id, 'member' => '__MEMBER__']),
|
||||
'acceptMemberPattern' => route('settings.collections.members.accept', ['member' => '__MEMBER__']),
|
||||
'declineMemberPattern' => route('settings.collections.members.decline', ['member' => '__MEMBER__']),
|
||||
'adminModerationUpdate' => $this->isAdmin($request) ? route('api.admin.collections.moderation.update', ['collection' => $collection->id]) : null,
|
||||
'adminInteractionsUpdate' => $this->isAdmin($request) ? route('api.admin.collections.interactions.update', ['collection' => $collection->id]) : null,
|
||||
'adminUnfeature' => $this->isAdmin($request) ? route('api.admin.collections.unfeature', ['collection' => $collection->id]) : null,
|
||||
'adminMemberRemovePattern' => $this->isAdmin($request) ? route('api.admin.collections.members.destroy', ['collection' => $collection->id, 'member' => '__MEMBER__']) : null,
|
||||
'submissionStore' => route('collections.submissions.store', ['collection' => $collection->id]),
|
||||
'submissionApprovePattern' => route('collections.submissions.approve', ['submission' => '__SUBMISSION__']),
|
||||
'submissionRejectPattern' => route('collections.submissions.reject', ['submission' => '__SUBMISSION__']),
|
||||
'submissionDeletePattern' => route('collections.submissions.destroy', ['submission' => '__SUBMISSION__']),
|
||||
'commentsIndex' => route('collections.comments.index', ['collection' => $collection->id]),
|
||||
'commentsStore' => route('collections.comments.store', ['collection' => $collection->id]),
|
||||
'commentDeletePattern' => route('collections.comments.destroy', ['collection' => $collection->id, 'comment' => '__COMMENT__']),
|
||||
'public' => route('profile.collections.show', [
|
||||
'username' => strtolower((string) $collection->user->username),
|
||||
'slug' => $collection->slug,
|
||||
]),
|
||||
'dashboard' => route('settings.collections.dashboard'),
|
||||
'analytics' => route('settings.collections.analytics', ['collection' => $collection->id]),
|
||||
'history' => route('settings.collections.history', ['collection' => $collection->id]),
|
||||
'canonicalize' => route('settings.collections.canonicalize', ['collection' => $collection->id]),
|
||||
'merge' => route('settings.collections.merge', ['collection' => $collection->id]),
|
||||
'rejectDuplicate' => route('settings.collections.merge.reject', ['collection' => $collection->id]),
|
||||
'staffSurfaces' => $this->isAdmin($request) ? route('settings.collections.surfaces.index') : null,
|
||||
'staffProgramming' => $this->isAdmin($request) ? route('staff.collections.programming') : null,
|
||||
'profileCollections' => route('profile.tab', [
|
||||
'username' => strtolower((string) $collection->user->username),
|
||||
'tab' => 'collections',
|
||||
]),
|
||||
],
|
||||
'viewer' => [
|
||||
'is_admin' => $this->isAdmin($request),
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Collection $collection)
|
||||
{
|
||||
return $this->show($request, $collection);
|
||||
}
|
||||
|
||||
public function store(StoreCollectionRequest $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$this->authorize('create', Collection::class);
|
||||
|
||||
$collection = $this->collections->createCollection($request->user(), $request->validated());
|
||||
$redirectTo = (string) route('settings.collections.show', ['collection' => $collection->id]);
|
||||
|
||||
return $this->jsonOrRedirect($request, [
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
|
||||
'comments' => $this->comments->mapComments($collection, $request->user()),
|
||||
'redirect' => $redirectTo,
|
||||
], $redirectTo);
|
||||
}
|
||||
|
||||
public function update(UpdateCollectionRequest $request, Collection $collection): RedirectResponse|JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection = $this->collections->updateCollection($collection->loadMissing('user'), $request->validated(), $request->user());
|
||||
$redirectTo = (string) route('settings.collections.show', ['collection' => $collection->id]);
|
||||
|
||||
return $this->jsonOrRedirect($request, [
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
|
||||
'comments' => $this->comments->mapComments($collection, $request->user()),
|
||||
'redirect' => $redirectTo,
|
||||
], $redirectTo);
|
||||
}
|
||||
|
||||
public function updatePresentation(UpdateCollectionPresentationRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return $this->updateScopedSettings($request, $collection, $request->validated());
|
||||
}
|
||||
|
||||
public function updateCampaign(UpdateCollectionCampaignRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection = $this->campaigns->updateCampaign($collection, $request->validated(), $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'campaign' => $this->campaigns->campaignSummary($collection),
|
||||
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
|
||||
'comments' => $this->comments->mapComments($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSeries(UpdateCollectionSeriesRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection = $this->series->updateSeries($collection, $request->validated(), $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'series' => $this->series->summary($collection),
|
||||
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
|
||||
'comments' => $this->comments->mapComments($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateLifecycle(UpdateCollectionLifecycleRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
return $this->updateScopedSettings($request, $collection, $request->validated());
|
||||
}
|
||||
|
||||
public function syncLinkedCollections(UpdateCollectionLinkedCollectionsRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection = $this->linkedCollections->syncLinks($collection->loadMissing('user'), $request->user(), $request->validated('related_collection_ids', []));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'linkedCollections' => $this->collections->mapCollectionCardPayloads($this->linkedCollections->linkedCollections($collection), true),
|
||||
'linkedCollectionOptions' => $this->collections->mapCollectionCardPayloads($this->linkedCollections->manageableLinkOptions($collection, $request->user()), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function syncEntityLinks(UpdateCollectionEntityLinksRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection = $this->entityLinks->syncLinks($collection->loadMissing('user'), $request->user(), $request->validated('entity_links', []));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'entityLinks' => $this->entityLinks->links($collection, false),
|
||||
'entityLinkOptions' => $this->entityLinks->manageableOptions($collection),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Collection $collection): RedirectResponse|JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $collection);
|
||||
|
||||
$profileCollectionsUrl = (string) route('profile.tab', [
|
||||
'username' => strtolower((string) $collection->user->username),
|
||||
'tab' => 'collections',
|
||||
]);
|
||||
|
||||
$this->collections->deleteCollection($collection);
|
||||
|
||||
return $this->jsonOrRedirect($request, [
|
||||
'ok' => true,
|
||||
'redirect' => $profileCollectionsUrl,
|
||||
], $profileCollectionsUrl);
|
||||
}
|
||||
|
||||
public function attachArtworks(AttachCollectionArtworksRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('manageArtworks', $collection);
|
||||
|
||||
$collection = $this->collections->attachArtworks($collection->loadMissing('user'), $request->user(), $request->validated('artwork_ids'));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
|
||||
'availableArtworks' => $this->collections->getAvailableArtworkOptions($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function artworkCollectionOptions(Request $request): JsonResponse
|
||||
{
|
||||
$artwork = $this->resolveArtworkFromRequest($request, 4);
|
||||
|
||||
abort_unless((int) $artwork->user_id === (int) $request->user()->id, 404);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->collections->getCollectionOptionsForArtwork($request->user(), $artwork),
|
||||
'meta' => [
|
||||
'create_url' => route('settings.collections.create'),
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeArtwork(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('manageArtworks', $collection);
|
||||
|
||||
$artwork = $this->resolveArtworkFromRequest($request, 5);
|
||||
|
||||
$isAttached = $collection->artworks()->where('artworks.id', $artwork->id)->exists();
|
||||
abort_unless($isAttached, 404);
|
||||
|
||||
$collection = $this->collections->removeArtwork($collection->loadMissing('user'), $artwork);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
|
||||
'availableArtworks' => $this->collections->getAvailableArtworkOptions($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reorderArtworks(ReorderCollectionArtworksRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('manageArtworks', $collection);
|
||||
|
||||
$collection = $this->collections->reorderArtworks($collection->loadMissing('user'), $request->validated('ordered_artwork_ids'));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
|
||||
]);
|
||||
}
|
||||
|
||||
public function availableArtworks(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('manageArtworks', $collection);
|
||||
|
||||
$request->validate([
|
||||
'search' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->collections->getAvailableArtworkOptions($collection, $request->user(), (string) $request->input('search', '')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function feature(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection = $this->collections->featureCollection($collection);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unfeature(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection = $this->collections->unfeatureCollection($collection);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reorderProfile(ReorderProfileCollectionsRequest $request): JsonResponse
|
||||
{
|
||||
$this->collections->reorderProfileCollections($request->user(), $request->validated('collection_ids'));
|
||||
|
||||
$items = $this->collections->getProfileCollections($request->user(), $request->user(), 24);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collections' => $this->collections->mapCollectionCardPayloads($items, true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function smartPreview(SmartCollectionRulesRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'preview' => $this->collections->previewSmartCollection($request->user(), $request->validated('smart_rules_json')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSmartRules(SmartCollectionRulesRequest $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $collection);
|
||||
|
||||
$collection = $this->collections->updateCollection($collection->loadMissing('user'), [
|
||||
'title' => $collection->title,
|
||||
'slug' => $collection->slug,
|
||||
'description' => $collection->description,
|
||||
'subtitle' => $collection->subtitle,
|
||||
'summary' => $collection->summary,
|
||||
'visibility' => $collection->visibility,
|
||||
'mode' => Collection::MODE_SMART,
|
||||
'sort_mode' => (string) (($request->validated('smart_rules_json')['sort'] ?? null) ?: $collection->sort_mode),
|
||||
'smart_rules_json' => $request->validated('smart_rules_json'),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'preview' => $this->collections->previewSmartCollection($request->user(), $collection->smart_rules_json ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
private function ownerPayload(Request $request): array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 96),
|
||||
];
|
||||
}
|
||||
|
||||
private function isAdmin(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return $user !== null && method_exists($user, 'hasRole') && $user->hasRole('admin');
|
||||
}
|
||||
|
||||
private function updateScopedSettings(Request $request, Collection $collection, array $attributes): JsonResponse
|
||||
{
|
||||
$collection = $this->collections->updateCollection($collection->loadMissing('user'), $attributes, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
|
||||
'comments' => $this->comments->mapComments($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
private function jsonOrRedirect(Request $request, array $payload, string $redirectTo): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
return redirect($redirectTo);
|
||||
}
|
||||
|
||||
private function resolveArtworkFromRequest(Request $request, int $fallbackSegment): Artwork
|
||||
{
|
||||
$routeValue = $request->route('artwork');
|
||||
|
||||
if ($routeValue instanceof Artwork) {
|
||||
return $routeValue;
|
||||
}
|
||||
|
||||
$artworkId = is_scalar($routeValue)
|
||||
? (int) $routeValue
|
||||
: (int) $request->segment($fallbackSegment);
|
||||
|
||||
return Artwork::query()->findOrFail($artworkId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Collections\CollectionProgramAssignmentRequest;
|
||||
use App\Http\Requests\Collections\CollectionProgrammingCollectionRequest;
|
||||
use App\Http\Requests\Collections\CollectionProgrammingMergePairRequest;
|
||||
use App\Http\Requests\Collections\CollectionProgrammingMetadataRequest;
|
||||
use App\Http\Requests\Collections\CollectionProgrammingPreviewRequest;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionProgramAssignment;
|
||||
use App\Services\CollectionBackgroundJobService;
|
||||
use App\Services\CollectionObservabilityService;
|
||||
use App\Services\CollectionProgrammingService;
|
||||
use App\Services\CollectionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CollectionProgrammingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionProgrammingService $programming,
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionBackgroundJobService $backgroundJobs,
|
||||
private readonly CollectionObservabilityService $observability,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response|JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$assignments = $this->programming->assignments();
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'assignments' => $assignments->map(fn (CollectionProgramAssignment $assignment): array => $this->mapAssignment($assignment))->values()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
$collectionOptions = Collection::query()
|
||||
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
|
||||
->where(function ($query): void {
|
||||
$query->where(function ($public): void {
|
||||
$public->where('visibility', Collection::VISIBILITY_PUBLIC)
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED])
|
||||
->where('moderation_status', Collection::MODERATION_ACTIVE);
|
||||
})->orWhereNotNull('program_key');
|
||||
})
|
||||
->orderByDesc('ranking_score')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(40)
|
||||
->get();
|
||||
|
||||
$programKeyOptions = $assignments->pluck('program_key')
|
||||
->merge($collectionOptions->pluck('program_key'))
|
||||
->filter(fn ($value): bool => is_string($value) && $value !== '')
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('Collection/CollectionStaffProgramming', [
|
||||
'assignments' => $assignments->map(fn (CollectionProgramAssignment $assignment): array => $this->mapAssignment($assignment))->values()->all(),
|
||||
'collectionOptions' => $this->collections->mapCollectionCardPayloads($collectionOptions, true),
|
||||
'programKeyOptions' => $programKeyOptions,
|
||||
'mergeQueue' => $this->programming->mergeQueue(true),
|
||||
'observabilitySummary' => $this->observability->summary(),
|
||||
'historyPattern' => route('settings.collections.history', ['collection' => '__COLLECTION__']),
|
||||
'viewer' => [
|
||||
'isAdmin' => $this->isAdmin($request),
|
||||
],
|
||||
'endpoints' => [
|
||||
'store' => route('staff.collections.programs.store'),
|
||||
'updatePattern' => route('staff.collections.programs.update', ['program' => '__PROGRAM__']),
|
||||
'publicProgramPattern' => route('collections.program.show', ['programKey' => '__PROGRAM__']),
|
||||
'preview' => route('staff.collections.surfaces.preview'),
|
||||
'refreshEligibility' => route('staff.collections.eligibility.refresh'),
|
||||
'duplicateScan' => route('staff.collections.duplicate-scan'),
|
||||
'refreshRecommendations' => route('staff.collections.recommendation-refresh'),
|
||||
'metadataUpdate' => route('staff.collections.metadata.update'),
|
||||
'canonicalizeCandidate' => route('staff.collections.merge-queue.canonicalize'),
|
||||
'mergeCandidate' => route('staff.collections.merge-queue.merge'),
|
||||
'rejectCandidate' => route('staff.collections.merge-queue.reject'),
|
||||
'managePattern' => route('settings.collections.show', ['collection' => '__COLLECTION__']),
|
||||
'surfaces' => route('settings.collections.surfaces.index'),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => 'Collection Programming — Skinbase Nova',
|
||||
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
|
||||
'canonical' => route('staff.collections.programming'),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function storeProgram(CollectionProgramAssignmentRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$assignment = $this->programming->upsertAssignment($request->validated(), $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'assignment' => $this->mapAssignment($assignment),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProgram(CollectionProgramAssignmentRequest $request, CollectionProgramAssignment $program): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$payload = $request->validated();
|
||||
$payload['id'] = (int) $program->id;
|
||||
|
||||
$assignment = $this->programming->upsertAssignment($payload, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'assignment' => $this->mapAssignment($assignment),
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(CollectionProgrammingPreviewRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collections' => $this->collections->mapCollectionCardPayloads(
|
||||
$this->programming->previewProgram((string) $payload['program_key'], (int) ($payload['limit'] ?? 12)),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function refreshEligibility(CollectionProgrammingCollectionRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$collection = $this->resolveCollection($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'queued' => true,
|
||||
'result' => $this->backgroundJobs->dispatchHealthRefresh($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function duplicateScan(CollectionProgrammingCollectionRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$collection = $this->resolveCollection($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'queued' => true,
|
||||
'result' => $this->backgroundJobs->dispatchDuplicateScan($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function refreshRecommendations(CollectionProgrammingCollectionRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$collection = $this->resolveCollection($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'queued' => true,
|
||||
'result' => $this->backgroundJobs->dispatchRecommendationRefresh($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateMetadata(CollectionProgrammingMetadataRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
$collection = Collection::query()->findOrFail((int) $payload['collection_id']);
|
||||
$result = $this->programming->syncHooks($collection, $payload, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Experiment and program governance hooks updated.',
|
||||
'collection' => $this->collections->mapCollectionCardPayloads([$result['collection']->loadMissing('user')], true)[0],
|
||||
'diagnostics' => $result['diagnostics'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function canonicalizeCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
[$source, $target] = $this->resolveMergePair($request);
|
||||
$payload = $this->programming->canonicalizePair($source, $target, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Canonical target updated from the staff merge queue.',
|
||||
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
|
||||
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
|
||||
'mergeQueue' => $payload['mergeQueue'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function mergeCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
[$source, $target] = $this->resolveMergePair($request);
|
||||
$payload = $this->programming->mergePair($source, $target, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Collections merged from the staff merge queue.',
|
||||
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
|
||||
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
|
||||
'attached_artwork_ids' => $payload['attached_artwork_ids'],
|
||||
'mergeQueue' => $payload['mergeQueue'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function rejectCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
[$source, $target] = $this->resolveMergePair($request);
|
||||
$payload = $this->programming->rejectPair($source, $target, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Duplicate candidate dismissed from the staff merge queue.',
|
||||
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
|
||||
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
|
||||
'mergeQueue' => $payload['mergeQueue'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveCollection(CollectionProgrammingCollectionRequest $request): ?Collection
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
if (! isset($payload['collection_id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Collection::query()->find((int) $payload['collection_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Collection, 1: Collection}
|
||||
*/
|
||||
private function resolveMergePair(CollectionProgrammingMergePairRequest $request): array
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
return [
|
||||
Collection::query()->findOrFail((int) $payload['source_collection_id']),
|
||||
Collection::query()->findOrFail((int) $payload['target_collection_id']),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapAssignment(CollectionProgramAssignment $assignment): array
|
||||
{
|
||||
$assignment->loadMissing(['collection.user', 'creator']);
|
||||
|
||||
return [
|
||||
'id' => (int) $assignment->id,
|
||||
'program_key' => (string) $assignment->program_key,
|
||||
'campaign_key' => $assignment->campaign_key,
|
||||
'placement_scope' => $assignment->placement_scope,
|
||||
'starts_at' => optional($assignment->starts_at)?->toISOString(),
|
||||
'ends_at' => optional($assignment->ends_at)?->toISOString(),
|
||||
'priority' => (int) $assignment->priority,
|
||||
'notes' => $assignment->notes,
|
||||
'collection' => $this->collections->mapCollectionCardPayloads([$assignment->collection], true)[0],
|
||||
'creator' => $assignment->creator ? [
|
||||
'id' => (int) $assignment->creator->id,
|
||||
'username' => (string) $assignment->creator->username,
|
||||
'name' => $assignment->creator->name,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function authorizeStaff(Request $request): void
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless($user && ($user->isAdmin() || $user->isModerator()), 403);
|
||||
}
|
||||
|
||||
private function isAdmin(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return $user !== null && $user->isAdmin();
|
||||
}
|
||||
}
|
||||
459
app/Http/Controllers/Settings/CollectionSurfaceController.php
Normal file
459
app/Http/Controllers/Settings/CollectionSurfaceController.php
Normal file
@@ -0,0 +1,459 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionSurfaceDefinition;
|
||||
use App\Models\CollectionSurfacePlacement;
|
||||
use App\Services\CollectionCampaignService;
|
||||
use App\Services\CollectionHistoryService;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\CollectionSurfaceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CollectionSurfaceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionSurfaceService $surfaces,
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionHistoryService $history,
|
||||
private readonly CollectionCampaignService $campaigns,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$conflicts = $this->surfaces->placementConflicts();
|
||||
$conflictPlacementIds = $conflicts->pluck('placement_ids')->flatten()->unique()->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
$definitions = $this->surfaces->definitions()->map(function ($definition): array {
|
||||
return $this->mapDefinition($definition);
|
||||
})->values()->all();
|
||||
|
||||
$placements = $this->surfaces->placements()->map(function ($placement) use ($conflictPlacementIds): array {
|
||||
return array_merge($this->mapPlacement($placement), [
|
||||
'has_conflict' => in_array((int) $placement->id, $conflictPlacementIds, true),
|
||||
]);
|
||||
})->values()->all();
|
||||
|
||||
return Inertia::render('Collection/CollectionStaffSurfaces', [
|
||||
'definitions' => $definitions,
|
||||
'placements' => $placements,
|
||||
'conflicts' => $conflicts->all(),
|
||||
'surfaceKeyOptions' => collect($definitions)->pluck('surface_key')->values()->all(),
|
||||
'collectionOptions' => $this->collections->mapCollectionCardPayloads(
|
||||
Collection::query()->public()->orderByDesc('ranking_score')->limit(30)->get(),
|
||||
true,
|
||||
),
|
||||
'endpoints' => [
|
||||
'definitionsStore' => route('settings.collections.surfaces.definitions.store'),
|
||||
'definitionsUpdatePattern' => route('settings.collections.surfaces.definitions.update', ['definition' => '__DEFINITION__']),
|
||||
'definitionsDeletePattern' => route('settings.collections.surfaces.definitions.destroy', ['definition' => '__DEFINITION__']),
|
||||
'placementsStore' => route('settings.collections.surfaces.placements.store'),
|
||||
'placementsUpdatePattern' => route('settings.collections.surfaces.placements.update', ['placement' => '__PLACEMENT__']),
|
||||
'placementsDeletePattern' => route('settings.collections.surfaces.placements.destroy', ['placement' => '__PLACEMENT__']),
|
||||
'previewPattern' => route('settings.collections.surfaces.preview', ['definition' => '__DEFINITION__']),
|
||||
'batchEditorial' => route('settings.collections.surfaces.batch-editorial'),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => 'Collection Surfaces - Skinbase Nova',
|
||||
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
|
||||
'canonical' => route('settings.collections.surfaces.index'),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function storeDefinition(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$definition = $this->surfaces->upsertDefinition($this->validateDefinition($request));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'definition' => $this->mapDefinition($definition->fresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateDefinition(Request $request, CollectionSurfaceDefinition $definition): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$payload = $this->validateDefinition($request);
|
||||
$payload['surface_key'] = $definition->surface_key;
|
||||
|
||||
$updatedDefinition = $this->surfaces->upsertDefinition($payload);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'definition' => $this->mapDefinition($updatedDefinition->fresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyDefinition(Request $request, CollectionSurfaceDefinition $definition): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
abort_if(
|
||||
CollectionSurfacePlacement::query()->where('surface_key', $definition->surface_key)->exists(),
|
||||
422,
|
||||
'Remove all placements from this surface before deleting the definition.'
|
||||
);
|
||||
|
||||
$deletedId = (int) $definition->id;
|
||||
$definition->delete();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'deleted_definition_id' => $deletedId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storePlacement(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$payload = $this->validatePlacement($request);
|
||||
$payload['created_by_user_id'] = $request->user()?->id;
|
||||
|
||||
$collection = Collection::query()->findOrFail((int) $payload['collection_id']);
|
||||
abort_unless($collection->isFeatureablePublicly(), 422, 'Only public, active collections can be placed on public surfaces.');
|
||||
|
||||
$placement = $this->surfaces->upsertPlacement($payload)->loadMissing([
|
||||
'collection.user:id,username,name',
|
||||
'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
]);
|
||||
|
||||
$this->history->record(
|
||||
$collection->fresh(),
|
||||
$request->user(),
|
||||
'placement_assigned',
|
||||
'Collection assigned to a staff surface.',
|
||||
null,
|
||||
$this->placementHistoryPayload($placement)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'placement' => $this->mapPlacement($placement),
|
||||
'conflicts' => $this->surfaces->placementConflicts()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePlacement(Request $request, CollectionSurfacePlacement $placement): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$before = $this->placementHistoryPayload($placement);
|
||||
$originalCollectionId = (int) $placement->collection_id;
|
||||
$payload = $this->validatePlacement($request);
|
||||
$payload['id'] = (int) $placement->id;
|
||||
$payload['created_by_user_id'] = $placement->created_by_user_id ?: $request->user()?->id;
|
||||
|
||||
$collection = Collection::query()->findOrFail((int) $payload['collection_id']);
|
||||
abort_unless($collection->isFeatureablePublicly(), 422, 'Only public, active collections can be placed on public surfaces.');
|
||||
|
||||
$updatedPlacement = $this->surfaces->upsertPlacement($payload)->loadMissing([
|
||||
'collection.user:id,username,name',
|
||||
'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
]);
|
||||
|
||||
if ($originalCollectionId !== (int) $updatedPlacement->collection_id) {
|
||||
$originalCollection = Collection::query()->find($originalCollectionId);
|
||||
if ($originalCollection) {
|
||||
$this->history->record(
|
||||
$originalCollection->fresh(),
|
||||
$request->user(),
|
||||
'placement_removed',
|
||||
'Collection removed from a staff surface assignment.',
|
||||
$before,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
$this->history->record(
|
||||
$collection->fresh(),
|
||||
$request->user(),
|
||||
'placement_assigned',
|
||||
'Collection assigned to a staff surface.',
|
||||
null,
|
||||
$this->placementHistoryPayload($updatedPlacement)
|
||||
);
|
||||
} else {
|
||||
$this->history->record(
|
||||
$collection->fresh(),
|
||||
$request->user(),
|
||||
'placement_updated',
|
||||
'Collection staff surface assignment updated.',
|
||||
$before,
|
||||
$this->placementHistoryPayload($updatedPlacement)
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'placement' => $this->mapPlacement($updatedPlacement),
|
||||
'conflicts' => $this->surfaces->placementConflicts()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyPlacement(Request $request, CollectionSurfacePlacement $placement): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$before = $this->placementHistoryPayload($placement);
|
||||
$collection = $placement->collection()->first();
|
||||
$deletedId = (int) $placement->id;
|
||||
|
||||
$this->surfaces->deletePlacement($placement);
|
||||
|
||||
if ($collection) {
|
||||
$this->history->record(
|
||||
$collection->fresh(),
|
||||
$request->user(),
|
||||
'placement_removed',
|
||||
'Collection removed from a staff surface assignment.',
|
||||
$before,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'deleted_placement_id' => $deletedId,
|
||||
'conflicts' => $this->surfaces->placementConflicts()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(Request $request, CollectionSurfaceDefinition $definition): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$items = $this->surfaces->resolveSurfaceItems(
|
||||
$definition->surface_key,
|
||||
(int) $request->integer('limit', (int) $definition->max_items)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collections' => $this->collections->mapCollectionCardPayloads($items, true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function batchEditorial(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeStaff($request);
|
||||
|
||||
$payload = $this->validateBatchEditorial($request);
|
||||
$collectionIds = collect($payload['collection_ids'] ?? [])->map(fn ($id) => (int) $id)->filter()->values()->all();
|
||||
abort_if(count($collectionIds) === 0, 422, 'Choose at least one collection for the batch editorial run.');
|
||||
|
||||
if (! ($payload['apply'] ?? false)) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'mode' => 'preview',
|
||||
'plan' => $this->campaigns->batchEditorialPlan($collectionIds, $payload),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = DB::transaction(function () use ($collectionIds, $payload, $request): array {
|
||||
$actor = $request->user();
|
||||
$applied = $this->campaigns->applyBatchEditorialPlan($collectionIds, $payload, $actor);
|
||||
$resultsByCollection = collect($applied['results'] ?? [])->keyBy('collection_id');
|
||||
|
||||
foreach ($applied['plan']['items'] as $item) {
|
||||
$collectionId = (int) Arr::get($item, 'collection.id');
|
||||
$collection = Collection::query()->find($collectionId);
|
||||
$itemResult = $resultsByCollection->get($collectionId, []);
|
||||
|
||||
if (! $collection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summaryParts = [];
|
||||
if (count($item['campaign_updates'] ?? []) > 0) {
|
||||
$summaryParts[] = 'campaign metadata refreshed';
|
||||
}
|
||||
if (is_array($item['placement'] ?? null)) {
|
||||
$summaryParts[] = ($item['placement']['eligible'] ?? false)
|
||||
? sprintf('placement planned for %s', (string) $item['placement']['surface_key'])
|
||||
: 'placement skipped';
|
||||
}
|
||||
|
||||
if (($itemResult['placement']['status'] ?? null) === 'created') {
|
||||
$this->history->record(
|
||||
$collection->fresh(),
|
||||
$actor,
|
||||
'placement_assigned',
|
||||
'Collection assigned to a staff surface via batch editorial tools.',
|
||||
null,
|
||||
$item['placement'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
if (($itemResult['placement']['status'] ?? null) === 'updated') {
|
||||
$this->history->record(
|
||||
$collection->fresh(),
|
||||
$actor,
|
||||
'placement_updated',
|
||||
'Collection staff surface assignment updated via batch editorial tools.',
|
||||
null,
|
||||
$item['placement'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
$this->history->record(
|
||||
$collection->fresh(),
|
||||
$actor,
|
||||
'batch_editorial_updated',
|
||||
'Staff batch editorial tools updated campaign planning.',
|
||||
null,
|
||||
[
|
||||
'summary' => $summaryParts,
|
||||
'campaign_updates' => $item['campaign_updates'] ?? [],
|
||||
'placement' => $item['placement'] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return $applied;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'mode' => 'apply',
|
||||
'plan' => $result['plan'],
|
||||
'results' => $result['results'],
|
||||
'placements' => $this->surfaces->placements()->map(fn ($placement): array => $this->mapPlacement($placement))->values()->all(),
|
||||
'conflicts' => $this->surfaces->placementConflicts()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeStaff(Request $request): void
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless($user && ($user->isAdmin() || $user->isModerator()), 403);
|
||||
}
|
||||
|
||||
private function validateDefinition(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'surface_key' => ['required', 'string', 'max:120'],
|
||||
'title' => ['required', 'string', 'max:160'],
|
||||
'description' => ['nullable', 'string', 'max:400'],
|
||||
'mode' => ['required', 'in:manual,automatic,hybrid'],
|
||||
'ranking_mode' => ['required', 'in:ranking_score,recent_activity,quality_score'],
|
||||
'max_items' => ['nullable', 'integer', 'min:1', 'max:24'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'starts_at' => ['nullable', 'date'],
|
||||
'ends_at' => ['nullable', 'date', 'after:starts_at'],
|
||||
'fallback_surface_key' => ['nullable', 'string', 'max:120', 'different:surface_key'],
|
||||
'rules_json' => ['nullable', 'array'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function validatePlacement(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'id' => ['nullable', 'integer', 'exists:collection_surface_placements,id'],
|
||||
'surface_key' => ['required', 'string', 'max:120'],
|
||||
'collection_id' => ['required', 'integer', 'exists:collections,id'],
|
||||
'placement_type' => ['required', 'in:manual,campaign,scheduled_override'],
|
||||
'priority' => ['nullable', 'integer', 'min:-100', 'max:100'],
|
||||
'starts_at' => ['nullable', 'date'],
|
||||
'ends_at' => ['nullable', 'date', 'after:starts_at'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'campaign_key' => ['nullable', 'string', 'max:80'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateBatchEditorial(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'collection_ids' => ['required', 'array', 'min:1', 'max:24'],
|
||||
'collection_ids.*' => ['integer', 'distinct', 'exists:collections,id'],
|
||||
'campaign_key' => ['nullable', 'string', 'max:80'],
|
||||
'campaign_label' => ['nullable', 'string', 'max:120'],
|
||||
'event_key' => ['nullable', 'string', 'max:80'],
|
||||
'event_label' => ['nullable', 'string', 'max:120'],
|
||||
'season_key' => ['nullable', 'string', 'max:80'],
|
||||
'banner_text' => ['nullable', 'string', 'max:160'],
|
||||
'badge_label' => ['nullable', 'string', 'max:80'],
|
||||
'spotlight_style' => ['nullable', 'string', 'max:60'],
|
||||
'editorial_notes' => ['nullable', 'string', 'max:4000'],
|
||||
'surface_key' => ['nullable', 'string', 'max:120'],
|
||||
'placement_type' => ['nullable', 'string', 'in:manual,campaign,scheduled_override'],
|
||||
'priority' => ['nullable', 'integer', 'min:-100', 'max:100'],
|
||||
'starts_at' => ['nullable', 'date'],
|
||||
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'apply' => ['nullable', 'boolean'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapDefinition(CollectionSurfaceDefinition $definition): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $definition->id,
|
||||
'surface_key' => $definition->surface_key,
|
||||
'title' => $definition->title,
|
||||
'description' => $definition->description,
|
||||
'mode' => $definition->mode,
|
||||
'ranking_mode' => $definition->ranking_mode,
|
||||
'max_items' => (int) $definition->max_items,
|
||||
'is_active' => (bool) $definition->is_active,
|
||||
'starts_at' => $definition->starts_at?->toISOString(),
|
||||
'ends_at' => $definition->ends_at?->toISOString(),
|
||||
'fallback_surface_key' => $definition->fallback_surface_key,
|
||||
'rules_json' => $definition->rules_json,
|
||||
];
|
||||
}
|
||||
|
||||
private function mapPlacement(CollectionSurfacePlacement $placement): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $placement->id,
|
||||
'surface_key' => $placement->surface_key,
|
||||
'placement_type' => $placement->placement_type,
|
||||
'priority' => (int) $placement->priority,
|
||||
'starts_at' => $placement->starts_at?->toISOString(),
|
||||
'ends_at' => $placement->ends_at?->toISOString(),
|
||||
'is_active' => (bool) $placement->is_active,
|
||||
'campaign_key' => $placement->campaign_key,
|
||||
'notes' => $placement->notes,
|
||||
'collection' => $placement->collection
|
||||
? ($this->collections->mapCollectionCardPayloads(collect([$placement->collection]), true)[0] ?? null)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
private function placementHistoryPayload(CollectionSurfacePlacement $placement): array
|
||||
{
|
||||
return [
|
||||
'placement_id' => (int) $placement->id,
|
||||
'surface_key' => (string) $placement->surface_key,
|
||||
'collection_id' => (int) $placement->collection_id,
|
||||
'placement_type' => (string) $placement->placement_type,
|
||||
'priority' => (int) $placement->priority,
|
||||
'starts_at' => $placement->starts_at?->toISOString(),
|
||||
'ends_at' => $placement->ends_at?->toISOString(),
|
||||
'is_active' => (bool) $placement->is_active,
|
||||
'campaign_key' => $placement->campaign_key,
|
||||
'notes' => $placement->notes,
|
||||
];
|
||||
}
|
||||
}
|
||||
545
app/Http/Controllers/Settings/NovaCardAdminController.php
Normal file
545
app/Http/Controllers/Settings/NovaCardAdminController.php
Normal file
@@ -0,0 +1,545 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\NovaCards\AdminStoreNovaCardAssetPackRequest;
|
||||
use App\Http\Requests\NovaCards\AdminStoreNovaCardCategoryRequest;
|
||||
use App\Http\Requests\NovaCards\AdminStoreNovaCardChallengeRequest;
|
||||
use App\Http\Requests\NovaCards\AdminStoreNovaCardTemplateRequest;
|
||||
use App\Http\Requests\NovaCards\AdminUpdateNovaCardRequest;
|
||||
use App\Models\NovaCardAssetPack;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardCategory;
|
||||
use App\Models\NovaCardChallenge;
|
||||
use App\Models\NovaCardCollection;
|
||||
use App\Models\Report;
|
||||
use App\Models\NovaCardTemplate;
|
||||
use App\Models\User;
|
||||
use App\Services\NovaCards\NovaCardCollectionService;
|
||||
use App\Services\NovaCards\NovaCardPublishModerationService;
|
||||
use App\Services\NovaCards\NovaCardPresenter;
|
||||
use App\Support\Moderation\ReportTargetResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class NovaCardAdminController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardPresenter $presenter,
|
||||
private readonly ReportTargetResolver $reportTargets,
|
||||
private readonly NovaCardCollectionService $collections,
|
||||
private readonly NovaCardPublishModerationService $moderation,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$cards = NovaCard::query()
|
||||
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
|
||||
->latest('updated_at')
|
||||
->paginate(24)
|
||||
->withQueryString();
|
||||
$featuredCreators = User::query()
|
||||
->whereHas('novaCards', fn ($query) => $query->publiclyVisible())
|
||||
->withCount([
|
||||
'novaCards as public_cards_count' => fn ($query) => $query->publiclyVisible(),
|
||||
'novaCards as featured_cards_count' => fn ($query) => $query->publiclyVisible()->where('featured', true),
|
||||
])
|
||||
->withSum([
|
||||
'novaCards as total_views_count' => fn ($query) => $query->publiclyVisible(),
|
||||
], 'views_count')
|
||||
->orderByDesc('nova_featured_creator')
|
||||
->orderByDesc('featured_cards_count')
|
||||
->orderByDesc('public_cards_count')
|
||||
->orderBy('username')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
$reportCounts = Report::query()
|
||||
->selectRaw('status, COUNT(*) as aggregate')
|
||||
->whereIn('target_type', $this->reportTargets->novaCardTargetTypes())
|
||||
->groupBy('status')
|
||||
->pluck('aggregate', 'status');
|
||||
|
||||
return Inertia::render('Collection/NovaCardsAdminIndex', [
|
||||
'cards' => $this->presenter->paginator($cards, false, $request->user()),
|
||||
'featuredCreators' => $featuredCreators->map(fn (User $creator): array => [
|
||||
'id' => (int) $creator->id,
|
||||
'username' => (string) $creator->username,
|
||||
'name' => $creator->name,
|
||||
'display_name' => $creator->name ?: '@' . $creator->username,
|
||||
'public_url' => route('cards.creator', ['username' => $creator->username]),
|
||||
'nova_featured_creator' => (bool) $creator->nova_featured_creator,
|
||||
'public_cards_count' => (int) ($creator->public_cards_count ?? 0),
|
||||
'featured_cards_count' => (int) ($creator->featured_cards_count ?? 0),
|
||||
'total_views_count' => (int) ($creator->total_views_count ?? 0),
|
||||
])->values()->all(),
|
||||
'categories' => NovaCardCategory::query()->orderBy('order_num')->orderBy('name')->get()->map(fn (NovaCardCategory $category): array => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => (string) $category->name,
|
||||
'description' => $category->description,
|
||||
'active' => (bool) $category->active,
|
||||
'order_num' => (int) $category->order_num,
|
||||
'cards_count' => (int) $category->cards()->count(),
|
||||
])->values()->all(),
|
||||
'stats' => [
|
||||
'pending' => NovaCard::query()->where('moderation_status', NovaCard::MOD_PENDING)->count(),
|
||||
'flagged' => NovaCard::query()->where('moderation_status', NovaCard::MOD_FLAGGED)->count(),
|
||||
'featured' => NovaCard::query()->where('featured', true)->count(),
|
||||
'published' => NovaCard::query()->where('status', NovaCard::STATUS_PUBLISHED)->count(),
|
||||
'remixable' => NovaCard::query()->where('allow_remix', true)->count(),
|
||||
'challenges' => NovaCardChallenge::query()->count(),
|
||||
],
|
||||
'reportingQueue' => [
|
||||
'enabled' => true,
|
||||
'pending' => (int) ($reportCounts['open'] ?? 0),
|
||||
'label' => 'Nova Cards report queue',
|
||||
'description' => 'Review open, investigating, and resolved reports for cards, challenge prompts, and challenge entries.',
|
||||
'statuses' => [
|
||||
'open' => (int) ($reportCounts['open'] ?? 0),
|
||||
'reviewing' => (int) ($reportCounts['reviewing'] ?? 0),
|
||||
'closed' => (int) ($reportCounts['closed'] ?? 0),
|
||||
],
|
||||
],
|
||||
'endpoints' => [
|
||||
'updateCardPattern' => route('cp.cards.update', ['card' => '__CARD__']),
|
||||
'updateCreatorPattern' => route('cp.cards.creators.update', ['user' => '__CREATOR__']),
|
||||
'templates' => route('cp.cards.templates.index'),
|
||||
'assetPacks' => route('cp.cards.asset-packs.index'),
|
||||
'challenges' => route('cp.cards.challenges.index'),
|
||||
'collections' => route('cp.cards.collections.index'),
|
||||
'storeCategory' => route('cp.cards.categories.store'),
|
||||
'updateCategoryPattern' => route('cp.cards.categories.update', ['category' => '__CATEGORY__']),
|
||||
'reportsQueue' => route('api.admin.reports.queue', ['group' => 'nova_cards']),
|
||||
'updateReportPattern' => route('api.admin.reports.update', ['report' => '__REPORT__']),
|
||||
'moderateReportTargetPattern' => route('api.admin.reports.moderate-target', ['report' => '__REPORT__']),
|
||||
],
|
||||
'moderationDispositionOptions' => [
|
||||
NovaCard::MOD_PENDING => $this->moderation->dispositionOptions(NovaCard::MOD_PENDING),
|
||||
NovaCard::MOD_APPROVED => $this->moderation->dispositionOptions(NovaCard::MOD_APPROVED),
|
||||
NovaCard::MOD_FLAGGED => $this->moderation->dispositionOptions(NovaCard::MOD_FLAGGED),
|
||||
NovaCard::MOD_REJECTED => $this->moderation->dispositionOptions(NovaCard::MOD_REJECTED),
|
||||
],
|
||||
'editorOptions' => $this->presenter->options(),
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function templates(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Collection/NovaCardsTemplateAdmin', [
|
||||
'templates' => NovaCardTemplate::query()->orderBy('order_num')->orderBy('name')->get()->map(fn (NovaCardTemplate $template): array => [
|
||||
'id' => (int) $template->id,
|
||||
'slug' => (string) $template->slug,
|
||||
'name' => (string) $template->name,
|
||||
'description' => $template->description,
|
||||
'preview_image' => $template->preview_image,
|
||||
'config_json' => $template->config_json,
|
||||
'supported_formats' => $template->supported_formats,
|
||||
'active' => (bool) $template->active,
|
||||
'official' => (bool) $template->official,
|
||||
'order_num' => (int) $template->order_num,
|
||||
])->values()->all(),
|
||||
'editorOptions' => $this->presenter->options(),
|
||||
'endpoints' => [
|
||||
'store' => route('cp.cards.templates.store'),
|
||||
'updatePattern' => route('cp.cards.templates.update', ['template' => '__TEMPLATE__']),
|
||||
'cards' => route('cp.cards.index'),
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function assetPacks(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Collection/NovaCardsAssetPackAdmin', [
|
||||
'packs' => NovaCardAssetPack::query()->orderBy('type')->orderBy('order_num')->orderBy('name')->get()->map(fn (NovaCardAssetPack $pack): array => [
|
||||
'id' => (int) $pack->id,
|
||||
'slug' => (string) $pack->slug,
|
||||
'name' => (string) $pack->name,
|
||||
'description' => $pack->description,
|
||||
'type' => (string) $pack->type,
|
||||
'preview_image' => $pack->preview_image,
|
||||
'manifest_json' => $pack->manifest_json,
|
||||
'official' => (bool) $pack->official,
|
||||
'active' => (bool) $pack->active,
|
||||
'order_num' => (int) $pack->order_num,
|
||||
])->values()->all(),
|
||||
'endpoints' => [
|
||||
'store' => route('cp.cards.asset-packs.store'),
|
||||
'updatePattern' => route('cp.cards.asset-packs.update', ['assetPack' => '__PACK__']),
|
||||
'cards' => route('cp.cards.index'),
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function challenges(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Collection/NovaCardsChallengeAdmin', [
|
||||
'challenges' => NovaCardChallenge::query()->with('winnerCard')->orderByDesc('featured')->orderByDesc('starts_at')->get()->map(fn (NovaCardChallenge $challenge): array => [
|
||||
'id' => (int) $challenge->id,
|
||||
'slug' => (string) $challenge->slug,
|
||||
'title' => (string) $challenge->title,
|
||||
'description' => $challenge->description,
|
||||
'prompt' => $challenge->prompt,
|
||||
'rules_json' => $challenge->rules_json,
|
||||
'status' => (string) $challenge->status,
|
||||
'official' => (bool) $challenge->official,
|
||||
'featured' => (bool) $challenge->featured,
|
||||
'winner_card_id' => $challenge->winner_card_id ? (int) $challenge->winner_card_id : null,
|
||||
'entries_count' => (int) $challenge->entries_count,
|
||||
'starts_at' => optional($challenge->starts_at)?->format('Y-m-d\TH:i'),
|
||||
'ends_at' => optional($challenge->ends_at)?->format('Y-m-d\TH:i'),
|
||||
])->values()->all(),
|
||||
'cards' => NovaCard::query()->published()->latest('published_at')->limit(100)->get()->map(fn (NovaCard $card): array => [
|
||||
'id' => (int) $card->id,
|
||||
'title' => (string) $card->title,
|
||||
])->values()->all(),
|
||||
'endpoints' => [
|
||||
'store' => route('cp.cards.challenges.store'),
|
||||
'updatePattern' => route('cp.cards.challenges.update', ['challenge' => '__CHALLENGE__']),
|
||||
'cards' => route('cp.cards.index'),
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function collections(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Collection/NovaCardsCollectionAdmin', [
|
||||
'collections' => NovaCardCollection::query()
|
||||
->with(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags'])
|
||||
->orderByDesc('featured')
|
||||
->orderByDesc('official')
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->map(fn (NovaCardCollection $collection): array => $this->presenter->collection($collection, $request->user(), true))
|
||||
->values()
|
||||
->all(),
|
||||
'cards' => NovaCard::query()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->limit(200)
|
||||
->get()
|
||||
->map(fn (NovaCard $card): array => [
|
||||
'id' => (int) $card->id,
|
||||
'title' => (string) $card->title,
|
||||
'slug' => (string) $card->slug,
|
||||
'creator' => $card->user?->username,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'admins' => \App\Models\User::query()
|
||||
->whereIn('role', ['admin', 'moderator'])
|
||||
->orderBy('username')
|
||||
->limit(50)
|
||||
->get(['id', 'username', 'name'])
|
||||
->map(fn ($user): array => [
|
||||
'id' => (int) $user->id,
|
||||
'username' => (string) $user->username,
|
||||
'name' => $user->name,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'endpoints' => [
|
||||
'store' => route('cp.cards.collections.store'),
|
||||
'updatePattern' => route('cp.cards.collections.update', ['collection' => '__COLLECTION__']),
|
||||
'attachCardPattern' => route('cp.cards.collections.cards.store', ['collection' => '__COLLECTION__']),
|
||||
'detachCardPattern' => route('cp.cards.collections.cards.destroy', ['collection' => '__COLLECTION__', 'card' => '__CARD__']),
|
||||
'cards' => route('cp.cards.index'),
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function storeTemplate(AdminStoreNovaCardTemplateRequest $request): JsonResponse
|
||||
{
|
||||
$template = NovaCardTemplate::query()->create($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'template' => [
|
||||
'id' => (int) $template->id,
|
||||
'slug' => (string) $template->slug,
|
||||
'name' => (string) $template->name,
|
||||
'description' => $template->description,
|
||||
'preview_image' => $template->preview_image,
|
||||
'config_json' => $template->config_json,
|
||||
'supported_formats' => $template->supported_formats,
|
||||
'active' => (bool) $template->active,
|
||||
'official' => (bool) $template->official,
|
||||
'order_num' => (int) $template->order_num,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateTemplate(AdminStoreNovaCardTemplateRequest $request, NovaCardTemplate $template): JsonResponse
|
||||
{
|
||||
$template->update($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'template' => [
|
||||
'id' => (int) $template->id,
|
||||
'slug' => (string) $template->slug,
|
||||
'name' => (string) $template->name,
|
||||
'description' => $template->description,
|
||||
'preview_image' => $template->preview_image,
|
||||
'config_json' => $template->config_json,
|
||||
'supported_formats' => $template->supported_formats,
|
||||
'active' => (bool) $template->active,
|
||||
'official' => (bool) $template->official,
|
||||
'order_num' => (int) $template->order_num,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeAssetPack(AdminStoreNovaCardAssetPackRequest $request): JsonResponse
|
||||
{
|
||||
$pack = NovaCardAssetPack::query()->create($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'pack' => [
|
||||
'id' => (int) $pack->id,
|
||||
'slug' => (string) $pack->slug,
|
||||
'name' => (string) $pack->name,
|
||||
'description' => $pack->description,
|
||||
'type' => (string) $pack->type,
|
||||
'preview_image' => $pack->preview_image,
|
||||
'manifest_json' => $pack->manifest_json,
|
||||
'official' => (bool) $pack->official,
|
||||
'active' => (bool) $pack->active,
|
||||
'order_num' => (int) $pack->order_num,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateAssetPack(AdminStoreNovaCardAssetPackRequest $request, NovaCardAssetPack $assetPack): JsonResponse
|
||||
{
|
||||
$assetPack->update($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'pack' => [
|
||||
'id' => (int) $assetPack->id,
|
||||
'slug' => (string) $assetPack->slug,
|
||||
'name' => (string) $assetPack->name,
|
||||
'description' => $assetPack->description,
|
||||
'type' => (string) $assetPack->type,
|
||||
'preview_image' => $assetPack->preview_image,
|
||||
'manifest_json' => $assetPack->manifest_json,
|
||||
'official' => (bool) $assetPack->official,
|
||||
'active' => (bool) $assetPack->active,
|
||||
'order_num' => (int) $assetPack->order_num,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeChallenge(AdminStoreNovaCardChallengeRequest $request): JsonResponse
|
||||
{
|
||||
$challenge = NovaCardChallenge::query()->create($request->validated() + [
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'challenge' => [
|
||||
'id' => (int) $challenge->id,
|
||||
'slug' => (string) $challenge->slug,
|
||||
'title' => (string) $challenge->title,
|
||||
'description' => $challenge->description,
|
||||
'prompt' => $challenge->prompt,
|
||||
'rules_json' => $challenge->rules_json,
|
||||
'status' => (string) $challenge->status,
|
||||
'official' => (bool) $challenge->official,
|
||||
'featured' => (bool) $challenge->featured,
|
||||
'winner_card_id' => $challenge->winner_card_id ? (int) $challenge->winner_card_id : null,
|
||||
'entries_count' => (int) $challenge->entries_count,
|
||||
'starts_at' => optional($challenge->starts_at)?->format('Y-m-d\TH:i'),
|
||||
'ends_at' => optional($challenge->ends_at)?->format('Y-m-d\TH:i'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateChallenge(AdminStoreNovaCardChallengeRequest $request, NovaCardChallenge $challenge): JsonResponse
|
||||
{
|
||||
$challenge->update($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'challenge' => [
|
||||
'id' => (int) $challenge->id,
|
||||
'slug' => (string) $challenge->slug,
|
||||
'title' => (string) $challenge->title,
|
||||
'description' => $challenge->description,
|
||||
'prompt' => $challenge->prompt,
|
||||
'rules_json' => $challenge->rules_json,
|
||||
'status' => (string) $challenge->status,
|
||||
'official' => (bool) $challenge->official,
|
||||
'featured' => (bool) $challenge->featured,
|
||||
'winner_card_id' => $challenge->winner_card_id ? (int) $challenge->winner_card_id : null,
|
||||
'entries_count' => (int) $challenge->entries_count,
|
||||
'starts_at' => optional($challenge->starts_at)?->format('Y-m-d\TH:i'),
|
||||
'ends_at' => optional($challenge->ends_at)?->format('Y-m-d\TH:i'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeCategory(AdminStoreNovaCardCategoryRequest $request): JsonResponse
|
||||
{
|
||||
$category = NovaCardCategory::query()->create($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'category' => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => (string) $category->name,
|
||||
'description' => $category->description,
|
||||
'active' => (bool) $category->active,
|
||||
'order_num' => (int) $category->order_num,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateCategory(AdminStoreNovaCardCategoryRequest $request, NovaCardCategory $category): JsonResponse
|
||||
{
|
||||
$category->update($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'category' => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => (string) $category->name,
|
||||
'description' => $category->description,
|
||||
'active' => (bool) $category->active,
|
||||
'order_num' => (int) $category->order_num,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeCollection(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'slug' => ['nullable', 'string', 'max:140'],
|
||||
'name' => ['required', 'string', 'min:2', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'visibility' => ['required', 'in:private,public'],
|
||||
'official' => ['nullable', 'boolean'],
|
||||
'featured' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$collection = $this->collections->createManagedCollection($payload);
|
||||
|
||||
return response()->json([
|
||||
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateCollection(Request $request, NovaCardCollection $collection): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'slug' => ['required', 'string', 'max:140'],
|
||||
'name' => ['required', 'string', 'min:2', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'visibility' => ['required', 'in:private,public'],
|
||||
'official' => ['nullable', 'boolean'],
|
||||
'featured' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$collection->update([
|
||||
'user_id' => (int) $payload['user_id'],
|
||||
'slug' => $payload['slug'],
|
||||
'name' => $payload['name'],
|
||||
'description' => $payload['description'] ?? null,
|
||||
'visibility' => $payload['visibility'],
|
||||
'official' => (bool) ($payload['official'] ?? false),
|
||||
'featured' => (bool) ($payload['featured'] ?? false),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeCollectionCard(Request $request, NovaCardCollection $collection): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'card_id' => ['required', 'integer', 'exists:nova_cards,id'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$card = NovaCard::query()->findOrFail((int) $payload['card_id']);
|
||||
$this->collections->addCardToCollection($collection, $card, $payload['note'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyCollectionCard(Request $request, NovaCardCollection $collection, NovaCard $card): JsonResponse
|
||||
{
|
||||
$this->collections->removeCardFromCollection($collection, $card);
|
||||
|
||||
return response()->json([
|
||||
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateCard(AdminUpdateNovaCardRequest $request, NovaCard $card): JsonResponse
|
||||
{
|
||||
$attributes = $request->validated();
|
||||
$requestedDisposition = $attributes['disposition'] ?? null;
|
||||
$hasModerationChange = array_key_exists('moderation_status', $attributes)
|
||||
&& $attributes['moderation_status'] !== $card->moderation_status;
|
||||
$hasDispositionChange = array_key_exists('disposition', $attributes)
|
||||
&& $requestedDisposition !== (($this->moderation->latestOverride($card) ?? [])['disposition'] ?? null);
|
||||
if (isset($attributes['featured']) && (! isset($attributes['status']) || $attributes['status'] !== NovaCard::STATUS_PUBLISHED)) {
|
||||
$attributes['featured'] = (bool) $attributes['featured'] && $card->status === NovaCard::STATUS_PUBLISHED;
|
||||
}
|
||||
|
||||
unset($attributes['disposition']);
|
||||
|
||||
$card->update($attributes);
|
||||
|
||||
if ($hasModerationChange || $hasDispositionChange) {
|
||||
$card = $this->moderation->recordStaffOverride(
|
||||
$card->fresh(),
|
||||
(string) ($attributes['moderation_status'] ?? $card->moderation_status),
|
||||
$request->user(),
|
||||
'admin_card_update',
|
||||
[
|
||||
'disposition' => $requestedDisposition,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'card' => $this->presenter->card($card->fresh()->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags']), false, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateCreator(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$attributes = $request->validate([
|
||||
'nova_featured_creator' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$user->update($attributes);
|
||||
|
||||
$publicCardsCount = $user->novaCards()->publiclyVisible()->count();
|
||||
$featuredCardsCount = $user->novaCards()->publiclyVisible()->where('featured', true)->count();
|
||||
$totalViewsCount = (int) ($user->novaCards()->publiclyVisible()->sum('views_count') ?? 0);
|
||||
|
||||
return response()->json([
|
||||
'creator' => [
|
||||
'id' => (int) $user->id,
|
||||
'username' => (string) $user->username,
|
||||
'name' => $user->name,
|
||||
'display_name' => $user->name ?: '@' . $user->username,
|
||||
'public_url' => route('cards.creator', ['username' => $user->username]),
|
||||
'nova_featured_creator' => (bool) $user->nova_featured_creator,
|
||||
'public_cards_count' => $publicCardsCount,
|
||||
'featured_cards_count' => $featuredCardsCount,
|
||||
'total_views_count' => $totalViewsCount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user