1235 lines
52 KiB
PHP
1235 lines
52 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Artwork;
|
||
use App\Models\Leaderboard;
|
||
use App\Models\Tag;
|
||
use App\Services\ArtworkSearchService;
|
||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||
use App\Services\EarlyGrowth\GridFiller;
|
||
use App\Services\Recommendations\RecommendationFeedResolver;
|
||
use App\Services\UserPreferenceService;
|
||
use App\Support\AvatarUrl;
|
||
use App\Models\Collection as CollectionModel;
|
||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Database\QueryException;
|
||
use cPad\Plugins\News\Models\NewsArticle;
|
||
use App\Services\Maturity\ArtworkMaturityService;
|
||
|
||
/**
|
||
* HomepageService
|
||
*
|
||
* Aggregates all data sections needed for the Nova homepage.
|
||
* All results are cached for CACHE_TTL seconds.
|
||
* Controllers stay thin — only call the aggregator.
|
||
*/
|
||
final class HomepageService
|
||
{
|
||
private const CACHE_TTL = 300; // 5 minutes
|
||
private const DEFAULT_ARTWORK_RAIL_LIMIT = 10;
|
||
private const ARTWORK_SERIALIZATION_RELATIONS = [
|
||
'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',
|
||
];
|
||
|
||
public function __construct(
|
||
private readonly ArtworkService $artworks,
|
||
private readonly ArtworkSearchService $search,
|
||
private readonly ArtworkMaturityService $maturity,
|
||
private readonly UserPreferenceService $prefs,
|
||
private readonly RecommendationFeedResolver $feedResolver,
|
||
private readonly GridFiller $gridFiller,
|
||
private readonly CollectionDiscoveryService $collectionDiscovery,
|
||
private readonly CollectionService $collectionService,
|
||
private readonly CollectionSurfaceService $collectionSurfaces,
|
||
private readonly GroupDiscoveryService $groupDiscovery,
|
||
private readonly LeaderboardService $leaderboards,
|
||
) {}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Public aggregator
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return all homepage section data as a single array ready to JSON-encode.
|
||
*/
|
||
public function all(): array
|
||
{
|
||
return $this->guestPayloadCache()->remember(
|
||
$this->guestPayloadCacheKey(),
|
||
$this->guestPayloadCacheTtl(),
|
||
fn (): array => $this->buildGuestPayload(),
|
||
);
|
||
}
|
||
|
||
public function warmGuestPayloadCache(): array
|
||
{
|
||
$payload = $this->buildGuestPayload();
|
||
|
||
$this->guestPayloadCache()->put(
|
||
$this->guestPayloadCacheKey(),
|
||
$payload,
|
||
$this->guestPayloadCacheTtl(),
|
||
);
|
||
|
||
return $payload;
|
||
}
|
||
|
||
public function clearGuestPayloadCache(): void
|
||
{
|
||
$this->guestPayloadCache()->forget($this->guestPayloadCacheKey());
|
||
}
|
||
|
||
public function clearFeaturedAndMedalCaches(): void
|
||
{
|
||
$this->clearGuestPayloadCache();
|
||
|
||
foreach (['visibility-hide', 'visibility-blur', 'visibility-show'] as $segment) {
|
||
Cache::forget("homepage.hero.{$segment}");
|
||
Cache::forget("homepage.community-favorites.8.{$segment}");
|
||
Cache::forget("homepage.hall-of-fame.8.{$segment}");
|
||
}
|
||
}
|
||
|
||
public function guestPayloadCacheStoreName(): string
|
||
{
|
||
$configuredStore = (string) config('homepage.cache_store', 'homepage');
|
||
|
||
if (is_array(config('cache.stores.' . $configuredStore))) {
|
||
return $configuredStore;
|
||
}
|
||
|
||
return (string) config('cache.default', 'database');
|
||
}
|
||
|
||
private function buildGuestPayload(): array
|
||
{
|
||
return [
|
||
'hero' => $this->getHeroArtwork(),
|
||
'community_favorites' => $this->getCommunityFavorites(),
|
||
'hall_of_fame' => $this->getHallOfFame(),
|
||
'rising' => $this->getRising(),
|
||
'trending' => $this->getTrending(),
|
||
'fresh' => $this->getFreshUploads(),
|
||
'collections_featured' => $this->getFeaturedCollections(),
|
||
'collections_trending' => $this->getTrendingCollections(),
|
||
'collections_editorial' => $this->getEditorialCollections(),
|
||
'collections_community' => $this->getCommunityCollections(),
|
||
'groups' => $this->getHomepageGroups(),
|
||
'tags' => $this->getPopularTags(),
|
||
'creators' => $this->getCreatorSpotlight(),
|
||
'news' => $this->getNews(),
|
||
];
|
||
}
|
||
|
||
private function guestPayloadCache(): CacheRepository
|
||
{
|
||
return Cache::store($this->guestPayloadCacheStoreName());
|
||
}
|
||
|
||
private function guestPayloadCacheKey(): string
|
||
{
|
||
return (string) config('homepage.guest_payload_key', 'homepage.payload.guest');
|
||
}
|
||
|
||
private function guestPayloadCacheTtl(): int
|
||
{
|
||
return max(60, (int) config('homepage.guest_payload_ttl_seconds', 1800));
|
||
}
|
||
|
||
/**
|
||
* Personalized homepage data for an authenticated user.
|
||
*
|
||
* Sections:
|
||
* 1. user_data – welcome row counts (messages, notifications, new followers)
|
||
* 2. from_following – artworks from creators you follow
|
||
* 3. for_you – personalized recommendation preview
|
||
* 4. trending – same trending feed as guests
|
||
* 5. by_categories – fresh uploads in user's favourite categories
|
||
* 6. suggested_creators – creators the user might want to follow
|
||
* 7. tags / creators / news – shared with guest homepage
|
||
*/
|
||
public function allForUser(\App\Models\User $user): array
|
||
{
|
||
$prefs = $this->prefs->build($user);
|
||
|
||
return [
|
||
'is_logged_in' => true,
|
||
'user_data' => $this->getUserData($user),
|
||
'hero' => $this->getHeroArtwork(),
|
||
'community_favorites' => $this->getCommunityFavorites(),
|
||
'hall_of_fame' => $this->getHallOfFame(),
|
||
'for_you' => $this->getForYouPreview($user),
|
||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||
'rising' => $this->getRising(),
|
||
'trending' => $this->getTrending(),
|
||
'fresh' => $this->getFreshUploads(),
|
||
'collections_featured' => $this->getFeaturedCollections(),
|
||
'collections_recent' => $this->getRecentCollections(),
|
||
'collections_trending' => $this->getTrendingCollections(),
|
||
'collections_editorial' => $this->getEditorialCollections(),
|
||
'collections_community' => $this->getCommunityCollections(),
|
||
'groups' => $this->getHomepageGroups($user),
|
||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
|
||
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
|
||
'tags' => $this->getPopularTags(),
|
||
'creators' => $this->getCreatorSpotlight(),
|
||
'news' => $this->getNews(),
|
||
'preferences' => [
|
||
'top_tags' => $prefs['top_tags'] ?? [],
|
||
'top_categories' => $prefs['top_categories'] ?? [],
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* "For You" homepage preview backed by the personalized feed engine.
|
||
*/
|
||
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
|
||
{
|
||
try {
|
||
$feed = $this->feedResolver->getFeed((int) $user->id, max($limit * 3, $limit));
|
||
$algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
|
||
$discoveryEndpoint = route('api.discovery.events.store');
|
||
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
|
||
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
|
||
|
||
return $this->filterMissingThumbnailPayloadItems(collect($feed['data'] ?? []))
|
||
->take($limit)
|
||
->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
|
||
$reason = (string) ($item['reason'] ?? 'Picked for you');
|
||
|
||
return [
|
||
'id' => (int) ($item['id'] ?? 0),
|
||
'title' => (string) ($item['title'] ?? 'Untitled'),
|
||
'name' => (string) ($item['title'] ?? 'Untitled'),
|
||
'slug' => (string) ($item['slug'] ?? ''),
|
||
'author' => (string) ($item['author'] ?? 'Artist'),
|
||
'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
|
||
'author_username' => (string) ($item['username'] ?? ''),
|
||
'author_avatar' => $item['avatar_url'] ?? null,
|
||
'avatar_url' => $item['avatar_url'] ?? null,
|
||
'published_as_type' => (string) ($item['published_as_type'] ?? ''),
|
||
'publisher' => is_array($item['publisher'] ?? null) ? $item['publisher'] : null,
|
||
'thumb' => $item['thumbnail_url'] ?? null,
|
||
'thumb_url' => $item['thumbnail_url'] ?? null,
|
||
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
|
||
'category_name' => (string) ($item['category_name'] ?? ''),
|
||
'category_slug' => (string) ($item['category_slug'] ?? ''),
|
||
'content_type_name' => (string) ($item['content_type_name'] ?? ''),
|
||
'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
|
||
'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))),
|
||
'width' => isset($item['width']) ? (int) $item['width'] : null,
|
||
'height' => isset($item['height']) ? (int) $item['height'] : null,
|
||
'published_at' => $item['published_at'] ?? null,
|
||
'primary_tag' => $item['primary_tag'] ?? null,
|
||
'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
|
||
'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
|
||
'recommendation_reason' => $reason,
|
||
'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
|
||
'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion),
|
||
'recommendation_surface' => 'homepage-for-you',
|
||
'discovery_endpoint' => $discoveryEndpoint,
|
||
'hide_artwork_endpoint' => $hideArtworkEndpoint,
|
||
'dislike_tag_endpoint' => $dislikeTagEndpoint,
|
||
'metric_badge' => [
|
||
'label' => $reason,
|
||
'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
|
||
],
|
||
];
|
||
})->values()->all();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
public function getTrendingCollections(int $limit = 6): array
|
||
{
|
||
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.trending_collections', $limit);
|
||
|
||
if ($surfaceCollections->isNotEmpty()) {
|
||
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
|
||
}
|
||
|
||
return $this->collectionService->mapCollectionCardPayloads(
|
||
$this->collectionDiscovery->publicTrendingCollections($limit),
|
||
false
|
||
);
|
||
}
|
||
|
||
public function getEditorialCollections(int $limit = 6): array
|
||
{
|
||
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.editorial_collections', $limit);
|
||
|
||
if ($surfaceCollections->isNotEmpty()) {
|
||
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
|
||
}
|
||
|
||
return $this->collectionService->mapCollectionCardPayloads(
|
||
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_EDITORIAL, $limit),
|
||
false
|
||
);
|
||
}
|
||
|
||
public function getCommunityCollections(int $limit = 6): array
|
||
{
|
||
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.community_collections', $limit);
|
||
|
||
if ($surfaceCollections->isNotEmpty()) {
|
||
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
|
||
}
|
||
|
||
return $this->collectionService->mapCollectionCardPayloads(
|
||
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_COMMUNITY, $limit),
|
||
false
|
||
);
|
||
}
|
||
|
||
public function getFeaturedCollections(int $limit = 6): array
|
||
{
|
||
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.featured_collections', $limit);
|
||
|
||
if ($surfaceCollections->isNotEmpty()) {
|
||
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
|
||
}
|
||
|
||
return $this->collectionService->mapCollectionCardPayloads(
|
||
$this->collectionDiscovery->publicFeaturedCollections($limit),
|
||
false,
|
||
);
|
||
}
|
||
|
||
public function getRecentCollections(int $limit = 6): array
|
||
{
|
||
return $this->collectionService->mapCollectionCardPayloads(
|
||
$this->collectionDiscovery->publicRecentCollections($limit),
|
||
false,
|
||
);
|
||
}
|
||
|
||
public function getHomepageGroups(?\App\Models\User $viewer = null): array
|
||
{
|
||
if (! $viewer) {
|
||
return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
|
||
}
|
||
|
||
return $this->buildHomepageGroups($viewer);
|
||
}
|
||
|
||
private function buildHomepageGroups(?\App\Models\User $viewer = null): array
|
||
{
|
||
$featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
|
||
$spotlight = $featured[0] ?? null;
|
||
|
||
return [
|
||
'spotlight' => $spotlight,
|
||
'featured' => $featured,
|
||
'recruiting' => $this->groupDiscovery->surfaceCards($viewer, 'recruiting', 4),
|
||
'rising' => $this->groupDiscovery->surfaceCards($viewer, 'new_rising', 4),
|
||
'leaderboard' => $this->leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, Leaderboard::PERIOD_MONTHLY, 5),
|
||
'count' => $this->groupDiscovery->publicGroupCount(),
|
||
];
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Sections
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Hero artwork: first item from the featured list.
|
||
*/
|
||
public function getHeroArtwork(): ?array
|
||
{
|
||
return Cache::remember('homepage.hero.' . $this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
|
||
$artwork = $this->artworks->getFeaturedArtworkWinner();
|
||
|
||
if (! $artwork instanceof Artwork) {
|
||
$artwork = Artwork::query()
|
||
->public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||
->latest('published_at')
|
||
->first();
|
||
}
|
||
|
||
if ($artwork instanceof Artwork) {
|
||
$artwork->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
|
||
}
|
||
|
||
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
|
||
});
|
||
}
|
||
|
||
public function getCommunityFavorites(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
|
||
{
|
||
return Cache::remember("homepage.community-favorites.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
|
||
try {
|
||
$artworks = Artwork::query()
|
||
->public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
|
||
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
|
||
]))
|
||
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
|
||
->select('artworks.*')
|
||
->whereRaw('COALESCE(aas.score_30d, 0) > 0')
|
||
->orderByRaw('COALESCE(aas.score_30d, 0) DESC')
|
||
->orderByDesc('artworks.published_at')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
return $this->fillArtworkRailFromArchive($artworks, $limit)
|
||
->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'community_favorites'))
|
||
->values()
|
||
->all();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('HomepageService::getCommunityFavorites failed', ['error' => $e->getMessage()]);
|
||
|
||
return [];
|
||
}
|
||
});
|
||
}
|
||
|
||
public function getHallOfFame(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
|
||
{
|
||
return Cache::remember("homepage.hall-of-fame.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
|
||
try {
|
||
$artworks = Artwork::query()
|
||
->public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
|
||
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
|
||
]))
|
||
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
|
||
->select('artworks.*')
|
||
->whereRaw('COALESCE(aas.score_total, 0) > 0')
|
||
->orderByRaw('COALESCE(aas.score_total, 0) DESC')
|
||
->orderByRaw('COALESCE(aas.last_medaled_at, artworks.published_at) DESC')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
return $this->fillArtworkRailFromArchive($artworks, $limit)
|
||
->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'hall_of_fame'))
|
||
->values()
|
||
->all();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('HomepageService::getHallOfFame failed', ['error' => $e->getMessage()]);
|
||
|
||
return [];
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
|
||
*
|
||
* Surfaces artworks with the fastest recent engagement growth.
|
||
* Falls back to DB ORDER BY heat_score if Meilisearch is unavailable.
|
||
*/
|
||
public function getRising(int $limit = 10): array
|
||
{
|
||
$cutoff = now()->subDays(30)->toDateString();
|
||
|
||
return Cache::remember("homepage.rising.{$limit}.{$this->viewerCacheSegment()}", 120, function () use ($limit, $cutoff): array {
|
||
try {
|
||
$results = $this->search->searchWithThumbnailPreference([
|
||
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
||
], $limit, true, 1);
|
||
|
||
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
|
||
|
||
if ($items->isEmpty()) {
|
||
return $this->getRisingFromDb($limit);
|
||
}
|
||
|
||
if ($this->collectionHasNoRisingMomentum($this->searchResultCollection($results))) {
|
||
return $this->getRisingLowSignalFromDb($limit);
|
||
}
|
||
|
||
return $this->fillArtworkRailFromArchive($items, $limit)
|
||
->map(fn ($a) => $this->serializeArtwork($a))
|
||
->values()
|
||
->all();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('HomepageService::getRising Meilisearch unavailable, DB fallback', [
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
return $this->getRisingFromDb($limit);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* DB-only fallback for rising (Meilisearch unavailable).
|
||
*/
|
||
private function getRisingFromDb(int $limit): array
|
||
{
|
||
$artworks = Artwork::public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||
->select('artworks.*')
|
||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||
->orderByDesc('artwork_stats.heat_score')
|
||
->orderByDesc('artwork_stats.engagement_velocity')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
return $this->fillArtworkRailFromArchive($artworks, $limit)
|
||
->map(fn ($a) => $this->serializeArtwork($a))
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function getRisingLowSignalFromDb(int $limit): array
|
||
{
|
||
$artworks = Artwork::public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
|
||
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
|
||
})
|
||
->select('artworks.*')
|
||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||
->orderByRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) DESC')
|
||
->orderByDesc('artworks.published_at')
|
||
->orderByDesc('artworks.id')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
return $this->fillArtworkRailFromArchive($artworks, $limit)
|
||
->map(fn ($a) => $this->serializeArtwork($a))
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
|
||
*
|
||
* Uses Meilisearch sorted by the V2 score (updated every 30 min).
|
||
* Falls back to DB ORDER BY ranking_score if Meilisearch is unavailable.
|
||
* Spec §6: ranking_score, last 30 days, highlight high-velocity artworks.
|
||
*/
|
||
public function getTrending(int $limit = 10): array
|
||
{
|
||
$cutoff = now()->subDays(30)->toDateString();
|
||
|
||
return Cache::remember("homepage.trending.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
|
||
try {
|
||
$results = $this->search->searchWithThumbnailPreference([
|
||
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
||
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
|
||
], $limit, true, 1);
|
||
|
||
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
|
||
|
||
if ($items->isEmpty()) {
|
||
return $this->getTrendingFromDb($limit);
|
||
}
|
||
|
||
return $this->fillArtworkRailFromArchive($items, $limit)
|
||
->map(fn ($a) => $this->serializeArtwork($a))
|
||
->values()
|
||
->all();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
return $this->getTrendingFromDb($limit);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* DB-only fallback for trending (Meilisearch unavailable).
|
||
* Joins artwork_stats to sort by V2 ranking_score.
|
||
*/
|
||
private function getTrendingFromDb(int $limit): array
|
||
{
|
||
$artworks = Artwork::public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||
->select('artworks.*')
|
||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||
->orderByDesc('artwork_stats.ranking_score')
|
||
->orderByDesc('artwork_stats.engagement_velocity')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
return $this->fillArtworkRailFromArchive($artworks, $limit)
|
||
->map(fn ($a) => $this->serializeArtwork($a))
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* Fresh uploads: latest 10 approved public artworks.
|
||
* EGS: GridFiller ensures the section is never empty even on low-traffic days.
|
||
*/
|
||
public function getFreshUploads(int $limit = 10): array
|
||
{
|
||
// Include EGS mode in cache key so toggling EGS updates the section within TTL
|
||
$egsKey = EarlyGrowth::gridFillerEnabled() ? 'egs-' . EarlyGrowth::mode() : 'std';
|
||
$cacheKey = "homepage.fresh.{$limit}.{$egsKey}.{$this->viewerCacheSegment()}";
|
||
|
||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
|
||
$artworks = Artwork::public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||
->orderByDesc('published_at')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
// EGS: fill up to $limit when fresh uploads are sparse
|
||
$artworks = $this->gridFiller->fillCollection($artworks, $limit);
|
||
|
||
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Top 12 popular tags by usage_count.
|
||
*/
|
||
public function getPopularTags(int $limit = 12): array
|
||
{
|
||
return Cache::remember("homepage.tags.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||
return Tag::query()
|
||
->where('is_active', true)
|
||
->orderByDesc('usage_count')
|
||
->limit($limit)
|
||
->get(['id', 'name', 'slug', 'usage_count'])
|
||
->map(fn ($t) => [
|
||
'id' => $t->id,
|
||
'name' => $t->name,
|
||
'slug' => $t->slug,
|
||
'count' => (int) $t->usage_count,
|
||
])
|
||
->values()
|
||
->all();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Creator spotlight: top 6 creators by weekly uploads, awards, and engagement.
|
||
* "Weekly uploads" drives ranking per spec; ties broken by total awards then views.
|
||
*/
|
||
public function getCreatorSpotlight(int $limit = 6): array
|
||
{
|
||
return Cache::remember("homepage.creators.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||
try {
|
||
$since = now()->subWeek();
|
||
|
||
$weeklyUploads = Artwork::query()
|
||
->selectRaw('user_id, COUNT(*) as weekly_uploads')
|
||
->where('is_public', true)
|
||
->where('is_approved', true)
|
||
->whereNull('deleted_at')
|
||
->whereNotNull('published_at')
|
||
->where('published_at', '<=', now())
|
||
->where('published_at', '>=', $since)
|
||
->groupBy('user_id');
|
||
|
||
$rows = DB::table('users as u')
|
||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||
->leftJoinSub($weeklyUploads, 'weekly_uploads', function ($join): void {
|
||
$join->on('weekly_uploads.user_id', '=', 'u.id');
|
||
})
|
||
->select(
|
||
'u.id',
|
||
'u.name',
|
||
'u.username',
|
||
'up.avatar_hash',
|
||
DB::raw('COALESCE(us.uploads_count, 0) as upload_count'),
|
||
DB::raw('COALESCE(weekly_uploads.weekly_uploads, 0) as weekly_uploads'),
|
||
DB::raw('COALESCE(us.artwork_views_received_count, 0) as total_views'),
|
||
DB::raw('COALESCE(us.awards_received_count, 0) as total_awards')
|
||
)
|
||
->whereNull('u.deleted_at')
|
||
->where('u.is_active', true)
|
||
->where(function ($query): void {
|
||
$query->where('us.uploads_count', '>', 0)
|
||
->orWhere('weekly_uploads.weekly_uploads', '>', 0);
|
||
})
|
||
->orderByDesc('weekly_uploads')
|
||
->orderByDesc('total_awards')
|
||
->orderByDesc('total_views')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
$userIds = $rows->pluck('id')->all();
|
||
|
||
$latestArtworkIds = Artwork::public()
|
||
->published()
|
||
->withoutMissingThumbnails()
|
||
->whereIn('user_id', $userIds)
|
||
->whereNotNull('hash')
|
||
->whereNotNull('thumb_ext')
|
||
->selectRaw('MAX(id) as id')
|
||
->groupBy('user_id')
|
||
->pluck('id')
|
||
->all();
|
||
|
||
$thumbsByUser = Artwork::query()
|
||
->whereIn('id', $latestArtworkIds)
|
||
->get(['id', 'user_id', 'hash', 'thumb_ext'])
|
||
->keyBy('user_id');
|
||
|
||
return $rows->map(function ($u) use ($thumbsByUser) {
|
||
$artworkForBg = $thumbsByUser->get($u->id);
|
||
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
|
||
|
||
return [
|
||
'id' => $u->id,
|
||
'name' => $u->name,
|
||
'uploads' => (int) $u->upload_count,
|
||
'weekly_uploads' => (int) $u->weekly_uploads,
|
||
'views' => (int) $u->total_views,
|
||
'awards' => (int) $u->total_awards,
|
||
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
|
||
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 128),
|
||
'bg_thumb' => $bgThumb,
|
||
];
|
||
})->values()->all();
|
||
} catch (QueryException $e) {
|
||
Log::warning('HomepageService::getCreatorSpotlight DB error', [
|
||
'exception' => $e->getMessage(),
|
||
]);
|
||
|
||
return [];
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Latest 5 news posts from the forum news category.
|
||
*/
|
||
public function getNews(int $limit = 5): array
|
||
{
|
||
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||
try {
|
||
$articles = NewsArticle::query()
|
||
->with('category')
|
||
->published()
|
||
->editorialOrder()
|
||
->limit($limit)
|
||
->get();
|
||
|
||
if ($articles->isNotEmpty()) {
|
||
return $articles->map(fn (NewsArticle $article) => [
|
||
'id' => $article->id,
|
||
'title' => $article->title,
|
||
'date' => $article->published_at,
|
||
'url' => route('news.show', ['slug' => $article->slug]),
|
||
'eyebrow' => $article->category?->name ?: $article->type_label,
|
||
'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 120),
|
||
])->values()->all();
|
||
}
|
||
|
||
$items = DB::table('forum_threads as t')
|
||
->leftJoin('forum_categories as c', 'c.id', '=', 't.category_id')
|
||
->select('t.id', 't.title', 't.created_at', 't.slug as thread_slug')
|
||
->where(function ($q) {
|
||
$q->where('t.category_id', 2876)
|
||
->orWhereIn('c.slug', ['news', 'forum-news']);
|
||
})
|
||
->whereNull('t.deleted_at')
|
||
->orderByDesc('t.created_at')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
return $items->map(fn ($row) => [
|
||
'id' => $row->id,
|
||
'title' => $row->title,
|
||
'date' => $row->created_at,
|
||
'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'),
|
||
])->values()->all();
|
||
} catch (QueryException $e) {
|
||
Log::warning('HomepageService::getNews DB error', [
|
||
'exception' => $e->getMessage(),
|
||
]);
|
||
|
||
return [];
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Personalized sections (auth only)
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Welcome-row counts: unread messages, unread notifications, new followers.
|
||
* Returns quickly from DB using simple COUNTs; never throws.
|
||
*/
|
||
public function getUserData(\App\Models\User $user): array
|
||
{
|
||
try {
|
||
$unreadMessages = DB::table('conversations as c')
|
||
->join('conversation_participants as cp', 'cp.conversation_id', '=', 'c.id')
|
||
->join('messages as m', 'm.conversation_id', '=', 'c.id')
|
||
->where('cp.user_id', $user->id)
|
||
->where('m.user_id', '!=', $user->id)
|
||
->whereColumn('m.created_at', '>', 'cp.last_read_at')
|
||
->distinct('c.id')
|
||
->count('c.id');
|
||
} catch (\Throwable) {
|
||
$unreadMessages = 0;
|
||
}
|
||
|
||
try {
|
||
$unreadNotifications = DB::table('notifications')
|
||
->where('user_id', $user->id)
|
||
->whereNull('read_at')
|
||
->count();
|
||
} catch (\Throwable) {
|
||
$unreadNotifications = 0;
|
||
}
|
||
|
||
return [
|
||
'id' => $user->id,
|
||
'name' => $user->name,
|
||
'username' => $user->username,
|
||
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 64),
|
||
'messages_unread' => (int) $unreadMessages,
|
||
'notifications_unread' => (int) $unreadNotifications,
|
||
'followers_count' => (int) ($user->statistics?->followers_count ?? 0),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Suggested creators: active public uploaders NOT already followed by the user,
|
||
* ranked by follower count. Optionally filtered to the user's top categories.
|
||
*/
|
||
public function getSuggestedCreators(\App\Models\User $user, array $prefs, int $limit = 8): array
|
||
{
|
||
return Cache::remember(
|
||
"homepage.suggested.{$user->id}",
|
||
300,
|
||
function () use ($user, $prefs, $limit): array {
|
||
try {
|
||
$followingIds = $prefs['followed_creators'] ?? [];
|
||
|
||
$query = DB::table('users as u')
|
||
->join('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||
->select(
|
||
'u.id',
|
||
'u.name',
|
||
'u.username',
|
||
'up.avatar_hash',
|
||
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
|
||
DB::raw('COALESCE(us.uploads_count, 0) as artworks_count'),
|
||
)
|
||
->where('u.id', '!=', $user->id)
|
||
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
|
||
->where('u.is_active', true)
|
||
->orderByDesc('followers_count')
|
||
->orderByDesc('artworks_count')
|
||
->limit($limit);
|
||
|
||
$rows = $query->get();
|
||
|
||
return $rows->map(fn ($u) => [
|
||
'id' => $u->id,
|
||
'name' => $u->name,
|
||
'username' => $u->username,
|
||
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
|
||
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 64),
|
||
'followers_count' => (int) $u->followers_count,
|
||
'artworks_count' => (int) $u->artworks_count,
|
||
])->values()->all();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('HomepageService::getSuggestedCreators failed', ['error' => $e->getMessage()]);
|
||
return [];
|
||
}
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Latest artworks from creators the user follows (max 12).
|
||
*/
|
||
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
|
||
{
|
||
$followingIds = $prefs['followed_creators'] ?? [];
|
||
|
||
if (empty($followingIds)) {
|
||
return [];
|
||
}
|
||
|
||
return Cache::remember(
|
||
"homepage.following.{$user->id}.{$this->viewerCacheSegment()}",
|
||
60, // short TTL – personal data
|
||
function () use ($followingIds): array {
|
||
$artworks = Artwork::public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||
->whereIn('user_id', $followingIds)
|
||
->orderByDesc('published_at')
|
||
->limit(10)
|
||
->get();
|
||
|
||
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Fresh artworks matching the user's favourite tags (max 12).
|
||
* Powered by Meilisearch.
|
||
*/
|
||
public function getByTags(array $tagSlugs): array
|
||
{
|
||
if (empty($tagSlugs)) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
$results = $this->search->discoverByTags($tagSlugs, 12);
|
||
$items = $this->fillArtworkRailFromArchive(
|
||
$this->searchResultCollection($results),
|
||
12,
|
||
static fn ($query) => $query->whereHas('tags', function ($tagQuery) use ($tagSlugs): void {
|
||
$tagQuery->whereIn('slug', array_slice($tagSlugs, 0, 5));
|
||
}),
|
||
);
|
||
|
||
return $items
|
||
->map(fn ($a) => $this->serializeArtwork($a))
|
||
->values()
|
||
->all();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fresh artworks in the user's favourite categories (max 12).
|
||
* Powered by Meilisearch.
|
||
*/
|
||
public function getByCategories(array $categorySlugs): array
|
||
{
|
||
if (empty($categorySlugs)) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
$results = $this->search->discoverByCategories($categorySlugs, 12);
|
||
$items = $this->fillArtworkRailFromArchive(
|
||
$this->searchResultCollection($results),
|
||
12,
|
||
static fn ($query) => $query->whereHas('categories', function ($categoryQuery) use ($categorySlugs): void {
|
||
$categoryQuery->whereIn('slug', array_slice($categorySlugs, 0, 3));
|
||
}),
|
||
);
|
||
|
||
return $items
|
||
->map(fn ($a) => $this->serializeArtwork($a))
|
||
->values()
|
||
->all();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* @return Collection<int, Artwork>
|
||
*/
|
||
private function searchResultCollection(mixed $results): Collection
|
||
{
|
||
if ($results instanceof Collection) {
|
||
return $results;
|
||
}
|
||
|
||
if (is_object($results) && method_exists($results, 'getCollection')) {
|
||
$collection = $results->getCollection();
|
||
if ($collection instanceof Collection) {
|
||
return $collection;
|
||
}
|
||
}
|
||
|
||
return collect();
|
||
}
|
||
|
||
/**
|
||
* Ensure serialized artwork payloads do not trigger lazy-loading per item.
|
||
*
|
||
* @param Collection<int, Artwork> $artworks
|
||
* @return Collection<int, Artwork>
|
||
*/
|
||
private function prepareArtworksForSerialization(Collection $artworks): Collection
|
||
{
|
||
if ($artworks->isEmpty()) {
|
||
return $artworks;
|
||
}
|
||
|
||
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
|
||
|
||
return $artworks
|
||
->reject(fn ($artwork) => (bool) ($artwork->has_missing_thumbnails ?? false))
|
||
->values();
|
||
}
|
||
|
||
/**
|
||
* Backfill sparse homepage rails with recent archive artworks while preserving lead ordering.
|
||
*
|
||
* @param Collection<int, Artwork> $artworks
|
||
* @return Collection<int, Artwork>
|
||
*/
|
||
private function fillArtworkRailFromArchive(Collection $artworks, int $limit, ?callable $fallbackConstraint = null): Collection
|
||
{
|
||
$artworks = $this->prepareArtworksForSerialization($artworks)->take($limit)->values();
|
||
|
||
if ($artworks->count() >= $limit) {
|
||
return $artworks;
|
||
}
|
||
|
||
$needed = $limit - $artworks->count();
|
||
$excludeIds = $artworks
|
||
->pluck('id')
|
||
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
|
||
->map(fn ($id) => (int) $id)
|
||
->values()
|
||
->all();
|
||
|
||
$fallback = Artwork::query()
|
||
->public()
|
||
->published()
|
||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||
->withoutMissingThumbnails()
|
||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||
->when($fallbackConstraint !== null, fn ($query) => $fallbackConstraint($query))
|
||
->when(! empty($excludeIds), fn ($query) => $query->whereNotIn('artworks.id', $excludeIds))
|
||
->orderByDesc('artworks.published_at')
|
||
->orderByDesc('artworks.id')
|
||
->limit($needed)
|
||
->get();
|
||
|
||
return $artworks
|
||
->concat($fallback)
|
||
->unique('id')
|
||
->take($limit)
|
||
->values();
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, array<string, mixed>> $items
|
||
* @return Collection<int, array<string, mixed>>
|
||
*/
|
||
private function filterMissingThumbnailPayloadItems(Collection $items): Collection
|
||
{
|
||
if ($items->isEmpty()) {
|
||
return $items;
|
||
}
|
||
|
||
$ids = $items
|
||
->pluck('id')
|
||
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
|
||
->map(fn ($id) => (int) $id)
|
||
->values();
|
||
|
||
if ($ids->isEmpty()) {
|
||
return $items;
|
||
}
|
||
|
||
$missingIds = Artwork::query()
|
||
->whereIn('id', $ids)
|
||
->where('has_missing_thumbnails', true)
|
||
->pluck('id')
|
||
->map(fn ($id) => (int) $id)
|
||
->flip();
|
||
|
||
if ($missingIds->isEmpty()) {
|
||
return $items;
|
||
}
|
||
|
||
return $items
|
||
->reject(fn (array $item) => $missingIds->has((int) ($item['id'] ?? 0)))
|
||
->values();
|
||
}
|
||
|
||
private function collectionHasNoRisingMomentum(Collection $items): bool
|
||
{
|
||
if ($items->isEmpty()) {
|
||
return true;
|
||
}
|
||
|
||
return $items->every(function ($item): bool {
|
||
$heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
|
||
$velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);
|
||
|
||
return $heat <= 0.0 && $velocity <= 0.0;
|
||
});
|
||
}
|
||
|
||
private function risingRecentActivitySubquery()
|
||
{
|
||
$since = now()->startOfHour()->subHours(24);
|
||
|
||
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
|
||
->selectRaw('rising_snapshots.artwork_id')
|
||
->selectRaw('(
|
||
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
|
||
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
|
||
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
|
||
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
|
||
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
|
||
) as recent_signal_24h')
|
||
->where('rising_snapshots.bucket_hour', '>=', $since)
|
||
->groupBy('rising_snapshots.artwork_id');
|
||
}
|
||
|
||
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
|
||
{
|
||
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
|
||
$thumbSm = $artwork->thumbUrl('sm');
|
||
$thumbMd = $artwork->thumbUrl('md');
|
||
$thumbLg = $artwork->thumbUrl('lg');
|
||
$thumbXl = $artwork->thumbUrl('xl');
|
||
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
|
||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||
|
||
$thumbSrcset = collect([
|
||
$thumbSm ? $thumbSm . ' 320w' : null,
|
||
$thumbMd ? $thumbMd . ' 640w' : null,
|
||
$thumbLg ? $thumbLg . ' 1280w' : null,
|
||
$thumbXl ? $thumbXl . ' 1920w' : null,
|
||
])->filter()->implode(', ');
|
||
|
||
$publisher = $this->mapArtworkPublisherPayload($artwork);
|
||
$isGroupPublisher = ($publisher['type'] ?? null) === 'group';
|
||
$authorId = $artwork->user_id;
|
||
$authorName = $isGroupPublisher ? ((string) ($publisher['name'] ?? 'Skinbase Group')) : ($artwork->user?->name ?? 'Artist');
|
||
$authorUsername = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
|
||
$authorAvatar = $isGroupPublisher
|
||
? ($publisher['avatar_url'] ?? null)
|
||
: AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
|
||
|
||
return $this->maturity->decoratePayload([
|
||
'id' => $artwork->id,
|
||
'title' => $artwork->title ?? 'Untitled',
|
||
'slug' => $artwork->slug,
|
||
'author' => $authorName,
|
||
'author_id' => $authorId,
|
||
'author_username' => $authorUsername,
|
||
'author_avatar' => $authorAvatar,
|
||
'published_as_type' => $artwork->publishedAsType(),
|
||
'publisher' => $publisher,
|
||
'thumb' => $thumb,
|
||
'thumb_sm' => $thumbSm,
|
||
'thumb_md' => $thumbMd,
|
||
'thumb_lg' => $thumbLg,
|
||
'thumb_xl' => $thumbXl,
|
||
'thumb_srcset' => $thumbSrcset !== '' ? $thumbSrcset : null,
|
||
'category_name' => $primaryCategory->name ?? '',
|
||
'category_slug' => $primaryCategory->slug ?? '',
|
||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
|
||
'width' => $artwork->width,
|
||
'height' => $artwork->height,
|
||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||
'medals' => [
|
||
'gold' => (int) ($awardStat?->gold_count ?? 0),
|
||
'silver' => (int) ($awardStat?->silver_count ?? 0),
|
||
'bronze' => (int) ($awardStat?->bronze_count ?? 0),
|
||
'score' => (int) ($awardStat?->score_total ?? 0),
|
||
'score_7d' => (int) ($awardStat?->score_7d ?? 0),
|
||
'score_30d' => (int) ($awardStat?->score_30d ?? 0),
|
||
],
|
||
], $artwork, request()->user());
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>|null
|
||
*/
|
||
private function mapArtworkPublisherPayload(Artwork $artwork): ?array
|
||
{
|
||
if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) {
|
||
return null;
|
||
}
|
||
|
||
$group = $artwork->relationLoaded('group') ? $artwork->group : $artwork->group()->first();
|
||
if (! $group) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'id' => (int) $group->id,
|
||
'type' => 'group',
|
||
'name' => (string) $group->name,
|
||
'slug' => (string) $group->slug,
|
||
'headline' => (string) ($group->headline ?? ''),
|
||
'avatar_url' => $group->avatarUrl(),
|
||
'profile_url' => $group->publicUrl(),
|
||
'followers_count' => (int) ($group->followers_count ?? 0),
|
||
];
|
||
}
|
||
|
||
private function serializeArtworkWithMedalBadge(Artwork $artwork, string $surface): array
|
||
{
|
||
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
|
||
$payload = $this->serializeArtwork($artwork);
|
||
$score = $surface === 'community_favorites'
|
||
? (int) ($awardStat?->score_30d ?? 0)
|
||
: (int) ($awardStat?->score_total ?? 0);
|
||
|
||
$payload['metric_badge'] = [
|
||
'label' => $surface === 'community_favorites'
|
||
? '30d medals: ' . $score
|
||
: 'All-time medals: ' . $score,
|
||
'className' => $surface === 'community_favorites'
|
||
? 'bg-amber-500/14 text-amber-100 ring-amber-300/30'
|
||
: 'bg-cyan-500/14 text-cyan-100 ring-cyan-300/30',
|
||
];
|
||
|
||
return $payload;
|
||
}
|
||
|
||
private function viewerCacheSegment(): string
|
||
{
|
||
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
|
||
}
|
||
}
|