463 lines
18 KiB
PHP
463 lines
18 KiB
PHP
<?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
|
||
);
|
||
}
|
||
|
||
public function legacyCategory(Request $request, ?string $group = null, ?string $slug = null, ?string $id = null)
|
||
{
|
||
if ($id !== null && ctype_digit((string) $id)) {
|
||
$category = Category::with('contentType')->find((int) $id);
|
||
if (! $category || ! $category->contentType) {
|
||
abort(404);
|
||
}
|
||
|
||
return redirect($category->url, 301);
|
||
}
|
||
|
||
$contentSlug = strtolower((string) $group);
|
||
if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) {
|
||
abort(404);
|
||
}
|
||
|
||
$target = '/' . $contentSlug;
|
||
$normalizedSlug = trim((string) $slug, '/');
|
||
if ($normalizedSlug !== '') {
|
||
$target .= '/' . strtolower($normalizedSlug);
|
||
}
|
||
|
||
if ($request->query()) {
|
||
$target .= '?' . http_build_query($request->query());
|
||
}
|
||
|
||
return redirect($target, 301);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|