Files
SkinbaseNova/app/Http/Controllers/Web/BrowseGalleryController.php
2026-03-20 21:17:26 +01:00

434 lines
17 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
namespace App\Http\Controllers\Web;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Pagination\AbstractCursorPaginator;
class BrowseGalleryController extends \App\Http\Controllers\Controller
{
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other'];
/**
* Meilisearch sort-field arrays per sort alias.
* First element is primary sort; subsequent elements are tie-breakers.
*/
private const SORT_MAP = [
// ── Nova sort aliases ─────────────────────────────────────────────────
// trending_score_24h only covers artworks ≤ 7 days old; use 7d score
// and favorites_count as fallbacks so older artworks don't all tie at 0.
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
// "New & Hot": 30-day trending window surfaces recently-active artworks.
'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'],
'oldest' => ['created_at:asc'],
// ── Legacy aliases (backward compat) ──────────────────────────────────
'latest' => ['created_at:desc'],
'popular' => ['views:desc', 'favorites_count:desc'],
'liked' => ['likes:desc', 'favorites_count:desc'],
'downloads' => ['downloads:desc', 'downloads_count: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' => '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,
) {
}
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.{$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)
);
$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(),
'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)
{
$contentSlug = strtolower($contentTypeSlug);
if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) {
abort(404);
}
$contentType = ContentType::where('slug', $contentSlug)->first();
if (! $contentType) {
abort(404);
}
// 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()->orderBy('sort_order')->orderBy('name')->get();
$normalizedPath = trim((string) $path, '/');
if ($normalizedPath === '') {
$artworks = Cache::remember(
"gallery.ct.{$contentSlug}.{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
);
$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' => $rootCategories,
'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' => $contentType->name, 'url' => '/' . $contentSlug]]),
'page_title' => $contentType->name . ' Skinbase Nova',
'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);
}
$categorySlugs = $this->categoryFilterSlugs($category);
$categoryFilter = collect($categorySlugs)
->map(fn (string $slug) => 'category = "' . addslashes($slug) . '"')
->implode(' OR ');
$artworks = Cache::remember(
'gallery.cat.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true AND (' . $categoryFilter . ')',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
$navigationCategory = $category->parent ?: $category;
$subcategories = $navigationCategory->children()->orderBy('sort_order')->orderBy('name')->get();
if ($subcategories->isEmpty()) {
$subcategories = $rootCategories;
}
$breadcrumbs = collect($category->breadcrumbs)
->map(function (Category $crumb) {
return (object) [
'name' => $crumb->name,
'url' => $crumb->url,
];
});
return view('gallery.index', [
'gallery_type' => 'category',
'mainCategories' => $mainCategories,
'subcategories' => $subcategories,
'subcategory_parent' => $navigationCategory,
'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 Nova',
'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))) : '');
// Normalize artwork param if route-model binding returned an Artwork model
$artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork;
return app(\App\Http\Controllers\ArtworkController::class)->show(
$req,
$contentTypeSlug,
$categoryPath,
$artworkSlug
);
}
private function presentArtwork(Artwork $artwork): object
{
$primaryCategory = $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,
'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' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
}
/**
* Build the category slug filter set for a gallery page.
* Includes the current category and all descendant subcategories.
*
* @return array<int, string>
*/
private function categoryFilterSlugs(Category $category): array
{
$category->loadMissing('descendants');
$slugs = [];
$stack = [$category];
while ($stack !== []) {
/** @var Category $current */
$current = array_pop($stack);
if (! empty($current->slug)) {
$slugs[] = Str::lower($current->slug);
}
foreach ($current->children as $child) {
$child->loadMissing('descendants');
$stack[] = $child;
}
}
return array_values(array_unique($slugs));
}
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 ContentType::orderBy('id')
->get(['name', 'slug'])
->map(function (ContentType $type) {
return (object) [
'id' => $type->id,
'name' => $type->name,
'slug' => $type->slug,
'url' => '/' . strtolower($type->slug),
];
});
}
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;
}
}