['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], 'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], 'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'], 'latest' => ['created_at:desc'], // Legacy aliases kept for backward compatibility. 'new-hot' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], 'best' => ['awards_received_count:desc', 'favorites_count:desc'], ]; private const SORT_TTL = [ 'trending' => 300, 'fresh' => 120, 'top-rated'=> 600, 'latest' => 120, 'new-hot' => 120, 'best' => 600, ]; private const SORT_OPTIONS = [ ['value' => 'trending', 'label' => '๐Ÿ”ฅ Trending'], ['value' => 'fresh', 'label' => '๐Ÿš€ New & Hot'], ['value' => 'top-rated', 'label' => 'โญ Best'], ['value' => 'latest', 'label' => '๐Ÿ• Latest'], ]; private const SORT_ALIASES = [ 'new-hot' => 'fresh', 'best' => 'top-rated', ]; public function __construct( private readonly ArtworkSearchService $search, private readonly GridFiller $gridFiller, private readonly SpotlightEngineInterface $spotlight, ) {} // โ”€โ”€ /explore (hub) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ public function index(Request $request) { $sort = $this->resolveSort($request); $perPage = $this->resolvePerPage($request); $page = max(1, (int) $request->query('page', 1)); $ttl = self::SORT_TTL[$sort] ?? 300; $artworks = Cache::remember("explore.all.{$sort}.{$page}", $ttl, fn () => Artwork::search('')->options([ 'filter' => 'is_public = true AND is_approved = true', 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], ])->paginate($perPage) ); // EGS: fill grid to minimum when uploads are sparse $artworks = $this->gridFiller->fill($artworks, 0, $page); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); // EGS ยง11: featured spotlight row on page 1 only $spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled()) ? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a)) : collect(); $mainCategories = $this->mainCategories(); $seo = $this->paginationSeo($request, url('/explore'), $artworks); return view('gallery.index', [ 'gallery_type' => 'browse', 'mainCategories' => $mainCategories, 'subcategories' => $mainCategories, 'contentType' => null, 'category' => null, 'artworks' => $artworks, 'spotlight' => $spotlightItems, 'current_sort' => $sort, 'sort_options' => self::SORT_OPTIONS, 'hero_title' => 'Explore', 'hero_description' => 'Browse the full Skinbase catalog โ€” wallpapers, skins, photography and more.', 'breadcrumbs' => collect([(object) ['name' => 'Explore', 'url' => '/explore']]), 'page_title' => 'Explore Artworks - Skinbase', 'page_meta_description' => 'Explore the full catalog of wallpapers, skins, photography and other artworks on Skinbase.', 'page_meta_keywords' => 'explore, wallpapers, skins, photography, artworks, skinbase', 'page_canonical' => $seo['canonical'], 'page_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], 'page_robots' => 'index,follow', ]); } // โ”€โ”€ /explore/:type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ public function byType(Request $request, string $type) { $type = strtolower($type); if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) { abort(404); } // "artworks" is the umbrella โ€” search all types $isAll = $type === 'artworks'; // Canonical URLs for content types are /skins, /wallpapers, /photography, /other. if (! $isAll) { return redirect()->to($this->canonicalTypeUrl($request, $type), 301); } $sort = $this->resolveSort($request); $perPage = $this->resolvePerPage($request); $page = max(1, (int) $request->query('page', 1)); $ttl = self::SORT_TTL[$sort] ?? 300; $filter = 'is_public = true AND is_approved = true'; if (!$isAll) { $filter .= ' AND content_type = "' . $type . '"'; } $artworks = Cache::remember("explore.{$type}.{$sort}.{$page}", $ttl, fn () => Artwork::search('')->options([ 'filter' => $filter, 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], ])->paginate($perPage) ); // EGS: fill grid to minimum when uploads are sparse $artworks = $this->gridFiller->fill($artworks, 0, $page); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); // EGS ยง11: featured spotlight row on page 1 only $spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled()) ? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a)) : collect(); $mainCategories = $this->mainCategories(); $contentType = null; $subcategories = $mainCategories; if (! $isAll) { $contentType = ContentType::where('slug', $type)->first(); $subcategories = $contentType ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() : collect(); } if ($isAll) { $humanType = 'Artworks'; } else { $humanType = $contentType?->name ?? ucfirst($type); } $baseUrl = url('/explore/' . $type); $seo = $this->paginationSeo($request, $baseUrl, $artworks); return view('gallery.index', [ 'gallery_type' => $isAll ? 'browse' : 'content-type', 'mainCategories' => $mainCategories, 'subcategories' => $subcategories, 'contentType' => $contentType, 'category' => null, 'artworks' => $artworks, 'spotlight' => $spotlightItems, 'current_sort' => $sort, 'sort_options' => self::SORT_OPTIONS, 'hero_title' => $humanType, 'hero_description' => "Browse {$humanType} on Skinbase.", 'breadcrumbs' => collect([ (object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => $humanType, 'url' => "/explore/{$type}"], ]), 'page_title' => "{$humanType} - Explore - Skinbase", 'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.", 'page_meta_keywords' => strtolower($type) . ', explore, skinbase, artworks, wallpapers, skins, photography', 'page_canonical' => $seo['canonical'], 'page_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], 'page_robots' => 'index,follow', ]); } // โ”€โ”€ /explore/:type/:mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ public function byTypeMode(Request $request, string $type, string $mode) { $type = strtolower($type); if ($type !== 'artworks') { $query = $request->query(); $query['sort'] = $this->normalizeSort((string) $mode); return redirect()->to($this->canonicalTypeUrl($request, $type, $query), 301); } // Rewrite the sort via the URL segment and delegate $request->query->set('sort', $mode); return $this->byType($request, $type); } // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private function mainCategories(): Collection { $categories = ContentType::orderBy('id') ->get(['name', 'slug']) ->map(fn ($ct) => (object) [ 'name' => $ct->name, 'slug' => $ct->slug, 'url' => '/' . strtolower($ct->slug), ]); return $categories->push((object) [ 'name' => 'Members', 'slug' => 'members', 'url' => '/members', ]); } private function resolveSort(Request $request): string { $s = $this->normalizeSort((string) $request->query('sort', 'trending')); return array_key_exists($s, self::SORT_MAP) ? $s : 'trending'; } private function normalizeSort(string $sort): string { $sort = strtolower($sort); return self::SORT_ALIASES[$sort] ?? $sort; } private function canonicalTypeUrl(Request $request, string $type, ?array $query = null): string { $query = $query ?? $request->query(); if (isset($query['sort'])) { $query['sort'] = $this->normalizeSort((string) $query['sort']); if ($query['sort'] === 'trending') { unset($query['sort']); } } return url('/' . $type) . ($query ? ('?' . http_build_query($query)) : ''); } private function resolvePerPage(Request $request): int { $v = (int) ($request->query('per_page') ?: $request->query('limit') ?: 24); return max(12, min($v, 80)); } private function presentArtwork(Artwork $artwork): object { $primary = $artwork->categories->sortBy('sort_order')->first(); $present = ThumbnailPresenter::present($artwork, 'md'); $avatarUrl = \App\Support\AvatarUrl::forUser( (int) ($artwork->user_id ?? 0), $artwork->user?->profile?->avatar_hash ?? null, 64 ); return (object) [ 'id' => $artwork->id, 'name' => $artwork->title, 'category_name' => $primary->name ?? '', 'category_slug' => $primary->slug ?? '', 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], 'uname' => $artwork->user?->name ?? 'Skinbase', 'username' => $artwork->user?->username ?? '', 'avatar_url' => $avatarUrl, 'published_at' => $artwork->published_at, 'slug' => $artwork->slug ?? '', 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, ]; } private function paginationSeo(Request $request, string $base, mixed $paginator): array { $q = $request->query(); unset($q['grid']); if (($q['page'] ?? null) !== null && (int) $q['page'] <= 1) { unset($q['page']); } $canonical = $base . ($q ? '?' . http_build_query($q) : ''); $prev = null; $next = null; if ($paginator instanceof AbstractPaginator || $paginator instanceof AbstractCursorPaginator) { $prev = $paginator->previousPageUrl(); $next = $paginator->nextPageUrl(); } return compact('canonical', 'prev', 'next'); } }