321 lines
13 KiB
PHP
321 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Web;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Artwork;
|
|
use App\Models\ContentType;
|
|
use App\Services\ArtworkSearchService;
|
|
use App\Services\EarlyGrowth\EarlyGrowth;
|
|
use App\Services\EarlyGrowth\GridFiller;
|
|
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
|
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
|
|
{
|
|
private const CONTENT_TYPE_SLUGS = ['artworks', 'wallpapers', 'skins', 'photography', 'other'];
|
|
|
|
/** 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,
|
|
) {}
|
|
|
|
// ── /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();
|
|
|
|
$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)
|
|
{
|
|
$type = strtolower($type);
|
|
if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) {
|
|
abort(404);
|
|
}
|
|
|
|
// "artworks" is the umbrella — search all types
|
|
$isAll = $type === 'artworks';
|
|
|
|
// Canonical URLs for content types are /skins, /wallpapers, /photography, /other.
|
|
if (! $isAll) {
|
|
return redirect()->to($this->canonicalTypeUrl($request, $type), 301);
|
|
}
|
|
|
|
$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();
|
|
|
|
$mainCategories = $this->mainCategories();
|
|
$contentType = null;
|
|
$subcategories = $mainCategories;
|
|
if (! $isAll) {
|
|
$contentType = ContentType::where('slug', $type)->first();
|
|
$subcategories = $contentType
|
|
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
|
|
: collect();
|
|
}
|
|
|
|
if ($isAll) {
|
|
$humanType = 'Artworks';
|
|
} else {
|
|
$humanType = $contentType?->name ?? ucfirst($type);
|
|
}
|
|
|
|
$baseUrl = url('/explore/' . $type);
|
|
$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/{$type}"],
|
|
]),
|
|
'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($type) . ', 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)
|
|
{
|
|
$type = strtolower($type);
|
|
if ($type !== 'artworks') {
|
|
$query = $request->query();
|
|
$query['sort'] = $this->normalizeSort((string) $mode);
|
|
|
|
return redirect()->to($this->canonicalTypeUrl($request, $type, $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 = ContentType::orderBy('id')
|
|
->get(['name', 'slug'])
|
|
->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 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');
|
|
}
|
|
}
|