optimizations
This commit is contained in:
117
app/Http/Controllers/User/CollectionSavedLibraryController.php
Normal file
117
app/Http/Controllers/User/CollectionSavedLibraryController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Collections\ReorderSavedCollectionListItemsRequest;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionSavedList;
|
||||
use App\Services\CollectionSavedLibraryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CollectionSavedLibraryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionSavedLibraryService $savedLibrary,
|
||||
) {
|
||||
}
|
||||
|
||||
public function storeList(Request $request): JsonResponse
|
||||
{
|
||||
$list = $this->savedLibrary->createList(
|
||||
$request->user(),
|
||||
(string) $request->validate([
|
||||
'title' => ['required', 'string', 'min:2', 'max:120'],
|
||||
])['title'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'list' => [
|
||||
'id' => (int) $list->id,
|
||||
'title' => $list->title,
|
||||
'slug' => $list->slug,
|
||||
'items_count' => 0,
|
||||
'url' => route('me.saved.collections.lists.show', ['listSlug' => $list->slug]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeItem(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'saved_list_id' => ['required', 'integer', 'exists:collection_saved_lists,id'],
|
||||
]);
|
||||
|
||||
$list = CollectionSavedList::query()->findOrFail((int) $payload['saved_list_id']);
|
||||
$item = $this->savedLibrary->addToList($request->user(), $list, $collection);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'added' => $item->wasRecentlyCreated,
|
||||
'item' => [
|
||||
'id' => (int) $item->id,
|
||||
'saved_list_id' => (int) $item->saved_list_id,
|
||||
'collection_id' => (int) $item->collection_id,
|
||||
'order_num' => (int) $item->order_num,
|
||||
],
|
||||
'list' => [
|
||||
'id' => (int) $list->id,
|
||||
'items_count' => $this->savedLibrary->itemsCount($list),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyItem(Request $request, CollectionSavedList $list, Collection $collection): JsonResponse
|
||||
{
|
||||
$removed = $this->savedLibrary->removeFromList($request->user(), $list, $collection);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'removed' => $removed,
|
||||
'list' => [
|
||||
'id' => (int) $list->id,
|
||||
'items_count' => $this->savedLibrary->itemsCount($list),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function reorderItems(ReorderSavedCollectionListItemsRequest $request, CollectionSavedList $list): JsonResponse
|
||||
{
|
||||
$orderedCollectionIds = $request->validated('collection_ids');
|
||||
|
||||
$this->savedLibrary->reorderList($request->user(), $list, $orderedCollectionIds);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'list' => [
|
||||
'id' => (int) $list->id,
|
||||
'items_count' => $this->savedLibrary->itemsCount($list),
|
||||
],
|
||||
'ordered_collection_ids' => collect($orderedCollectionIds)
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateNote(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$note = $this->savedLibrary->upsertNote($request->user(), $collection, $payload['note'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'note' => $note ? [
|
||||
'collection_id' => (int) $note->collection_id,
|
||||
'note' => (string) $note->note,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
221
app/Http/Controllers/User/ProfileCollectionController.php
Normal file
221
app/Http/Controllers/User/ProfileCollectionController.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Events\Collections\CollectionViewed;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionCollaborationService;
|
||||
use App\Services\CollectionCommentService;
|
||||
use App\Services\CollectionDiscoveryService;
|
||||
use App\Services\CollectionFollowService;
|
||||
use App\Services\CollectionLinkService;
|
||||
use App\Services\CollectionLikeService;
|
||||
use App\Services\CollectionLinkedCollectionsService;
|
||||
use App\Services\CollectionRecommendationService;
|
||||
use App\Services\CollectionSaveService;
|
||||
use App\Services\CollectionSeriesService;
|
||||
use App\Services\CollectionSubmissionService;
|
||||
use App\Services\CollectionService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProfileCollectionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionLikeService $likes,
|
||||
private readonly CollectionFollowService $follows,
|
||||
private readonly CollectionSaveService $saves,
|
||||
private readonly CollectionCollaborationService $collaborators,
|
||||
private readonly CollectionSubmissionService $submissions,
|
||||
private readonly CollectionCommentService $comments,
|
||||
private readonly CollectionDiscoveryService $discovery,
|
||||
private readonly CollectionRecommendationService $recommendations,
|
||||
private readonly CollectionLinkService $entityLinks,
|
||||
private readonly CollectionLinkedCollectionsService $linkedCollections,
|
||||
private readonly CollectionSeriesService $series,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(Request $request, string $username, string $slug)
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
|
||||
if (! $user) {
|
||||
$redirect = DB::table('username_redirects')
|
||||
->whereRaw('LOWER(old_username) = ?', [$normalized])
|
||||
->value('new_username');
|
||||
|
||||
if ($redirect) {
|
||||
return redirect()->route('profile.collections.show', [
|
||||
'username' => strtolower((string) $redirect),
|
||||
'slug' => $slug,
|
||||
], 301);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($username !== strtolower((string) $user->username)) {
|
||||
return redirect()->route('profile.collections.show', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'slug' => $slug,
|
||||
], 301);
|
||||
}
|
||||
|
||||
$collection = Collection::query()
|
||||
->with([
|
||||
'user.profile',
|
||||
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
'canonicalCollection.user.profile',
|
||||
])
|
||||
->where('user_id', $user->id)
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$viewer = $request->user();
|
||||
$ownerView = $viewer && (int) $viewer->id === (int) $user->id;
|
||||
|
||||
if ($collection->canonical_collection_id) {
|
||||
$canonicalTarget = $collection->canonicalCollection;
|
||||
|
||||
if ($canonicalTarget instanceof Collection
|
||||
&& (int) $canonicalTarget->id !== (int) $collection->id
|
||||
&& $canonicalTarget->user instanceof User
|
||||
&& $canonicalTarget->canBeViewedBy($viewer)) {
|
||||
return redirect()->route('profile.collections.show', [
|
||||
'username' => strtolower((string) $canonicalTarget->user->username),
|
||||
'slug' => $canonicalTarget->slug,
|
||||
], 301);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $collection->canBeViewedBy($viewer)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$collection = $this->collections->recordView($collection);
|
||||
$this->saves->touchSavedCollectionView($viewer, $collection);
|
||||
$artworks = $this->collections->getCollectionDetailArtworks($collection, $ownerView, 24);
|
||||
$collectionPayload = $this->collections->mapCollectionDetailPayload($collection, $ownerView);
|
||||
$manualRelatedCollections = $this->linkedCollections->publicLinkedCollections($collection, 6);
|
||||
$recommendedCollections = $this->recommendations->relatedPublicCollections($collection, 6);
|
||||
$seriesContext = $collection->inSeries()
|
||||
? $this->series->seriesContext($collection)
|
||||
: ['previous' => null, 'next' => null, 'items' => [], 'title' => null, 'description' => null];
|
||||
|
||||
event(new CollectionViewed($collection, $viewer?->id));
|
||||
|
||||
return Inertia::render('Collection/CollectionShow', [
|
||||
'collection' => $collectionPayload,
|
||||
'artworks' => $this->collections->mapArtworkPaginator($artworks),
|
||||
'owner' => $collectionPayload['owner'],
|
||||
'isOwner' => $ownerView,
|
||||
'manageUrl' => $ownerView ? route('settings.collections.show', ['collection' => $collection->id]) : null,
|
||||
'editUrl' => $ownerView ? route('settings.collections.edit', ['collection' => $collection->id]) : null,
|
||||
'analyticsUrl' => $ownerView && $collection->supportsAnalytics() ? route('settings.collections.analytics', ['collection' => $collection->id]) : null,
|
||||
'historyUrl' => $ownerView ? route('settings.collections.history', ['collection' => $collection->id]) : null,
|
||||
'engagement' => [
|
||||
'liked' => $this->likes->isLiked($viewer, $collection),
|
||||
'following' => $this->follows->isFollowing($viewer, $collection),
|
||||
'saved' => $this->saves->isSaved($viewer, $collection),
|
||||
'can_interact' => ! $ownerView && $collection->isPubliclyEngageable(),
|
||||
'like_url' => route('collections.like', ['collection' => $collection->id]),
|
||||
'unlike_url' => route('collections.unlike', ['collection' => $collection->id]),
|
||||
'follow_url' => route('collections.follow', ['collection' => $collection->id]),
|
||||
'unfollow_url' => route('collections.unfollow', ['collection' => $collection->id]),
|
||||
'save_url' => route('collections.save', ['collection' => $collection->id]),
|
||||
'unsave_url' => route('collections.unsave', ['collection' => $collection->id]),
|
||||
'share_url' => route('collections.share', ['collection' => $collection->id]),
|
||||
'login_url' => route('login'),
|
||||
],
|
||||
'members' => $this->collaborators->mapMembers($collection, $viewer),
|
||||
'submissions' => $this->submissions->mapSubmissions($collection, $viewer),
|
||||
'comments' => $this->comments->mapComments($collection, $viewer),
|
||||
'entityLinks' => $this->entityLinks->links($collection, true),
|
||||
'relatedCollections' => $this->collections->mapCollectionCardPayloads(
|
||||
$manualRelatedCollections
|
||||
->concat($recommendedCollections)
|
||||
->unique('id')
|
||||
->take(6)
|
||||
->values(),
|
||||
false
|
||||
),
|
||||
'seriesContext' => [
|
||||
'key' => $seriesContext['key'] ?? $collection->series_key,
|
||||
'title' => $seriesContext['title'] ?? $collection->series_title,
|
||||
'description' => $seriesContext['description'] ?? $collection->series_description,
|
||||
'url' => ! empty($seriesContext['key']) ? route('collections.series.show', ['seriesKey' => $seriesContext['key']]) : null,
|
||||
'previous' => $seriesContext['previous'] ? ($this->collections->mapCollectionCardPayloads(collect([$seriesContext['previous']]), false)[0] ?? null) : null,
|
||||
'next' => $seriesContext['next'] ? ($this->collections->mapCollectionCardPayloads(collect([$seriesContext['next']]), false)[0] ?? null) : null,
|
||||
'siblings' => $this->collections->mapCollectionCardPayloads(collect($seriesContext['items'] ?? [])->filter(fn (Collection $item) => (int) $item->id !== (int) $collection->id), false),
|
||||
],
|
||||
'submitEndpoint' => route('collections.submissions.store', ['collection' => $collection->id]),
|
||||
'commentsEndpoint' => route('collections.comments.store', ['collection' => $collection->id]),
|
||||
'submissionArtworkOptions' => $viewer ? $this->collections->getSubmissionArtworkOptions($viewer) : [],
|
||||
'canSubmit' => $collection->canReceiveSubmissionsFrom($viewer),
|
||||
'canComment' => $collection->canReceiveCommentsFrom($viewer),
|
||||
'profileCollectionsUrl' => route('profile.tab', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'tab' => 'collections',
|
||||
]),
|
||||
'featuredCollectionsUrl' => route('collections.featured'),
|
||||
'reportEndpoint' => $viewer ? route('api.reports.store') : null,
|
||||
'seo' => [
|
||||
'title' => $collection->is_featured
|
||||
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
|
||||
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
|
||||
'description' => $collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
|
||||
'canonical' => $collectionPayload['public_url'],
|
||||
'og_image' => $collectionPayload['cover_image'],
|
||||
'robots' => $collection->visibility === Collection::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,nofollow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function showSeries(Request $request, string $seriesKey)
|
||||
{
|
||||
$seriesCollections = $this->series->publicSeriesItems($seriesKey);
|
||||
|
||||
abort_if($seriesCollections->isEmpty(), 404);
|
||||
|
||||
$mappedCollections = $this->collections->mapCollectionCardPayloads($seriesCollections, false);
|
||||
$leadCollection = $mappedCollections[0] ?? null;
|
||||
$ownersCount = $seriesCollections->pluck('user_id')->unique()->count();
|
||||
$artworksCount = $seriesCollections->sum(fn (Collection $collection) => (int) $collection->artworks_count);
|
||||
$latestActivityAt = $seriesCollections->max('last_activity_at');
|
||||
$seriesMeta = $this->series->metadataFor($seriesCollections);
|
||||
$seriesTitle = $seriesMeta['title'] ?: collect($mappedCollections)
|
||||
->pluck('campaign_label')
|
||||
->filter()
|
||||
->first();
|
||||
$seriesDescription = $seriesMeta['description'];
|
||||
|
||||
return Inertia::render('Collection/CollectionSeriesShow', [
|
||||
'seriesKey' => $seriesKey,
|
||||
'title' => $seriesTitle ?: sprintf('Collection Series: %s', str_replace(['-', '_'], ' ', $seriesKey)),
|
||||
'description' => $seriesDescription ?: sprintf('Browse the %s collection series in sequence, with public entries ordered for smooth navigation across related curations.', $seriesKey),
|
||||
'collections' => $mappedCollections,
|
||||
'leadCollection' => $leadCollection,
|
||||
'stats' => [
|
||||
'collections' => $seriesCollections->count(),
|
||||
'owners' => $ownersCount,
|
||||
'artworks' => $artworksCount,
|
||||
'latest_activity_at' => optional($latestActivityAt)?->toISOString(),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => sprintf('Series: %s — Skinbase Nova', $seriesKey),
|
||||
'description' => sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
|
||||
'canonical' => route('collections.series.show', ['seriesKey' => $seriesKey]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,10 @@ use App\Services\AvatarService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\AchievementService;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\FollowAnalyticsService;
|
||||
use App\Services\LeaderboardService;
|
||||
use App\Services\UserSuggestionService;
|
||||
use App\Services\Countries\CountryCatalogService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
@@ -69,8 +72,11 @@ class ProfileController extends Controller
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
private readonly XPService $xp,
|
||||
private readonly AchievementService $achievements,
|
||||
private readonly CollectionService $collections,
|
||||
private readonly FollowAnalyticsService $followAnalytics,
|
||||
private readonly LeaderboardService $leaderboards,
|
||||
private readonly CountryCatalogService $countryCatalog,
|
||||
private readonly UserSuggestionService $userSuggestions,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -1003,9 +1009,11 @@ class ProfileController extends Controller
|
||||
$followerCount = 0;
|
||||
$recentFollowers = collect();
|
||||
$viewerIsFollowing = false;
|
||||
$followingCount = 0;
|
||||
|
||||
if (Schema::hasTable('user_followers')) {
|
||||
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
$followingCount = DB::table('user_followers')->where('follower_id', $user->id)->count();
|
||||
|
||||
$recentFollowers = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
@@ -1033,6 +1041,30 @@ class ProfileController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$liveUploadsCount = 0;
|
||||
if (Schema::hasTable('artworks')) {
|
||||
$liveUploadsCount = (int) DB::table('artworks')
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
$liveAwardsReceivedCount = 0;
|
||||
if (Schema::hasTable('artwork_awards') && Schema::hasTable('artworks')) {
|
||||
$liveAwardsReceivedCount = (int) DB::table('artwork_awards as aw')
|
||||
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
|
||||
->where('a.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
$statsPayload = array_merge($stats ? (array) $stats : [], [
|
||||
'uploads_count' => $liveUploadsCount,
|
||||
'awards_received_count' => $liveAwardsReceivedCount,
|
||||
'followers_count' => (int) $followerCount,
|
||||
'following_count' => (int) $followingCount,
|
||||
]);
|
||||
|
||||
// ── Profile comments ─────────────────────────────────────────────────
|
||||
$profileComments = collect();
|
||||
if (Schema::hasTable('profile_comments')) {
|
||||
@@ -1066,6 +1098,11 @@ class ProfileController extends Controller
|
||||
}
|
||||
|
||||
$xpSummary = $this->xp->summary((int) $user->id);
|
||||
$followContext = $viewer && $viewer->id !== $user->id
|
||||
? $this->followService->relationshipContext((int) $viewer->id, (int) $user->id)
|
||||
: null;
|
||||
$followAnalytics = $this->followAnalytics->summaryForUser((int) $user->id, $followerCount);
|
||||
$suggestedUsers = $viewer ? $this->userSuggestions->suggestFor($viewer, 4) : [];
|
||||
|
||||
$creatorStories = Story::query()
|
||||
->published()
|
||||
@@ -1100,6 +1137,9 @@ class ProfileController extends Controller
|
||||
'published_at' => $story->published_at?->toISOString(),
|
||||
]);
|
||||
|
||||
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
|
||||
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner);
|
||||
|
||||
// ── Profile data ─────────────────────────────────────────────────────
|
||||
$profile = $user->profile;
|
||||
$country = $this->countryCatalog->resolveUserCountry($user);
|
||||
@@ -1193,14 +1233,18 @@ class ProfileController extends Controller
|
||||
'artworks' => $artworkPayload,
|
||||
'featuredArtworks' => $featuredArtworks->values(),
|
||||
'favourites' => $favourites,
|
||||
'stats' => $stats,
|
||||
'stats' => $statsPayload,
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
'recentFollowers' => $recentFollowers->values(),
|
||||
'followContext' => $followContext,
|
||||
'followAnalytics' => $followAnalytics,
|
||||
'suggestedUsers' => $suggestedUsers,
|
||||
'viewerIsFollowing' => $viewerIsFollowing,
|
||||
'heroBgUrl' => $heroBgUrl,
|
||||
'profileComments' => $profileComments->values(),
|
||||
'creatorStories' => $creatorStories->values(),
|
||||
'collections' => $profileCollectionsPayload,
|
||||
'achievements' => $achievementSummary,
|
||||
'leaderboardRank' => $leaderboardRank,
|
||||
'countryName' => $countryName,
|
||||
@@ -1209,6 +1253,10 @@ class ProfileController extends Controller
|
||||
'initialTab' => $resolvedInitialTab,
|
||||
'profileUrl' => $canonical,
|
||||
'galleryUrl' => $galleryUrl,
|
||||
'collectionCreateUrl' => $isOwner ? route('settings.collections.create') : null,
|
||||
'collectionReorderUrl' => $isOwner ? route('settings.collections.reorder-profile') : null,
|
||||
'collectionsFeaturedUrl' => route('collections.featured'),
|
||||
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
|
||||
'profileTabUrls' => $profileTabUrls,
|
||||
])->withViewData([
|
||||
'page_title' => $galleryOnly
|
||||
|
||||
255
app/Http/Controllers/User/SavedCollectionController.php
Normal file
255
app/Http/Controllers/User/SavedCollectionController.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Collections\CollectionSavedLibraryRequest;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionSavedList;
|
||||
use App\Services\CollectionRecommendationService;
|
||||
use App\Services\CollectionSavedLibraryService;
|
||||
use App\Services\CollectionService;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SavedCollectionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionSavedLibraryService $savedLibrary,
|
||||
private readonly CollectionRecommendationService $recommendations,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(CollectionSavedLibraryRequest $request): Response
|
||||
{
|
||||
return $this->renderSavedLibrary($request);
|
||||
}
|
||||
|
||||
public function showList(CollectionSavedLibraryRequest $request, string $listSlug): Response
|
||||
{
|
||||
$list = $this->savedLibrary->findListBySlugForUser($request->user(), $listSlug);
|
||||
|
||||
return $this->renderSavedLibrary($request, $list);
|
||||
}
|
||||
|
||||
private function renderSavedLibrary(CollectionSavedLibraryRequest $request, ?CollectionSavedList $activeList = null): Response
|
||||
{
|
||||
$savedCollections = $this->collections->getSavedCollectionsForUser($request->user(), 120);
|
||||
$filter = (string) ($request->validated('filter') ?? 'all');
|
||||
$sort = (string) ($request->validated('sort') ?? 'saved_desc');
|
||||
$query = trim((string) ($request->validated('q') ?? ''));
|
||||
$listId = $activeList ? (int) $activeList->id : ($request->filled('list') ? (int) $request->query('list') : null);
|
||||
$preserveListOrder = false;
|
||||
$listOrder = null;
|
||||
|
||||
if ($activeList) {
|
||||
$preserveListOrder = true;
|
||||
$allowedCollectionIds = $this->savedLibrary->collectionIdsForList($request->user(), $activeList);
|
||||
$listOrder = array_flip($allowedCollectionIds);
|
||||
$savedCollections = $savedCollections
|
||||
->filter(fn ($collection) => in_array((int) $collection->id, $allowedCollectionIds, true))
|
||||
->sortBy(fn ($collection) => $listOrder[(int) $collection->id] ?? PHP_INT_MAX)
|
||||
->values();
|
||||
} elseif ($listId) {
|
||||
$preserveListOrder = true;
|
||||
$activeList = $request->user()->savedCollectionLists()->withCount('items')->findOrFail($listId);
|
||||
$allowedCollectionIds = $this->savedLibrary->collectionIdsForList($request->user(), $activeList);
|
||||
$listOrder = array_flip($allowedCollectionIds);
|
||||
$savedCollections = $savedCollections
|
||||
->filter(fn ($collection) => in_array((int) $collection->id, $allowedCollectionIds, true))
|
||||
->sortBy(fn ($collection) => $listOrder[(int) $collection->id] ?? PHP_INT_MAX)
|
||||
->values();
|
||||
}
|
||||
|
||||
$savedCollectionIds = $savedCollections->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
$notes = $this->savedLibrary->notesFor($request->user(), $savedCollectionIds);
|
||||
$saveMetadata = $this->savedLibrary->saveMetadataFor($request->user(), $savedCollectionIds);
|
||||
$filterCounts = $this->filterCounts($savedCollections, $notes);
|
||||
|
||||
$savedCollections = $savedCollections
|
||||
->filter(fn (Collection $collection): bool => $this->matchesSearch($collection, $query))
|
||||
->filter(fn (Collection $collection): bool => $this->matchesFilter($collection, $filter, $notes))
|
||||
->values();
|
||||
|
||||
if (! ($preserveListOrder && $sort === 'saved_desc')) {
|
||||
$savedCollections = $this->sortCollections($savedCollections, $sort)->values();
|
||||
}
|
||||
|
||||
$collectionPayloads = $this->collections->mapCollectionCardPayloads($savedCollections, false);
|
||||
$collectionIds = collect($collectionPayloads)->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
||||
$memberships = $this->savedLibrary->membershipsFor($request->user(), $collectionIds);
|
||||
$savedLists = collect($this->savedLibrary->listsFor($request->user()))
|
||||
->map(function (array $list) use ($filter, $sort, $query): array {
|
||||
return [
|
||||
...$list,
|
||||
'url' => route('me.saved.collections.lists.show', ['listSlug' => $list['slug']]) . ($filter !== 'all' || $sort !== 'saved_desc'
|
||||
? ('?' . http_build_query(array_filter([
|
||||
'filter' => $filter !== 'all' ? $filter : null,
|
||||
'sort' => $sort !== 'saved_desc' ? $sort : null,
|
||||
'q' => $query !== '' ? $query : null,
|
||||
])))
|
||||
: ''),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$filterOptions = [
|
||||
['key' => 'all', 'label' => 'All', 'count' => $filterCounts['all'] ?? 0],
|
||||
['key' => 'editorial', 'label' => 'Editorial', 'count' => $filterCounts['editorial'] ?? 0],
|
||||
['key' => 'community', 'label' => 'Community', 'count' => $filterCounts['community'] ?? 0],
|
||||
['key' => 'personal', 'label' => 'Personal', 'count' => $filterCounts['personal'] ?? 0],
|
||||
['key' => 'seasonal', 'label' => 'Seasonal or campaign', 'count' => $filterCounts['seasonal'] ?? 0],
|
||||
['key' => 'noted', 'label' => 'With notes', 'count' => $filterCounts['noted'] ?? 0],
|
||||
['key' => 'revisited', 'label' => 'Revisited', 'count' => $filterCounts['revisited'] ?? 0],
|
||||
];
|
||||
|
||||
$sortOptions = [
|
||||
['key' => 'saved_desc', 'label' => 'Recently saved'],
|
||||
['key' => 'saved_asc', 'label' => 'Oldest saved'],
|
||||
['key' => 'updated_desc', 'label' => 'Recently updated'],
|
||||
['key' => 'revisited_desc', 'label' => 'Recently revisited'],
|
||||
['key' => 'ranking_desc', 'label' => 'Highest ranking'],
|
||||
['key' => 'title_asc', 'label' => 'Title A-Z'],
|
||||
];
|
||||
|
||||
return Inertia::render('Collection/SavedCollections', [
|
||||
'collections' => collect($collectionPayloads)->map(function (array $collection) use ($memberships, $notes, $saveMetadata): array {
|
||||
return [
|
||||
...$collection,
|
||||
'saved_list_ids' => $memberships[(int) $collection['id']] ?? [],
|
||||
'saved_note' => $notes[(int) $collection['id']] ?? null,
|
||||
'saved_because' => $saveMetadata[(int) $collection['id']]['saved_because'] ?? null,
|
||||
'last_viewed_at' => $saveMetadata[(int) $collection['id']]['last_viewed_at'] ?? null,
|
||||
];
|
||||
})->all(),
|
||||
'recentlyRevisited' => $this->collections->mapCollectionCardPayloads($this->savedLibrary->recentlyRevisited($request->user(), 6), false),
|
||||
'recommendedCollections' => $this->collections->mapCollectionCardPayloads($this->recommendations->recommendedForUser($request->user(), 6), false),
|
||||
'savedLists' => $savedLists,
|
||||
'activeList' => $activeList ? [
|
||||
'id' => (int) $activeList->id,
|
||||
'title' => (string) $activeList->title,
|
||||
'slug' => (string) $activeList->slug,
|
||||
'items_count' => (int) $activeList->items_count,
|
||||
'url' => route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]),
|
||||
] : null,
|
||||
'activeFilters' => [
|
||||
'q' => $query,
|
||||
'filter' => $filter,
|
||||
'sort' => $sort,
|
||||
'list' => $listId,
|
||||
],
|
||||
'filterOptions' => $filterOptions,
|
||||
'sortOptions' => $sortOptions,
|
||||
'endpoints' => [
|
||||
'createList' => route('me.saved.collections.lists.store'),
|
||||
'addToListPattern' => route('me.saved.collections.lists.items.store', ['collection' => '__COLLECTION__']),
|
||||
'removeFromListPattern' => route('me.saved.collections.lists.items.destroy', ['list' => '__LIST__', 'collection' => '__COLLECTION__']),
|
||||
'reorderItemsPattern' => route('me.saved.collections.lists.items.reorder', ['list' => '__LIST__']),
|
||||
'updateNotePattern' => route('me.saved.collections.notes.update', ['collection' => '__COLLECTION__']),
|
||||
'unsavePattern' => route('collections.unsave', ['collection' => '__COLLECTION__']),
|
||||
],
|
||||
'libraryUrl' => route('me.saved.collections'),
|
||||
'browseUrl' => route('collections.featured'),
|
||||
'seo' => [
|
||||
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase Nova', $activeList->title) : 'Saved Collections — Skinbase Nova',
|
||||
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase Nova.', $activeList->title) : 'Your saved collections on Skinbase Nova.',
|
||||
'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
private function matchesSearch(Collection $collection, string $query): bool
|
||||
{
|
||||
if ($query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystacks = [
|
||||
$collection->title,
|
||||
$collection->subtitle,
|
||||
$collection->summary,
|
||||
$collection->description,
|
||||
$collection->campaign_label,
|
||||
$collection->season_key,
|
||||
$collection->event_label,
|
||||
$collection->series_title,
|
||||
optional($collection->user)->username,
|
||||
optional($collection->user)->name,
|
||||
];
|
||||
|
||||
$needle = mb_strtolower($query);
|
||||
|
||||
return collect($haystacks)
|
||||
->filter(fn ($value): bool => is_string($value) && $value !== '')
|
||||
->contains(fn (string $value): bool => str_contains(mb_strtolower($value), $needle));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $notes
|
||||
*/
|
||||
private function matchesFilter(Collection $collection, string $filter, array $notes): bool
|
||||
{
|
||||
return match ($filter) {
|
||||
'editorial' => $collection->type === Collection::TYPE_EDITORIAL,
|
||||
'community' => $collection->type === Collection::TYPE_COMMUNITY,
|
||||
'personal' => $collection->type === Collection::TYPE_PERSONAL,
|
||||
'seasonal' => filled($collection->season_key) || filled($collection->event_key) || filled($collection->campaign_key),
|
||||
'noted' => filled($notes[(int) $collection->id] ?? null),
|
||||
'revisited' => $this->timestamp($collection->saved_last_viewed_at) !== $this->timestamp($collection->saved_at),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $notes
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function filterCounts($collections, array $notes): array
|
||||
{
|
||||
return [
|
||||
'all' => $collections->count(),
|
||||
'editorial' => $collections->where('type', Collection::TYPE_EDITORIAL)->count(),
|
||||
'community' => $collections->where('type', Collection::TYPE_COMMUNITY)->count(),
|
||||
'personal' => $collections->where('type', Collection::TYPE_PERSONAL)->count(),
|
||||
'seasonal' => $collections->filter(fn (Collection $collection): bool => filled($collection->season_key) || filled($collection->event_key) || filled($collection->campaign_key))->count(),
|
||||
'noted' => $collections->filter(fn (Collection $collection): bool => filled($notes[(int) $collection->id] ?? null))->count(),
|
||||
'revisited' => $collections->filter(fn (Collection $collection): bool => $this->timestamp($collection->saved_last_viewed_at) !== $this->timestamp($collection->saved_at))->count(),
|
||||
];
|
||||
}
|
||||
|
||||
private function sortCollections($collections, string $sort)
|
||||
{
|
||||
return match ($sort) {
|
||||
'saved_asc' => $collections->sortBy(fn (Collection $collection): int => $this->timestamp($collection->saved_at)),
|
||||
'updated_desc' => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->updated_at)),
|
||||
'revisited_desc' => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->saved_last_viewed_at)),
|
||||
'ranking_desc' => $collections->sortByDesc(fn (Collection $collection): float => (float) ($collection->ranking_score ?? 0)),
|
||||
'title_asc' => $collections->sortBy(fn (Collection $collection): string => mb_strtolower((string) $collection->title)),
|
||||
default => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->saved_at)),
|
||||
};
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): int
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->getTimestamp();
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp !== false ? $timestamp : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user