['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, private readonly ContentTypeSlugResolver $contentTypeResolver, private readonly ArtworkMaturityService $maturity, ) {} // ── /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; $cacheVersion = $this->cacheVersion(); $filter = $this->buildExploreFilterExpression($request); $cacheSuffix = $this->requestCacheSuffix($request); $artworks = Cache::remember("explore.all.v{$cacheVersion}.{$cacheSuffix}.{$page}", $ttl, fn () => $this->search->searchWithThumbnailPreference([ 'filter' => $filter, 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], ], $perPage, false, $page) ); $artworks = $this->filterBrowsableArtworks($artworks); // 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) { $resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true); if (! $resolution->found()) { abort(404); } $isAll = $resolution->isVirtual && $resolution->virtualType === 'artworks'; if (! $isAll && $resolution->contentType === null) { abort(404); } $resolvedTypeSlug = $isAll ? 'artworks' : strtolower((string) $resolution->contentType->slug); // Canonical URLs for content types are /skins, /wallpapers, /photography, /other. if (! $isAll) { return redirect()->to($this->canonicalTypeUrl($request, $resolvedTypeSlug), 301); } $sort = $this->resolveSort($request); $perPage = $this->resolvePerPage($request); $page = max(1, (int) $request->query('page', 1)); $ttl = self::SORT_TTL[$sort] ?? 300; $cacheVersion = $this->cacheVersion(); $filter = $this->buildExploreFilterExpression($request, $isAll ? null : $resolvedTypeSlug); $cacheSuffix = $this->requestCacheSuffix($request); $artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$cacheSuffix}.{$page}", $ttl, fn () => $this->search->searchWithThumbnailPreference([ 'filter' => $filter, 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], ], $perPage, false, $page) ); $artworks = $this->filterBrowsableArtworks($artworks); // 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 = $resolution->contentType; $subcategories = $contentType ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() : collect(); } if ($isAll) { $humanType = 'Artworks'; } else { $humanType = $contentType?->name ?? ucfirst($resolvedTypeSlug); } $baseUrl = url('/explore/' . $resolvedTypeSlug); $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/{$resolvedTypeSlug}"], ]), '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($resolvedTypeSlug) . ', 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) { $resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true); if (! $resolution->found()) { abort(404); } if (! ($resolution->isVirtual && $resolution->virtualType === 'artworks')) { $query = $request->query(); $query['sort'] = $this->normalizeSort((string) $mode); return redirect()->to($this->canonicalTypeUrl($request, strtolower((string) $resolution->contentType?->slug), $query), 301); } // Rewrite the sort via the URL segment and delegate $request->query->set('sort', $mode); return $this->byType($request, $type); } // ── /explore/best (Hall of Fame) ──────────────────────────────────── /** * Hall of Fame: all-time highest-medal artworks, ranked by prestige. * * Algorithm: * 1. Primary: score_total DESC (all-time weighted medal score: goldΓ—5 + silverΓ—3 + bronzeΓ—1) * 2. Secondary: gold_count DESC (prestige tiebreak β€” golds are rarer and more deliberate) * 3. Tertiary: favorites_count DESC (overall community love) * * Only artworks published β‰₯ 30 days ago are eligible so freshly-viral * pieces don't crowd out genuine all-time standouts. * * Cache TTL is 1 hour β€” rankings shift slowly for the HoF. */ public function hallOfFame(Request $request) { $perPage = 24; $page = max(1, (int) $request->query('page', 1)); $minAge = now()->subDays(30); $maturityUser = $request->user(); $cacheVersion = $this->cacheVersion(); $viewerSegment = $maturityUser ? 'auth.' . $maturityUser->id : 'guest'; $cacheKey = "explore.hall-of-fame.v{$cacheVersion}.{$viewerSegment}.p{$page}"; $paginator = Cache::remember($cacheKey, 3600, function () use ($perPage, $page, $minAge, $maturityUser): LengthAwarePaginator { $query = Artwork::query() ->public() ->published() ->tap(fn ($b) => $this->maturity->applyViewerFilter($b, $maturityUser)) ->withoutMissingThumbnails() ->with([ 'user:id,name,username', 'user.profile:user_id,avatar_hash', 'group:id,name,slug,headline,avatar_path,followers_count', 'categories:id,name,slug,content_type_id,sort_order', 'categories.contentType:id,name,slug', 'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total', 'stats:artwork_id,favorites', ]) ->leftJoin('artwork_medal_stats as hof', 'hof.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats as hof_stats', 'hof_stats.artwork_id', '=', 'artworks.id') ->select('artworks.*') // Must have at least one medal ->whereRaw('COALESCE(hof.score_total, 0) > 0') // Minimum 30-day age to exclude freshly-viral pieces ->where('artworks.published_at', '<=', $minAge) // Ranking: prestige-weighted medal score, then gold count, then favorites ->orderByRaw('COALESCE(hof.score_total, 0) DESC') ->orderByRaw('COALESCE(hof.gold_count, 0) DESC') ->orderByRaw('COALESCE(hof_stats.favorites, 0) DESC'); return $query->paginate($perPage, ['artworks.*'], 'page', $page) ->withPath(url('/explore/best')); }); $paginator->getCollection()->transform(fn (Artwork $a) => $this->presentArtwork($a)); $mainCategories = $this->mainCategories(); $seo = $this->paginationSeo($request, url('/explore/best'), $paginator); return view('gallery.index', [ 'gallery_type' => 'browse', 'gallery_nav_section' => 'artworks', 'mainCategories' => $mainCategories, 'subcategories' => $mainCategories, 'contentType' => null, 'category' => null, 'artworks' => $paginator, 'spotlight' => collect(), 'hide_rank_tabs' => true, 'current_sort' => 'top-rated', 'sort_options' => [], 'hero_title' => 'Hall of Fame', 'hero_description' => 'All-time medal standouts ranked by prestige β€” the artworks the community has honoured most across the years.', 'breadcrumbs' => collect([ (object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => 'Hall of Fame', 'url' => '/explore/best'], ]), 'page_title' => 'Hall of Fame β€” All-Time Best Artworks - Skinbase', 'page_meta_description' => 'The highest-medal artworks of all time on Skinbase, ranked by gold, silver and bronze prestige.', 'page_meta_keywords' => 'hall of fame, best artworks, top rated, medals, skinbase', 'page_canonical' => $seo['canonical'], 'page_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], 'page_robots' => 'index,follow', ]); } // ── Helpers ────────────────────────────────────────────────────────── private function mainCategories(): Collection { $categories = $this->contentTypeResolver ->toolbarContentTypes() ->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 requestCacheSuffix(Request $request): string { $query = $request->query(); unset($query['grid'], $query['page']); ksort($query); return md5(json_encode($query, JSON_THROW_ON_ERROR)); } private function cacheVersion(): int { return max(1, (int) Cache::get('explore.cache.version', 1)); } private function buildExploreFilterExpression(Request $request, ?string $contentType = null): string { $filterParts = [ 'is_public = true', 'is_approved = true', ]; if ($contentType !== null && $contentType !== '') { $quoted = addslashes($contentType); $filterParts[] = '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")'; } $orientation = strtolower(trim((string) $request->query('orientation', ''))); if (in_array($orientation, ['landscape', 'portrait', 'square'], true)) { $filterParts[] = 'orientation = "' . addslashes($orientation) . '"'; } $resolution = $this->resolutionFilterValue((string) $request->query('resolution', '')); if ($resolution !== null) { $filterParts[] = 'resolution = "' . addslashes($resolution) . '"'; } $dateFrom = $this->normalizeDateQuery((string) $request->query('date_from', '')); if ($dateFrom !== null) { $filterParts[] = 'created_at >= "' . $dateFrom . '"'; } $dateTo = $this->normalizeDateQuery((string) $request->query('date_to', '')); if ($dateTo !== null) { $filterParts[] = 'created_at <= "' . $dateTo . '"'; } $authorFilter = $this->authorFilterExpression((string) $request->query('author', '')); if ($authorFilter !== null) { $filterParts[] = $authorFilter; } return implode(' AND ', $filterParts); } private function resolutionFilterValue(string $resolution): ?string { return match (strtolower(trim($resolution))) { 'hd' => '1280x720', 'fhd' => '1920x1080', '2k' => '2560x1440', '4k' => '3840x2160', default => null, }; } private function normalizeDateQuery(string $value): ?string { $value = trim($value); if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { return null; } return $value; } private function authorFilterExpression(string $author): ?string { $author = trim($author); if ($author === '') { return null; } $userIds = User::query() ->where(function ($query) use ($author): void { $query->where('username', 'like', '%' . $author . '%') ->orWhere('name', 'like', '%' . $author . '%'); }) ->limit(20) ->pluck('id'); $groupIds = Group::query() ->where(function ($query) use ($author): void { $query->where('name', 'like', '%' . $author . '%') ->orWhere('slug', 'like', '%' . $author . '%'); }) ->limit(20) ->pluck('id'); $clauses = []; foreach ($userIds as $userId) { $clauses[] = '(author_id = ' . (int) $userId . ' AND published_as_type = "user")'; } foreach ($groupIds as $groupId) { $clauses[] = '(author_id = ' . (int) $groupId . ' AND published_as_type = "group")'; } if ($clauses === []) { return 'id = 0'; } return '(' . implode(' OR ', $clauses) . ')'; } private function filterBrowsableArtworks(AbstractPaginator $paginator): AbstractPaginator { $paginator->setCollection( $paginator->getCollection() ->filter(fn ($artwork) => $artwork instanceof Artwork && $artwork->deleted_at === null && (bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at !== null) ->values() ); return $paginator; } private function presentArtwork(Artwork $artwork): object { $primary = $artwork->categories->sortBy('sort_order')->first(); $present = ThumbnailPresenter::present($artwork, 'md'); $group = $artwork->group; $isGroupPublisher = $group !== null; $avatarUrl = $isGroupPublisher ? $group->avatarUrl() : \App\Support\AvatarUrl::forUser( (int) ($artwork->user_id ?? 0), $artwork->user?->profile?->avatar_hash ?? null, 64 ); $displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase'); $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); return (object) $this->maturity->decoratePayload([ 'id' => $artwork->id, 'name' => $artwork->title, 'content_type_name' => $primary?->contentType?->name ?? '', 'content_type_slug' => $primary?->contentType?->slug ?? '', 'category_name' => $primary->name ?? '', 'category_slug' => $primary->slug ?? '', 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], 'uname' => $displayName, 'username' => $username, 'avatar_url' => $avatarUrl, 'profile_url' => $profileUrl, 'published_as_type' => $isGroupPublisher ? 'group' : 'user', 'publisher' => [ 'type' => $isGroupPublisher ? 'group' : 'user', 'name' => $displayName, 'username' => $username, 'avatar_url' => $avatarUrl, 'profile_url' => $profileUrl, ], 'published_at' => $artwork->published_at, 'slug' => $artwork->slug ?? '', 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, ], $artwork, request()->user()); } 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'); } }