Implement creator studio and upload updates
This commit is contained in:
@@ -11,6 +11,7 @@ use App\Models\ArtworkComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -113,7 +114,7 @@ final class ArtworkPageController extends Controller
|
||||
$description = Str::limit(trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 160, '…');
|
||||
|
||||
$meta = [
|
||||
'title' => sprintf('%s by %s | Skinbase', html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), html_entity_decode((string) $authorName, ENT_QUOTES | ENT_HTML5, 'UTF-8')),
|
||||
'title' => sprintf('%s by %s — Skinbase', html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), html_entity_decode((string) $authorName, ENT_QUOTES | ENT_HTML5, 'UTF-8')),
|
||||
'description' => $description !== '' ? $description : html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'canonical' => $canonical,
|
||||
'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null,
|
||||
@@ -121,6 +122,12 @@ final class ArtworkPageController extends Controller
|
||||
'og_height' => $thumbXl['height'] ?? $thumbLg['height'] ?? null,
|
||||
];
|
||||
|
||||
$seo = app(SeoFactory::class)->artwork($artwork, [
|
||||
'md' => $thumbMd,
|
||||
'lg' => $thumbLg,
|
||||
'xl' => $thumbXl,
|
||||
], $canonical)->toArray();
|
||||
|
||||
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
|
||||
$tagIds = $artwork->tags->pluck('id')->filter()->values();
|
||||
|
||||
@@ -226,6 +233,8 @@ final class ArtworkPageController extends Controller
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'meta' => $meta,
|
||||
'seo' => $seo,
|
||||
'useUnifiedSeo' => true,
|
||||
'relatedItems' => $related,
|
||||
'comments' => $comments,
|
||||
]);
|
||||
|
||||
@@ -82,14 +82,26 @@ class CategoryController extends Controller
|
||||
|
||||
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
|
||||
|
||||
$page_title = $category->name;
|
||||
$breadcrumbs = collect(array_merge([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Explore', 'url' => '/browse'],
|
||||
(object) ['name' => $category->contentType->name, 'url' => '/' . strtolower((string) $category->contentType->slug)],
|
||||
], collect($category->breadcrumbs)->map(fn ($crumb) => (object) [
|
||||
'name' => $crumb->name,
|
||||
'url' => $crumb->url,
|
||||
])->all()));
|
||||
|
||||
$page_title = sprintf('%s — %s — Skinbase', $category->name, $category->contentType->name);
|
||||
$page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase');
|
||||
$page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
|
||||
$page_canonical = url()->current();
|
||||
|
||||
return view('web.category', compact(
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords',
|
||||
'page_canonical',
|
||||
'breadcrumbs',
|
||||
'group',
|
||||
'category',
|
||||
'subcategories',
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Services\CollectionRecommendationService;
|
||||
use App\Services\CollectionSearchService;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\CollectionSurfaceService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
@@ -50,18 +51,23 @@ class CollectionDiscoveryController extends Controller
|
||||
|
||||
$results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18));
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
'Search Collections — Skinbase Nova',
|
||||
filled($filters['q'] ?? null)
|
||||
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
|
||||
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
|
||||
$request->fullUrl(),
|
||||
null,
|
||||
false,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Collection/CollectionFeaturedIndex', [
|
||||
'eyebrow' => 'Search',
|
||||
'title' => 'Search collections',
|
||||
'description' => filled($filters['q'] ?? null)
|
||||
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
|
||||
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
|
||||
'seo' => [
|
||||
'title' => 'Search Collections — Skinbase Nova',
|
||||
'description' => 'Search public collections by category, theme, quality tier, and curator context.',
|
||||
'canonical' => route('collections.search'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'seo' => $seo,
|
||||
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()),
|
||||
'communityCollections' => [],
|
||||
'editorialCollections' => [],
|
||||
@@ -197,16 +203,17 @@ class CollectionDiscoveryController extends Controller
|
||||
|
||||
abort_if(! $program || collect($landing['collections'])->isEmpty(), 404);
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
sprintf('%s — Skinbase Nova', $program['label']),
|
||||
$program['description'],
|
||||
route('collections.program.show', ['programKey' => $program['key']]),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Collection/CollectionFeaturedIndex', [
|
||||
'eyebrow' => 'Program',
|
||||
'title' => $program['label'],
|
||||
'description' => $program['description'],
|
||||
'seo' => [
|
||||
'title' => sprintf('%s — Skinbase Nova', $program['label']),
|
||||
'description' => $program['description'],
|
||||
'canonical' => route('collections.program.show', ['programKey' => $program['key']]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'seo' => $seo,
|
||||
'collections' => $this->collections->mapCollectionCardPayloads($landing['collections'], false, $request->user()),
|
||||
'communityCollections' => $this->collections->mapCollectionCardPayloads($landing['community_collections'] ?? collect(), false, $request->user()),
|
||||
'editorialCollections' => $this->collections->mapCollectionCardPayloads($landing['editorial_collections'] ?? collect(), false, $request->user()),
|
||||
@@ -231,16 +238,17 @@ class CollectionDiscoveryController extends Controller
|
||||
$seasonalCollections = null,
|
||||
$campaign = null,
|
||||
) {
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
sprintf('%s — Skinbase Nova', $title),
|
||||
$description,
|
||||
url()->current(),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Collection/CollectionFeaturedIndex', [
|
||||
'eyebrow' => $eyebrow,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'seo' => [
|
||||
'title' => sprintf('%s — Skinbase Nova', $title),
|
||||
'description' => $description,
|
||||
'canonical' => url()->current(),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'seo' => $seo,
|
||||
'collections' => $this->collections->mapCollectionCardPayloads($collections, false, $viewer),
|
||||
'communityCollections' => $this->collections->mapCollectionCardPayloads($communityCollections ?? collect(), false, $viewer),
|
||||
'editorialCollections' => $this->collections->mapCollectionCardPayloads($editorialCollections ?? collect(), false, $viewer),
|
||||
|
||||
@@ -23,6 +23,36 @@ final class FooterController extends Controller
|
||||
'page_title' => 'FAQ — Skinbase',
|
||||
'page_meta_description' => 'Frequently Asked Questions about Skinbase — the community for skins, wallpapers, and photography.',
|
||||
'page_canonical' => url('/faq'),
|
||||
'faq_schema' => [[
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'FAQPage',
|
||||
'mainEntity' => [
|
||||
[
|
||||
'@type' => 'Question',
|
||||
'name' => 'What is Skinbase?',
|
||||
'acceptedAnswer' => [
|
||||
'@type' => 'Answer',
|
||||
'text' => 'Skinbase is a community gallery for desktop customisation including skins, themes, wallpapers, icons, and more.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'@type' => 'Question',
|
||||
'name' => 'Is Skinbase free to use?',
|
||||
'acceptedAnswer' => [
|
||||
'@type' => 'Answer',
|
||||
'text' => 'Yes. Browsing and downloading are free, and registering is also free.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'@type' => 'Question',
|
||||
'name' => 'Who runs Skinbase?',
|
||||
'acceptedAnswer' => [
|
||||
'@type' => 'Answer',
|
||||
'text' => 'Skinbase is maintained by a small volunteer staff team.',
|
||||
],
|
||||
],
|
||||
],
|
||||
]],
|
||||
'hero_title' => 'Frequently Asked Questions',
|
||||
'hero_description' => 'Answers to the most common questions from our members. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HomepageService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class HomeController extends Controller
|
||||
@@ -30,6 +31,8 @@ final class HomeController extends Controller
|
||||
];
|
||||
|
||||
return view('web.home', [
|
||||
'seo' => app(SeoFactory::class)->homepage($meta)->toArray(),
|
||||
'useUnifiedSeo' => true,
|
||||
'meta' => $meta,
|
||||
'props' => $sections,
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Web;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -26,10 +27,11 @@ class LeaderboardPageController extends Controller
|
||||
'initialType' => $type,
|
||||
'initialPeriod' => $period,
|
||||
'initialData' => $leaderboards->getLeaderboard($type, $period),
|
||||
'meta' => [
|
||||
'title' => 'Top Creators & Artworks Leaderboard | Skinbase',
|
||||
'description' => 'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
],
|
||||
'seo' => app(SeoFactory::class)->leaderboardPage(
|
||||
'Top Creators & Artworks Leaderboard — Skinbase',
|
||||
'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
route('leaderboard')
|
||||
)->toArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
317
app/Http/Controllers/Web/SimilarArtworksPageController.php
Normal file
317
app/Http/Controllers/Web/SimilarArtworksPageController.php
Normal file
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Recommendations\HybridSimilarArtworksService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\Vision\VectorService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* GET /art/{id}/similar
|
||||
*
|
||||
* Renders a full gallery page of artworks similar to the given source artwork.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Qdrant visual similarity (VectorService / vision gateway)
|
||||
* 2. HybridSimilarArtworksService (precomputed tag, behavior, hybrid)
|
||||
* 3. Meilisearch tag-overlap + category fallback
|
||||
*/
|
||||
final class SimilarArtworksPageController extends Controller
|
||||
{
|
||||
private const PER_PAGE = 24;
|
||||
|
||||
/** How many candidates to request from Qdrant (> PER_PAGE to allow pagination) */
|
||||
private const QDRANT_LIMIT = 120;
|
||||
|
||||
public function __construct(
|
||||
private readonly VectorService $vectors,
|
||||
private readonly HybridSimilarArtworksService $hybridService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id)
|
||||
{
|
||||
// ── Load source artwork ────────────────────────────────────────────────
|
||||
$source = Artwork::public()
|
||||
->published()
|
||||
->with([
|
||||
'tags:id,slug',
|
||||
'categories:id,slug,name',
|
||||
'categories.contentType:id,name,slug',
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
])
|
||||
->findOrFail($id);
|
||||
|
||||
$baseUrl = url("/art/{$id}/similar");
|
||||
|
||||
// ── Normalise source artwork for the view ──────────────────────────────
|
||||
$primaryCat = $source->categories->sortBy('sort_order')->first();
|
||||
$sourceMd = ThumbnailPresenter::present($source, 'md');
|
||||
$sourceLg = ThumbnailPresenter::present($source, 'lg');
|
||||
$sourceTitle = html_entity_decode((string) ($source->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
|
||||
|
||||
$sourceCard = (object) [
|
||||
'id' => $source->id,
|
||||
'title' => $sourceTitle,
|
||||
'url' => $sourceUrl,
|
||||
'thumb_md' => $sourceMd['url'] ?? null,
|
||||
'thumb_lg' => $sourceLg['url'] ?? null,
|
||||
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
|
||||
'author_name' => $source->user?->name ?? 'Artist',
|
||||
'author_username' => $source->user?->username ?? '',
|
||||
'author_avatar' => AvatarUrl::forUser(
|
||||
(int) ($source->user_id ?? 0),
|
||||
$source->user?->profile?->avatar_hash ?? null,
|
||||
80
|
||||
),
|
||||
'category_name' => $primaryCat?->name ?? '',
|
||||
'category_slug' => $primaryCat?->slug ?? '',
|
||||
'content_type_name' => $primaryCat?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
|
||||
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
|
||||
'width' => $source->width ?? null,
|
||||
'height' => $source->height ?? null,
|
||||
];
|
||||
|
||||
return view('gallery.similar', [
|
||||
'sourceArtwork' => $sourceCard,
|
||||
'gallery_type' => 'similar',
|
||||
'gallery_nav_section' => 'artworks',
|
||||
'mainCategories' => collect(),
|
||||
'subcategories' => collect(),
|
||||
'contentType' => null,
|
||||
'category' => null,
|
||||
'spotlight' => collect(),
|
||||
'current_sort' => 'trending',
|
||||
'sort_options' => [],
|
||||
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
|
||||
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
|
||||
'page_canonical' => $baseUrl,
|
||||
'page_robots' => 'noindex,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
|
||||
(object) ['name' => 'Similar Artworks', 'url' => $baseUrl],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /art/{id}/similar-results (JSON)
|
||||
*
|
||||
* Returns paginated similar artworks asynchronously so the page shell
|
||||
* can render instantly while this slower query runs in the background.
|
||||
*/
|
||||
public function results(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$source = Artwork::public()
|
||||
->published()
|
||||
->with([
|
||||
'tags:id,slug',
|
||||
'categories:id,slug,name',
|
||||
'categories.contentType:id,name,slug',
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
])
|
||||
->findOrFail($id);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$baseUrl = url("/art/{$id}/similar");
|
||||
|
||||
[$artworks, $similaritySource] = $this->resolveSimilarArtworks($source, $page, $baseUrl);
|
||||
|
||||
$galleryItems = $artworks->getCollection()->map(fn ($art) => [
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? null,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->username ?? $art->uname ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'slug' => $art->slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
])->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $galleryItems,
|
||||
'similarity_source' => $similaritySource,
|
||||
'total' => $artworks->total(),
|
||||
'current_page' => $artworks->currentPage(),
|
||||
'last_page' => $artworks->lastPage(),
|
||||
'next_page_url' => $artworks->nextPageUrl(),
|
||||
'prev_page_url' => $artworks->previousPageUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Similarity resolution ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return array{0: LengthAwarePaginator, 1: string}
|
||||
*/
|
||||
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
|
||||
{
|
||||
// Priority 1 — Qdrant visual (vision) similarity
|
||||
if ($this->vectors->isConfigured()) {
|
||||
$qdrantItems = $this->resolveViaQdrant($source);
|
||||
if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) {
|
||||
$paginator = $this->paginateCollection(
|
||||
$qdrantItems->map(fn ($a) => $this->presentArtwork($a)),
|
||||
$page,
|
||||
$baseUrl,
|
||||
);
|
||||
return [$paginator, 'visual'];
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2 — precomputed hybrid list (tag / behavior / AI)
|
||||
$hybridItems = $this->hybridService->forArtwork($source->id, self::QDRANT_LIMIT);
|
||||
if ($hybridItems->isNotEmpty()) {
|
||||
$paginator = $this->paginateCollection(
|
||||
$hybridItems->map(fn ($a) => $this->presentArtwork($a)),
|
||||
$page,
|
||||
$baseUrl,
|
||||
);
|
||||
return [$paginator, 'hybrid'];
|
||||
}
|
||||
|
||||
// Priority 3 — Meilisearch tag/category overlap
|
||||
$paginator = $this->meilisearchFallback($source, $page);
|
||||
return [$paginator, 'tags'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Qdrant via VectorGateway, then re-hydrate full Artwork models
|
||||
* (so we have category/dimension data for the masonry grid).
|
||||
*
|
||||
* Returns null when the gateway call fails, so the caller can fall through.
|
||||
*/
|
||||
private function resolveViaQdrant(Artwork $source): ?Collection
|
||||
{
|
||||
try {
|
||||
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
|
||||
} catch (RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Preserve Qdrant relevance order; IDs are already filtered to public+published
|
||||
$orderedIds = array_column($raw, 'id');
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->whereIn('id', $orderedIds)
|
||||
->where('id', '!=', $source->id) // belt-and-braces exclusion
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'categories:id,slug,name',
|
||||
'categories.contentType:id,name,slug',
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return collect($orderedIds)
|
||||
->map(fn (int $id) => $artworks->get($id))
|
||||
->filter()
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Meilisearch tag-overlap query with category fallback.
|
||||
*/
|
||||
private function meilisearchFallback(Artwork $source, int $page): LengthAwarePaginator
|
||||
{
|
||||
$tagSlugs = $source->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $source->categories->pluck('slug')->values()->all();
|
||||
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
'id != ' . $source->id,
|
||||
];
|
||||
|
||||
if ($tagSlugs !== []) {
|
||||
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
|
||||
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
$quoted = array_map(fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs);
|
||||
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
|
||||
}
|
||||
|
||||
$results = Artwork::search('')->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||||
])->paginate(self::PER_PAGE, 'page', $page);
|
||||
|
||||
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Collection into a LengthAwarePaginator for the view.
|
||||
*/
|
||||
private function paginateCollection(
|
||||
Collection $items,
|
||||
int $page,
|
||||
string $path,
|
||||
): LengthAwarePaginator {
|
||||
$perPage = self::PER_PAGE;
|
||||
$total = $items->count();
|
||||
$slice = $items->forPage($page, $perPage)->values();
|
||||
|
||||
return new LengthAwarePaginator($slice, $total, $perPage, $page, [
|
||||
'path' => $path,
|
||||
'query' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Presenter ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function presentArtwork(Artwork $artwork): object
|
||||
{
|
||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$avatarUrl = 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' => $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' => $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user