— tag slugs (AND match) * category string * orientation string — landscape | portrait | square * resolution string — e.g. "1920x1080" * author_id int * sort string — created_at|downloads|likes|views (suffix :asc or :desc) */ public function search(string $q, array $filters = [], int $perPage = 24): LengthAwarePaginator { $filterParts = [self::BASE_FILTER]; $sort = []; if (! empty($filters['tags'])) { foreach ((array) $filters['tags'] as $tag) { $filterParts[] = 'tags = "' . addslashes((string) $tag) . '"'; } } if (! empty($filters['category'])) { $filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"'; } if (! empty($filters['orientation'])) { $filterParts[] = 'orientation = "' . addslashes((string) $filters['orientation']) . '"'; } if (! empty($filters['resolution'])) { $filterParts[] = 'resolution = "' . addslashes((string) $filters['resolution']) . '"'; } if (! empty($filters['author_id'])) { $filterParts[] = 'author_id = ' . (int) $filters['author_id']; } if (! empty($filters['sort'])) { [$field, $dir] = $this->parseSort((string) $filters['sort']); if ($field) { $sort[] = $field . ':' . $dir; } } $options = ['filter' => implode(' AND ', $filterParts)]; if ($sort !== []) { $options['sort'] = $sort; } return Artwork::search($q ?: '') ->options($options) ->paginate($perPage); } /** * Load artworks for a tag page, sorted by views + likes descending. */ public function byTag(string $slug, int $perPage = 24): LengthAwarePaginator { $tag = Tag::where('slug', $slug)->first(); if (! $tag) { return $this->emptyPaginator($perPage); } $cacheKey = "search.tag.{$slug}.page." . request()->get('page', 1); return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($slug, $perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"', 'sort' => ['views:desc', 'likes:desc'], ]) ->paginate($perPage); }); } /** * Load artworks for a category, sorted by created_at desc. */ public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator { $cacheKey = "search.cat.{$cat}.page." . request()->get('page', 1); return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"', 'sort' => ['created_at:desc'], ]) ->paginate($perPage); }); } // ── Category / Content-Type page sorts ──────────────────────────────────── /** * Meilisearch sort fields per alias. * Used by categoryPageSort() and contentTypePageSort(). */ private const CATEGORY_SORT_FIELDS = [ 'trending' => ['trending_score_24h:desc', 'created_at:desc'], 'fresh' => ['created_at:desc'], 'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'], 'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'], 'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'], 'oldest' => ['created_at:asc'], ]; /** Cache TTL (seconds) per sort alias for category pages. */ private const CATEGORY_SORT_TTL = [ 'trending' => 300, // 5 min 'fresh' => 120, // 2 min 'top-rated' => 600, // 10 min 'favorited' => 300, 'downloaded' => 300, 'oldest' => 600, ]; /** * Artworks for a single category page, sorted via Meilisearch. * Default sort: trending (trending_score_24h:desc). * * Cache key pattern: category.{slug}.{sort}.{page} * TTL varies by sort (see spec: 5/2/10 min). */ public function categoryPageSort(string $categorySlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator { $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; $page = (int) request()->get('page', 1); $ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL; $cacheKey = "category.{$categorySlug}.{$sort}.{$page}"; return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"', 'sort' => self::CATEGORY_SORT_FIELDS[$sort], ]) ->paginate($perPage); }); } /** * Artworks for a content-type root page, sorted via Meilisearch. * Default sort: trending. * * Cache key pattern: content_type.{slug}.{sort}.{page} */ public function contentTypePageSort(string $contentTypeSlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator { $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; $page = (int) request()->get('page', 1); $ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL; $cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$page}"; return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"', 'sort' => self::CATEGORY_SORT_FIELDS[$sort], ]) ->paginate($perPage); }); } // ------------------------------------------------------------------------- /** * Related artworks: same tags, different artwork, ranked by views + likes. * Limit 12. */ public function related(Artwork $artwork, int $limit = 12): LengthAwarePaginator { $tags = $artwork->tags()->pluck('tags.slug')->values()->all(); if ($tags === []) { return $this->popular($limit); } $cacheKey = "search.related.{$artwork->id}"; return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) { $tagFilters = implode(' OR ', array_map( fn ($t) => 'tags = "' . addslashes($t) . '"', $tags )); return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')', 'sort' => ['views:desc', 'likes:desc'], ]) ->paginate($limit); }); } /** * Most popular artworks by views. */ public function popular(int $perPage = 24): LengthAwarePaginator { return Cache::remember('search.popular.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, 'sort' => ['views:desc', 'likes:desc'], ]) ->paginate($perPage); }); } /** * Most recent artworks by created_at. */ public function recent(int $perPage = 24): LengthAwarePaginator { return Cache::remember('search.recent.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, 'sort' => ['created_at:desc'], ]) ->paginate($perPage); }); } // ── Discover section helpers ─────────────────────────────────────────────── /** * Trending: sorted by pre-computed trending_score_24h (recalculated every 30 min). * Falls back to views:desc if the column is not yet populated. */ public function discoverTrending(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, 'sort' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'views:desc', 'created_at:desc'], ]) ->paginate($perPage); }); } /** * Fresh: newest uploads first. */ public function discoverFresh(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, 'sort' => ['created_at:desc'], ]) ->paginate($perPage); }); } /** * Top rated: highest number of favourites/likes. */ public function discoverTopRated(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, 'sort' => ['likes:desc', 'views:desc'], ]) ->paginate($perPage); }); } /** * Most downloaded: highest download count. */ public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, 'sort' => ['downloads:desc', 'views:desc'], ]) ->paginate($perPage); }); } /** * Artworks matching any of the given tag slugs, sorted by trending score. * Used for personalized "Because you like {tags}" homepage section. * * @param string[] $tagSlugs */ public function discoverByTags(array $tagSlugs, int $limit = 12): LengthAwarePaginator { if (empty($tagSlugs)) { return $this->popular($limit); } $tagFilter = implode(' OR ', array_map( fn (string $t): string => 'tags = "' . addslashes($t) . '"', array_slice($tagSlugs, 0, 5) )); $cacheKey = 'discover.by-tags.' . md5(implode(',', $tagSlugs)); return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')', 'sort' => ['trending_score_7d:desc', 'likes:desc'], ]) ->paginate($limit); }); } /** * Fresh artworks in given categories, sorted by created_at desc. * Used for personalized "Fresh in your favourite categories" section. * * @param string[] $categorySlugs */ public function discoverByCategories(array $categorySlugs, int $limit = 12): LengthAwarePaginator { if (empty($categorySlugs)) { return $this->recent($limit); } $catFilter = implode(' OR ', array_map( fn (string $c): string => 'category = "' . addslashes($c) . '"', array_slice($categorySlugs, 0, 3) )); $cacheKey = 'discover.by-cats.' . md5(implode(',', $categorySlugs)); return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')', 'sort' => ['created_at:desc'], ]) ->paginate($limit); }); } // ------------------------------------------------------------------------- private function parseSort(string $sort): array { $allowed = ['created_at', 'downloads', 'likes', 'views']; $parts = explode(':', $sort, 2); $field = $parts[0] ?? ''; $dir = strtolower($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc'; return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc']; } private function emptyPaginator(int $perPage): LengthAwarePaginator { return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage); } }