optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View 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);
}
}