596 lines
24 KiB
PHP
596 lines
24 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\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);
|
||
$this->loadPresentationRelations($artworks->getCollection());
|
||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||
|
||
// EGS §11: featured spotlight row on page 1 only
|
||
$spotlightItems = collect();
|
||
|
||
if ($page === 1 && EarlyGrowth::spotlightEnabled()) {
|
||
$spotlightItems = $this->spotlight->getSpotlight(6);
|
||
$this->loadPresentationRelations($spotlightItems);
|
||
$spotlightItems = $spotlightItems->map(fn ($a) => $this->presentArtwork($a));
|
||
}
|
||
|
||
$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);
|
||
$this->loadPresentationRelations($artworks->getCollection());
|
||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||
|
||
// EGS §11: featured spotlight row on page 1 only
|
||
$spotlightItems = collect();
|
||
|
||
if ($page === 1 && EarlyGrowth::spotlightEnabled()) {
|
||
$spotlightItems = $this->spotlight->getSpotlight(6);
|
||
$this->loadPresentationRelations($spotlightItems);
|
||
$spotlightItems = $spotlightItems->map(fn ($a) => $this->presentArtwork($a));
|
||
}
|
||
|
||
$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 loadPresentationRelations(mixed $artworks): void
|
||
{
|
||
if (is_object($artworks) && method_exists($artworks, 'loadMissing')) {
|
||
$artworks->loadMissing(['user.profile', 'group', 'categories.contentType']);
|
||
}
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|