$collectionIds * @return array */ public function saveMetadataFor(User $user, array $collectionIds): array { if ($collectionIds === []) { return []; } return CollectionSave::query() ->where('user_id', $user->id) ->whereIn('collection_id', $collectionIds) ->get(['collection_id', 'save_context', 'save_context_meta_json', 'last_viewed_at']) ->mapWithKeys(function (CollectionSave $save): array { return [ (int) $save->collection_id => [ 'saved_because' => $this->savedBecauseLabel($save), 'last_viewed_at' => $save->last_viewed_at?->toIso8601String(), ], ]; }) ->all(); } public function recentlyRevisited(User $user, int $limit = 6): SupportCollection { $savedIds = CollectionSave::query() ->where('user_id', $user->id) ->whereNotNull('last_viewed_at') ->orderByDesc('last_viewed_at') ->limit(max(1, min($limit, 12))) ->pluck('collection_id') ->map(static fn ($id): int => (int) $id) ->all(); if ($savedIds === []) { return collect(); } $collections = Collection::query() ->public() ->with([ 'user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->whereIn('id', $savedIds) ->get() ->keyBy('id'); return collect($savedIds) ->map(fn (int $collectionId) => $collections->get($collectionId)) ->filter() ->values(); } public function listsFor(User $user): array { return CollectionSavedList::query() ->withCount('items') ->where('user_id', $user->id) ->orderBy('title') ->get() ->map(fn (CollectionSavedList $list) => [ 'id' => (int) $list->id, 'title' => $list->title, 'slug' => $list->slug, 'items_count' => (int) $list->items_count, ]) ->all(); } public function findListBySlugForUser(User $user, string $slug): CollectionSavedList { return CollectionSavedList::query() ->withCount('items') ->where('user_id', $user->id) ->where('slug', $slug) ->firstOrFail(); } /** * @param array $collectionIds * @return array> */ public function membershipsFor(User $user, array $collectionIds): array { if ($collectionIds === []) { return []; } return DB::table('collection_saved_list_items as items') ->join('collection_saved_lists as lists', 'lists.id', '=', 'items.saved_list_id') ->where('lists.user_id', $user->id) ->whereIn('items.collection_id', $collectionIds) ->orderBy('items.saved_list_id') ->get(['items.collection_id', 'items.saved_list_id']) ->groupBy('collection_id') ->map(fn ($rows) => collect($rows)->pluck('saved_list_id')->map(static fn ($id) => (int) $id)->values()->all()) ->mapWithKeys(fn ($listIds, $collectionId) => [(int) $collectionId => $listIds]) ->all(); } /** * @param array $collectionIds * @return array */ public function notesFor(User $user, array $collectionIds): array { if ($collectionIds === []) { return []; } return CollectionSavedNote::query() ->where('user_id', $user->id) ->whereIn('collection_id', $collectionIds) ->pluck('note', 'collection_id') ->mapWithKeys(fn ($note, $collectionId) => [(int) $collectionId => (string) $note]) ->all(); } /** * @return array */ public function collectionIdsForList(User $user, CollectionSavedList $list): array { abort_unless((int) $list->user_id === (int) $user->id, 403); return CollectionSavedListItem::query() ->where('saved_list_id', $list->id) ->orderBy('order_num') ->pluck('collection_id') ->map(static fn ($id) => (int) $id) ->all(); } public function createList(User $user, string $title): CollectionSavedList { $slug = $this->uniqueSlug($user, $title); return CollectionSavedList::query()->create([ 'user_id' => $user->id, 'title' => $title, 'slug' => $slug, ]); } public function addToList(User $user, CollectionSavedList $list, Collection $collection): CollectionSavedListItem { abort_unless((int) $list->user_id === (int) $user->id, 403); $nextOrder = (int) (CollectionSavedListItem::query()->where('saved_list_id', $list->id)->max('order_num') ?? -1) + 1; return CollectionSavedListItem::query()->firstOrCreate( [ 'saved_list_id' => $list->id, 'collection_id' => $collection->id, ], [ 'order_num' => $nextOrder, 'created_at' => now(), ] ); } public function removeFromList(User $user, CollectionSavedList $list, Collection $collection): bool { abort_unless((int) $list->user_id === (int) $user->id, 403); $deleted = CollectionSavedListItem::query() ->where('saved_list_id', $list->id) ->where('collection_id', $collection->id) ->delete(); if ($deleted > 0) { $this->normalizeOrder($list); } return $deleted > 0; } /** * @param array $orderedCollectionIds */ public function reorderList(User $user, CollectionSavedList $list, array $orderedCollectionIds): void { abort_unless((int) $list->user_id === (int) $user->id, 403); $normalizedIds = collect($orderedCollectionIds) ->map(static fn ($id) => (int) $id) ->filter(static fn (int $id) => $id > 0) ->values(); $currentIds = collect($this->collectionIdsForList($user, $list))->values(); if ($normalizedIds->count() !== $currentIds->count() || $normalizedIds->diff($currentIds)->isNotEmpty() || $currentIds->diff($normalizedIds)->isNotEmpty()) { throw ValidationException::withMessages([ 'collection_ids' => 'The submitted saved-list order is invalid.', ]); } DB::transaction(function () use ($list, $normalizedIds): void { /** @var SupportCollection $itemIds */ $itemIds = CollectionSavedListItem::query() ->where('saved_list_id', $list->id) ->whereIn('collection_id', $normalizedIds->all()) ->pluck('id', 'collection_id') ->mapWithKeys(static fn ($id, $collectionId) => [(int) $collectionId => (int) $id]); foreach ($normalizedIds as $index => $collectionId) { $itemId = $itemIds->get($collectionId); if (! $itemId) { continue; } CollectionSavedListItem::query() ->whereKey($itemId) ->update(['order_num' => $index]); } }); } public function itemsCount(CollectionSavedList $list): int { return (int) CollectionSavedListItem::query() ->where('saved_list_id', $list->id) ->count(); } public function upsertNote(User $user, Collection $collection, ?string $note): ?CollectionSavedNote { $hasSavedCollection = DB::table('collection_saves') ->where('user_id', $user->id) ->where('collection_id', $collection->id) ->exists(); if (! $hasSavedCollection) { throw ValidationException::withMessages([ 'collection' => 'You can only add notes to collections saved in your library.', ]); } $normalizedNote = trim((string) ($note ?? '')); if ($normalizedNote === '') { CollectionSavedNote::query() ->where('user_id', $user->id) ->where('collection_id', $collection->id) ->delete(); return null; } return CollectionSavedNote::query()->updateOrCreate( [ 'user_id' => $user->id, 'collection_id' => $collection->id, ], [ 'note' => $normalizedNote, ] ); } private function uniqueSlug(User $user, string $title): string { $base = Str::slug(Str::limit($title, 80, '')) ?: 'saved-list'; $slug = $base; $suffix = 2; while (CollectionSavedList::query()->where('user_id', $user->id)->where('slug', $slug)->exists()) { $slug = $base . '-' . $suffix; $suffix++; } return $slug; } private function normalizeOrder(CollectionSavedList $list): void { $itemIds = CollectionSavedListItem::query() ->where('saved_list_id', $list->id) ->orderBy('order_num') ->orderBy('id') ->pluck('id'); foreach ($itemIds as $index => $itemId) { CollectionSavedListItem::query() ->whereKey($itemId) ->update(['order_num' => $index]); } } private function savedBecauseLabel(CollectionSave $save): ?string { $context = trim((string) ($save->save_context ?? '')); $meta = is_array($save->save_context_meta_json) ? $save->save_context_meta_json : []; return match ($context) { 'collection_detail' => 'Saved from the collection page', 'featured_collections' => 'Saved from featured collections', 'featured_landing' => 'Saved from featured collections', 'recommended_landing' => 'Saved from recommended collections', 'trending_landing' => 'Saved from trending collections', 'community_landing' => 'Saved from community collections', 'editorial_landing' => 'Saved from editorial collections', 'seasonal_landing' => 'Saved from seasonal collections', 'collection_search' => ! empty($meta['query']) ? sprintf('Saved from search for "%s"', (string) $meta['query']) : 'Saved from collection search', 'community_row', 'trending_row', 'editorial_row', 'seasonal_row', 'recent_row' => ! empty($meta['surface_label']) ? sprintf('Saved from %s', (string) $meta['surface_label']) : 'Saved from a collection rail', 'program_landing' => ! empty($meta['program_label']) ? sprintf('Saved from the %s program', (string) $meta['program_label']) : (! empty($meta['program_key']) ? sprintf('Saved from the %s program', (string) $meta['program_key']) : 'Saved from a program landing'), 'campaign_landing' => ! empty($meta['campaign_label']) ? sprintf('Saved during %s', (string) $meta['campaign_label']) : (! empty($meta['campaign_key']) ? sprintf('Saved during %s', (string) $meta['campaign_key']) : 'Saved from a campaign landing'), default => $context !== '' ? str_replace('_', ' ', ucfirst($context)) : null, }; } }