Files
SkinbaseNova/app/Http/Controllers/Web/ExploreController.php

579 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\User;
use App\Services\ArtworkSearchService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\EarlyGrowth\SpotlightEngineInterface;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Pagination\AbstractCursorPaginator;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* ExploreController
*
* Powers the /explore/* structured catalog pages (§3.2 of routing spec).
* Delegates to the same Meilisearch pipeline as BrowseGalleryController but
* uses canonical /explore/* URLs with the ExploreLayout blade template.
*/
final class ExploreController extends Controller
{
/** Meilisearch sort-field arrays per sort alias. */
private const SORT_MAP = [
'trending' => ['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');
}
}