feat: add reusable gallery carousel and ranking feed infrastructure
This commit is contained in:
144
app/Http/Controllers/Api/RankController.php
Normal file
144
app/Http/Controllers/Api/RankController.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\RankingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
/**
|
||||
* RankController
|
||||
*
|
||||
* Serves pre-computed ranked artwork lists.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/rank/global?type=trending|new_hot|best
|
||||
* GET /api/rank/category/{id}?type=trending|new_hot|best
|
||||
* GET /api/rank/type/{contentType}?type=trending|new_hot|best
|
||||
*/
|
||||
class RankController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RankingService $ranking) {}
|
||||
|
||||
/**
|
||||
* GET /api/rank/global
|
||||
*
|
||||
* Returns: { data: [...], meta: { list_type, computed_at, model_version, fallback } }
|
||||
*/
|
||||
public function global(Request $request): AnonymousResourceCollection|JsonResponse
|
||||
{
|
||||
$listType = $this->resolveListType($request);
|
||||
$result = $this->ranking->getList('global', null, $listType);
|
||||
|
||||
return $this->buildResponse($result, $listType);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/rank/category/{id}
|
||||
*/
|
||||
public function byCategory(Request $request, int $id): AnonymousResourceCollection|JsonResponse
|
||||
{
|
||||
if (! Category::where('id', $id)->where('is_active', true)->exists()) {
|
||||
return response()->json(['message' => 'Category not found.'], 404);
|
||||
}
|
||||
|
||||
$listType = $this->resolveListType($request);
|
||||
$result = $this->ranking->getList('category', $id, $listType);
|
||||
|
||||
return $this->buildResponse($result, $listType);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/rank/type/{contentType}
|
||||
*
|
||||
* {contentType} is accepted as either a slug (string) or numeric id.
|
||||
*/
|
||||
public function byContentType(Request $request, string $contentType): AnonymousResourceCollection|JsonResponse
|
||||
{
|
||||
$ct = is_numeric($contentType)
|
||||
? ContentType::find((int) $contentType)
|
||||
: ContentType::where('slug', $contentType)->first();
|
||||
|
||||
if ($ct === null) {
|
||||
return response()->json(['message' => 'Content type not found.'], 404);
|
||||
}
|
||||
|
||||
$listType = $this->resolveListType($request);
|
||||
$result = $this->ranking->getList('content_type', $ct->id, $listType);
|
||||
|
||||
return $this->buildResponse($result, $listType);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and normalise the ?type query param.
|
||||
* Defaults to 'trending'.
|
||||
*/
|
||||
private function resolveListType(Request $request): string
|
||||
{
|
||||
$allowed = ['trending', 'new_hot', 'best'];
|
||||
$type = $request->query('type', 'trending');
|
||||
|
||||
return in_array($type, $allowed, true) ? $type : 'trending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate artwork IDs into Eloquent models (no N+1) and wrap in resources.
|
||||
*
|
||||
* @param array{ids: int[], computed_at: string|null, model_version: string, fallback: bool} $result
|
||||
*/
|
||||
private function buildResponse(array $result, string $listType = 'trending'): AnonymousResourceCollection
|
||||
{
|
||||
$ids = $result['ids'];
|
||||
$artworks = collect();
|
||||
|
||||
if (! empty($ids)) {
|
||||
// Single whereIn query — no N+1
|
||||
$keyed = Artwork::whereIn('id', $ids)
|
||||
->with([
|
||||
'user:id,name',
|
||||
'categories' => function ($q): void {
|
||||
$q->select(
|
||||
'categories.id',
|
||||
'categories.content_type_id',
|
||||
'categories.parent_id',
|
||||
'categories.name',
|
||||
'categories.slug',
|
||||
'categories.sort_order'
|
||||
)->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Restore the ranked order
|
||||
$artworks = collect($ids)
|
||||
->filter(fn ($id) => $keyed->has($id))
|
||||
->map(fn ($id) => $keyed[$id]);
|
||||
}
|
||||
|
||||
$collection = ArtworkListResource::collection($artworks);
|
||||
|
||||
// Attach ranking meta as additional data
|
||||
$collection->additional([
|
||||
'meta' => [
|
||||
'list_type' => $listType,
|
||||
'computed_at' => $result['computed_at'],
|
||||
'model_version' => $result['model_version'],
|
||||
'fallback' => $result['fallback'],
|
||||
'count' => $artworks->count(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $collection;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ namespace App\Http\Controllers\Web;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
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\Pagination\AbstractPaginator;
|
||||
use Illuminate\Pagination\AbstractCursorPaginator;
|
||||
|
||||
@@ -16,11 +18,56 @@ 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 = [
|
||||
'latest' => 'created_at:desc',
|
||||
'popular' => 'views:desc',
|
||||
'liked' => 'likes:desc',
|
||||
'downloads' => 'downloads:desc',
|
||||
// ── 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(
|
||||
@@ -31,34 +78,43 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
|
||||
public function browse(Request $request)
|
||||
{
|
||||
$sort = (string) $request->query('sort', 'latest');
|
||||
$sort = $this->resolveSort($request, 'trending');
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$page = (int) $request->query('page', 1);
|
||||
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
|
||||
|
||||
$artworks = Artwork::search('')->options([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
|
||||
])->paginate($perPage);
|
||||
$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,
|
||||
'hero_title' => 'Browse Artworks',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -74,37 +130,47 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$sort = (string) $request->query('sort', 'latest');
|
||||
// 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 = 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 = 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,
|
||||
'hero_title' => $contentType->name,
|
||||
'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,
|
||||
'page_meta_description' => $contentType->description ?? ($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',
|
||||
'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',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -114,10 +180,16 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$artworks = Artwork::search('')->options([
|
||||
'filter' => 'is_public = true AND is_approved = true AND category = "' . $category->slug . '"',
|
||||
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
|
||||
])->paginate($perPage);
|
||||
$catSlug = $category->slug;
|
||||
$artworks = Cache::remember(
|
||||
"gallery.cat.{$catSlug}.{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => Artwork::search('')->options([
|
||||
'filter' => 'is_public = true AND is_approved = true AND category = "' . $catSlug . '"',
|
||||
'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);
|
||||
|
||||
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
|
||||
@@ -134,22 +206,24 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
});
|
||||
|
||||
return view('gallery.index', [
|
||||
'gallery_type' => 'category',
|
||||
'mainCategories' => $mainCategories,
|
||||
'subcategories' => $subcategories,
|
||||
'contentType' => $contentType,
|
||||
'category' => $category,
|
||||
'artworks' => $artworks,
|
||||
'hero_title' => $category->name,
|
||||
'gallery_type' => 'category',
|
||||
'mainCategories' => $mainCategories,
|
||||
'subcategories' => $subcategories,
|
||||
'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,
|
||||
'page_meta_description' => $category->description ?? ($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',
|
||||
'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',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -211,16 +285,53 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
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,
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvePerPage(Request $request): int
|
||||
{
|
||||
$limit = (int) $request->query('limit', 0);
|
||||
$perPage = (int) $request->query('per_page', 0);
|
||||
|
||||
$value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 40);
|
||||
// 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')
|
||||
|
||||
@@ -38,7 +38,7 @@ final class DiscoverController extends Controller
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverTrending($perPage);
|
||||
$artworks = $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
@@ -55,7 +55,7 @@ final class DiscoverController extends Controller
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverFresh($perPage);
|
||||
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
@@ -72,7 +72,7 @@ final class DiscoverController extends Controller
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverTopRated($perPage);
|
||||
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
@@ -89,7 +89,7 @@ final class DiscoverController extends Controller
|
||||
{
|
||||
$perPage = 24;
|
||||
$results = $this->searchService->discoverMostDownloaded($perPage);
|
||||
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
@@ -110,7 +110,11 @@ final class DiscoverController extends Controller
|
||||
$artworks = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with(['user:id,name', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
])
|
||||
->whereRaw('MONTH(published_at) = ?', [$today->month])
|
||||
->whereRaw('DAY(published_at) = ?', [$today->day])
|
||||
->whereRaw('YEAR(published_at) < ?', [$today->year])
|
||||
@@ -206,16 +210,27 @@ final class DiscoverController extends Controller
|
||||
$artworkItems = $feedResult['data'] ?? [];
|
||||
|
||||
// Build a simple presentable collection
|
||||
$artworks = collect($artworkItems)->map(fn (array $item) => (object) [
|
||||
'id' => $item['id'] ?? 0,
|
||||
'name' => $item['title'] ?? 'Untitled',
|
||||
'category_name' => '',
|
||||
'thumb_url' => $item['thumbnail_url'] ?? null,
|
||||
'thumb_srcset' => $item['thumbnail_url'] ?? null,
|
||||
'uname' => $item['author'] ?? 'Artist',
|
||||
'published_at' => null,
|
||||
'slug' => $item['slug'] ?? '',
|
||||
]);
|
||||
$artworks = collect($artworkItems)->map(function (array $item) {
|
||||
$width = isset($item['width']) && $item['width'] > 0 ? (int) $item['width'] : null;
|
||||
$height = isset($item['height']) && $item['height'] > 0 ? (int) $item['height'] : null;
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($item['author_id'] ?? 0), null, 64);
|
||||
|
||||
return (object) [
|
||||
'id' => $item['id'] ?? 0,
|
||||
'name' => $item['title'] ?? 'Untitled',
|
||||
'category_name' => $item['category_name'] ?? '',
|
||||
'category_slug' => $item['category_slug'] ?? '',
|
||||
'thumb_url' => $item['thumbnail_url'] ?? null,
|
||||
'thumb_srcset' => $item['thumbnail_url'] ?? null,
|
||||
'uname' => $item['author'] ?? 'Artist',
|
||||
'username' => $item['username'] ?? '',
|
||||
'avatar_url' => $avatarUrl,
|
||||
'published_at' => $item['published_at'] ?? null,
|
||||
'slug' => $item['slug'] ?? '',
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
});
|
||||
|
||||
$meta = $feedResult['meta'] ?? [];
|
||||
$nextCursor = $meta['next_cursor'] ?? null;
|
||||
@@ -308,10 +323,73 @@ final class DiscoverController extends Controller
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function hydrateDiscoverSearchResults($paginator): void
|
||||
{
|
||||
if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = $paginator->getCollection();
|
||||
if (!$items || $items->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = $items
|
||||
->pluck('id')
|
||||
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values();
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$byId = Artwork::query()
|
||||
->whereIn('id', $ids)
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$paginator->setCollection(
|
||||
$items->map(function ($item) use ($byId) {
|
||||
$id = (int) ($item->id ?? 0);
|
||||
$full = $id > 0 ? $byId->get($id) : null;
|
||||
|
||||
if ($full instanceof Artwork) {
|
||||
return $this->presentArtwork($full);
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'id' => $item->id ?? 0,
|
||||
'name' => $item->title ?? $item->name ?? 'Untitled',
|
||||
'category_name' => $item->category_name ?? $item->category ?? '',
|
||||
'category_slug' => $item->category_slug ?? '',
|
||||
'thumb_url' => $item->thumbnail_url ?? $item->thumb_url ?? $item->thumb ?? null,
|
||||
'thumb_srcset' => $item->thumb_srcset ?? null,
|
||||
'uname' => $item->author ?? $item->uname ?? 'Skinbase',
|
||||
'username' => $item->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($item->user_id ?? $item->author_id ?? 0), null, 64),
|
||||
'published_at' => $item->published_at ?? null,
|
||||
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
|
||||
'height' => isset($item->height) && $item->height ? (int) $item->height : null,
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -322,6 +400,7 @@ final class DiscoverController extends Controller
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'avatar_url' => $avatarUrl,
|
||||
'published_at' => $artwork->published_at,
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
|
||||
Reference in New Issue
Block a user