['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], 'new-hot' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], 'best' => ['awards_received_count:desc', 'favorites_count:desc'], 'latest' => ['created_at:desc'], ]; private const SORT_TTL = [ 'trending' => 300, 'new-hot' => 120, 'best' => 600, 'latest' => 120, ]; private const SORT_OPTIONS = [ ['value' => 'trending', 'label' => '๐Ÿ”ฅ Trending'], ['value' => 'new-hot', 'label' => '๐Ÿš€ New & Hot'], ['value' => 'best', 'label' => 'โญ Best'], ['value' => 'latest', 'label' => '๐Ÿ• Latest'], ]; 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(); $contentTypes = $this->contentTypeLinks(); $seo = $this->paginationSeo($request, url('/explore'), $artworks); return view('web.explore.index', [ 'artworks' => $artworks, 'spotlight' => $spotlightItems, 'contentTypes' => $contentTypes, 'activeType' => null, '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_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'; $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(); $contentTypes = $this->contentTypeLinks(); $baseUrl = url('/explore/' . $type); $seo = $this->paginationSeo($request, $baseUrl, $artworks); $humanType = ucfirst($type); return view('web.explore.index', [ 'artworks' => $artworks, 'spotlight' => $spotlightItems, 'contentTypes' => $contentTypes, 'activeType' => $type, '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_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) { // Rewrite the sort via the URL segment and delegate $request->query->set('sort', $mode); return $this->byType($request, $type); } // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private function contentTypeLinks(): Collection { return collect([ (object) ['name' => 'All Artworks', 'slug' => 'artworks', 'url' => '/explore/artworks'], ...ContentType::orderBy('id')->get(['name', 'slug'])->map(fn ($ct) => (object) [ 'name' => $ct->name, 'slug' => $ct->slug, 'url' => '/explore/' . strtolower($ct->slug), ]), ]); } private function resolveSort(Request $request): string { $s = (string) $request->query('sort', 'trending'); return array_key_exists($s, self::SORT_MAP) ? $s : 'trending'; } 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'); } }