['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'published_at_ts:desc'], // "New & Hot": 30-day trending window surfaces recently-active artworks. 'fresh' => ['published_at_ts:desc', 'trending_score_7d:desc', 'favorites_count:desc'], 'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'], 'favorited' => ['favorites_count:desc', 'trending_score_24h:desc', 'published_at_ts:desc'], 'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc', 'published_at_ts:desc'], 'oldest' => ['published_at_ts:asc'], // ── Legacy aliases (backward compat) ────────────────────────────────── 'latest' => ['published_at_ts:desc'], 'popular' => ['views:desc', 'favorites_count:desc', 'published_at_ts:desc'], 'liked' => ['likes:desc', 'favorites_count:desc', 'published_at_ts:desc'], 'downloads' => ['downloads:desc', 'downloads_count:desc', 'published_at_ts:desc'], ]; /** * Cache TTL (seconds) per sort alias. * trending → 5 min * fresh → 2 min * top-rated → 10 min * others → 5 min */ private const SORT_TTL_MAP = [ 'trending' => 300, 'fresh' => 120, 'top-rated' => 600, 'favorited' => 300, 'downloaded' => 300, 'oldest' => 600, 'latest' => 120, 'popular' => 300, 'liked' => 300, 'downloads' => 300, ]; /** Human-readable sort options passed to every gallery view. */ private const SORT_OPTIONS = [ ['value' => 'trending', 'label' => '🔥 Trending'], ['value' => 'fresh', 'label' => '🆕 Fresh'], ['value' => 'latest', 'label' => '🕐 Latest'], ['value' => 'top-rated', 'label' => '⭐ Top Rated'], ['value' => 'favorited', 'label' => '❤️ Most Favorited'], ['value' => 'downloaded', 'label' => '⬇ Most Downloaded'], ['value' => 'oldest', 'label' => '📅 Oldest'], ]; public function __construct( private ArtworkService $artworks, private ArtworkSearchService $search, private ContentTypeSlugResolver $contentTypeResolver, private ArtworkMaturityService $maturity, ) { } public function browse(Request $request) { $sort = $this->resolveSort($request, 'trending'); $perPage = $this->resolvePerPage($request); $page = (int) $request->query('page', 1); $ttl = self::SORT_TTL_MAP[$sort] ?? 300; $artworks = Cache::remember( "browse.all.catalog-visible." . self::CACHE_VERSION . ".{$sort}.{$page}", $ttl, fn () => $this->search->searchWithThumbnailPreference([ 'filter' => 'is_public = true AND is_approved = true', 'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'], ], $perPage, false, $page) ); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $seo = $this->buildPaginationSeo($request, url('/browse'), $artworks); $mainCategories = $this->mainCategories(); return view('gallery.index', [ 'gallery_type' => 'browse', 'mainCategories' => $mainCategories, 'subcategories' => $mainCategories, 'contentType' => null, 'category' => null, 'artworks' => $artworks, 'current_sort' => $sort, 'sort_options' => self::SORT_OPTIONS, 'hero_title' => 'Browse Artworks', 'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.', 'breadcrumbs' => collect([(object) ['name' => 'Explore', 'url' => '/browse']]), 'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase', 'page_meta_description' => "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.", 'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo', 'page_canonical' => $seo['canonical'], 'page_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], 'page_robots' => 'index,follow', ]); } public function content(Request $request, string $contentTypeSlug, ?string $path = null) { $requestedSlug = strtolower($contentTypeSlug); $resolution = $this->contentTypeResolver->resolve($requestedSlug); if (! $resolution->found() || $resolution->contentType === null) { abort(404); } $contentType = $resolution->contentType; $contentSlug = strtolower((string) $contentType->slug); if ($resolution->requiresRedirect()) { return $this->redirectToContentTypePath($request, $contentSlug, $path, 301); } // Default sort: trending (not chronological) $sort = $this->resolveSort($request, 'trending'); $perPage = $this->resolvePerPage($request); $page = (int) $request->query('page', 1); $ttl = self::SORT_TTL_MAP[$sort] ?? 300; $mainCategories = $this->mainCategories(); $rootCategories = $contentType->rootCategories() ->with('contentType') ->orderBy('sort_order') ->orderBy('name') ->get(); $rootCategoryLinks = $this->buildCategoryLinkItems($rootCategories, $contentSlug); $normalizedPath = trim((string) $path, '/'); if ($normalizedPath === '') { $artworks = Cache::remember( "gallery.ct.catalog-visible." . self::CACHE_VERSION . ".{$contentSlug}.{$sort}.{$page}", $ttl, fn () => $this->search->searchWithThumbnailPreference([ 'filter' => 'is_public = true AND is_approved = true AND ' . $this->contentTypeFilterClause($contentSlug), 'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'], ], $perPage, false, $page) ); $this->loadGalleryArtworkRelations($artworks->getCollection()); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks); return view('gallery.index', [ 'gallery_type' => 'content-type', 'mainCategories' => $mainCategories, 'subcategories' => $rootCategoryLinks, 'contentType' => $contentType, 'category' => null, 'artworks' => $artworks, 'current_sort' => $sort, 'sort_options' => self::SORT_OPTIONS, 'hero_title' => $contentType->name, 'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'), 'breadcrumbs' => collect([ (object) ['name' => 'Explore', 'url' => route('explore.index')], (object) ['name' => $contentType->name, 'url' => '/' . $contentSlug], ]), 'page_title' => $contentType->name . ' – Skinbase', 'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'), 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', 'page_canonical' => $seo['canonical'], 'page_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], 'page_robots' => 'index,follow', ]); } $segments = array_values(array_filter(explode('/', $normalizedPath))); $category = Category::findByPath($contentSlug, $segments); if (! $category) { abort(404); } $this->loadCategoryLineage($category); $categorySlugs = $this->categoryFilterSlugs($category); $filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs); $artworks = Cache::remember( 'gallery.cat.catalog-visible.' . self::CACHE_VERSION . '.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}", $ttl, fn () => $this->search->searchWithThumbnailPreference([ 'filter' => $filterExpression, 'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'], ], $perPage, false, $page) ); $this->loadGalleryArtworkRelations($artworks->getCollection()); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks); $navigationCategory = $category->parent ?: $category; $navigationPath = strtolower($navigationCategory->full_slug_path); $subcategoryParent = (object) [ 'id' => $navigationCategory->id, 'url' => $this->buildCategoryUrl($contentSlug, $navigationPath), ]; $subcategories = $navigationCategory->children() ->with(['contentType', 'parent.contentType']) ->orderBy('sort_order') ->orderBy('name') ->get(); $subcategoryLinks = $this->buildCategoryLinkItems($subcategories, $contentSlug, $navigationPath); if ($subcategories->isEmpty()) { $subcategoryLinks = $rootCategoryLinks; } $breadcrumbs = collect(array_merge([ (object) [ 'name' => 'Explore', 'url' => route('explore.index'), ], (object) [ 'name' => $contentType->name, 'url' => '/' . $contentSlug, ], ], $category->breadcrumbs)) ->map(function ($crumb) { return (object) [ 'name' => $crumb->name, 'url' => $crumb->url, ]; }); return view('gallery.index', [ 'gallery_type' => 'category', 'mainCategories' => $mainCategories, 'subcategories' => $subcategoryLinks, 'subcategory_parent' => $subcategoryParent, 'contentType' => $contentType, 'category' => $category, 'artworks' => $artworks, 'current_sort' => $sort, 'sort_options' => self::SORT_OPTIONS, 'hero_title' => $category->name, 'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'), 'breadcrumbs' => $breadcrumbs, 'page_title' => $category->name . ' – Skinbase', 'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'), 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', 'page_canonical' => $seo['canonical'], 'page_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], 'page_robots' => 'index,follow', ]); } public function showArtwork(...$params) { $req = request(); $pathSegments = array_values(array_filter(explode('/', trim($req->path(), '/')))); $contentTypeSlug = $params[0] ?? ($pathSegments[0] ?? null); $categoryPath = $params[1] ?? null; $artwork = $params[2] ?? null; // If artwork wasn't provided (some route invocations supply fewer args), // derive it from the request path's last segment. if ($artwork === null) { $artwork = end($pathSegments) ?: null; } $contentTypeSlug = strtolower((string) $contentTypeSlug); $categoryPath = $categoryPath !== null ? trim((string) $categoryPath, '/') : (isset($pathSegments[1]) ? implode('/', array_slice($pathSegments, 1, max(0, count($pathSegments) - 2))) : ''); $resolution = $this->contentTypeResolver->resolve($contentTypeSlug); if (! $resolution->found() || $resolution->contentType === null) { abort(404); } $resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug); // Normalize artwork param if route-model binding returned an Artwork model $artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork; if ($resolution->requiresRedirect()) { $path = trim($categoryPath . '/' . $artworkSlug, '/'); return $this->redirectToContentTypePath($req, $resolvedContentTypeSlug, $path, 301); } return app(\App\Http\Controllers\ArtworkController::class)->show( $req, $resolvedContentTypeSlug, $categoryPath, $artworkSlug ); } private function presentArtwork(Artwork $artwork): object { $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $present = ThumbnailPresenter::present($artwork, 'md'); $group = $artwork->group; $isGroupPublisher = $group !== null; $avatarHash = $artwork->user?->profile?->avatar_hash ?? null; $avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($avatarHash !== null ? \App\Support\AvatarUrl::forUser((int) ($artwork->user_id ?? 0), $avatarHash, 64) : \App\Support\AvatarUrl::default()); $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, 'slug' => $artwork->slug, 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]), 'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $primaryCategory->name ?? '', 'category_slug' => $primaryCategory->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, 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, ], $artwork, request()->user()); } /** * Build the category slug filter set for a gallery page. * Includes the current category and all descendant subcategories. * * @return array */ private function categoryFilterSlugs(Category $category): array { $slugs = []; $pendingParentIds = [$category->id]; if (! empty($category->slug)) { $slugs[] = Str::lower($category->slug); } while ($pendingParentIds !== []) { $children = Category::query() ->whereIn('parent_id', $pendingParentIds) ->get(['id', 'slug']); $pendingParentIds = $children->pluck('id')->all(); foreach ($children as $child) { if (! empty($child->slug)) { $slugs[] = Str::lower($child->slug); } } } return array_values(array_unique($slugs)); } private function loadCategoryLineage(Category $category): void { $current = $category; while ($current !== null) { $current->loadMissing(['contentType', 'parent']); $current = $current->parent; } } private function buildCategoryLinkItems(Collection $categories, string $contentSlug, ?string $basePath = null): Collection { $normalizedBasePath = trim(strtolower((string) $basePath), '/'); return $categories->map(function (Category $category) use ($contentSlug, $normalizedBasePath) { return (object) [ 'id' => $category->id, 'name' => $category->name, 'slug' => $category->slug, 'url' => $this->buildCategoryUrl($contentSlug, implode('/', array_filter([$normalizedBasePath, $category->slug]))), ]; }); } private function buildCategoryUrl(string $contentSlug, ?string $path = null): string { $normalizedPath = trim(strtolower((string) $path), '/'); return '/' . implode('/', array_filter([$contentSlug, $normalizedPath])); } private function loadGalleryArtworkRelations(Collection $artworks): void { if ($artworks->isEmpty()) { return; } $artworks->loadMissing([ 'user.profile', 'group', 'categories.contentType', ]); } private function categoryFilterClause(string $categorySlug): string { $quoted = addslashes($categorySlug); return '(category = "' . $quoted . '" OR categories = "' . $quoted . '")'; } private function categoryPageFilterExpression(string $contentTypeSlug, array $categorySlugs): string { $categoryFilter = collect($categorySlugs) ->map(fn (string $slug) => $this->categoryFilterClause($slug)) ->implode(' OR '); return 'is_public = true AND is_approved = true AND ' . $this->contentTypeFilterClause($contentTypeSlug) . ' AND (' . $categoryFilter . ')'; } private function contentTypeFilterClause(string $contentTypeSlug): string { $quoted = addslashes($contentTypeSlug); return '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")'; } private function resolvePerPage(Request $request): int { $limit = (int) $request->query('limit', 0); $perPage = (int) $request->query('per_page', 0); // Spec §8: recommended 24 per page on category/gallery pages $value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 24); return max(12, min($value, 80)); } /** * Validate and return the requested sort alias, falling back to $default. * Only allows keys present in SORT_MAP. */ private function resolveSort(Request $request, string $default = 'trending'): string { $requested = (string) $request->query('sort', $default); return array_key_exists($requested, self::SORT_MAP) ? $requested : $default; } private function mainCategories(): Collection { return $this->contentTypeResolver ->toolbarContentTypes() ->map(function (ContentType $type) { return (object) [ 'id' => $type->id, 'name' => $type->name, 'slug' => $type->slug, 'url' => '/' . strtolower($type->slug), ]; }); } private function redirectToContentTypePath(Request $request, string $contentTypeSlug, ?string $path = null, int $status = 301): RedirectResponse { $target = url('/' . trim($contentTypeSlug . '/' . trim((string) $path, '/'), '/')); $queryString = $request->getQueryString(); if ($queryString) { $target .= '?' . $queryString; } return redirect()->to($target, $status); } private function buildPaginationSeo(Request $request, string $canonicalBaseUrl, mixed $paginator): array { $canonicalQuery = $request->query(); unset($canonicalQuery['grid']); if (($canonicalQuery['page'] ?? null) !== null && (int) $canonicalQuery['page'] <= 1) { unset($canonicalQuery['page']); } $canonical = $canonicalBaseUrl; if ($canonicalQuery !== []) { $canonical .= '?' . http_build_query($canonicalQuery); } $prev = null; $next = null; if ($paginator instanceof AbstractPaginator || $paginator instanceof AbstractCursorPaginator) { $prev = $this->stripQueryParamFromUrl($paginator->previousPageUrl(), 'grid'); $next = $this->stripQueryParamFromUrl($paginator->nextPageUrl(), 'grid'); } return [ 'canonical' => $canonical, 'prev' => $prev, 'next' => $next, ]; } private function stripQueryParamFromUrl(?string $url, string $queryParam): ?string { if ($url === null || $url === '') { return null; } $parts = parse_url($url); if (!is_array($parts)) { return $url; } $query = []; if (!empty($parts['query'])) { parse_str($parts['query'], $query); unset($query[$queryParam]); } $rebuilt = ''; if (isset($parts['scheme'])) { $rebuilt .= $parts['scheme'] . '://'; } if (isset($parts['user'])) { $rebuilt .= $parts['user']; if (isset($parts['pass'])) { $rebuilt .= ':' . $parts['pass']; } $rebuilt .= '@'; } if (isset($parts['host'])) { $rebuilt .= $parts['host']; } if (isset($parts['port'])) { $rebuilt .= ':' . $parts['port']; } $rebuilt .= $parts['path'] ?? ''; if ($query !== []) { $rebuilt .= '?' . http_build_query($query); } if (isset($parts['fragment'])) { $rebuilt .= '#' . $parts['fragment']; } return $rebuilt; } }