Files
SkinbaseNova/app/Http/Controllers/Web/ExploreController.php
2026-04-18 17:02:56 +02:00

487 lines
19 KiB
PHP

<?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\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);
}
// ── Helpers ──────────────────────────────────────────────────────────
private function mainCategories(): Collection
{
$categories = $this->contentTypeResolver
->publicContentTypes()
->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 !== '') {
$filterParts[] = 'content_type = "' . addslashes($contentType) . '"';
}
$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');
}
}