feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -14,6 +14,7 @@ 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;
@@ -21,6 +22,7 @@ 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
@@ -32,9 +34,11 @@ use cPad\Plugins\News\Models\NewsArticle;
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',
];
@@ -42,6 +46,7 @@ final class HomepageService
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,
@@ -60,9 +65,60 @@ final class HomepageService
* 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(),
@@ -77,6 +133,21 @@ final class HomepageService
];
}
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.
*
@@ -97,6 +168,8 @@ final class HomepageService
'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(),
@@ -127,52 +200,56 @@ final class HomepageService
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{
try {
$feed = $this->feedResolver->getFeed((int) $user->id, $limit);
$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 collect($feed['data'] ?? [])->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
$reason = (string) ($item['reason'] ?? 'Picked for you');
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,
'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();
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 [];
@@ -276,18 +353,18 @@ final class HomepageService
*/
public function getHeroArtwork(): ?array
{
return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array {
$result = $this->artworks->getFeaturedArtworks(null, 1);
return Cache::remember('homepage.hero.' . $this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
$artwork = $this->artworks->getFeaturedArtworkWinner();
/** @var \Illuminate\Database\Eloquent\Model|\null $artwork */
if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) {
$artwork = $result->getCollection()->first();
} elseif ($result instanceof \Illuminate\Support\Collection) {
$artwork = $result->first();
} elseif (is_array($result)) {
$artwork = $result[0] ?? null;
} else {
$artwork = null;
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) {
@@ -298,6 +375,70 @@ final class HomepageService
});
}
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).
*
@@ -308,14 +449,12 @@ final class HomepageService
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.rising.{$limit}", 120, function () use ($limit, $cutoff): array {
return Cache::remember("homepage.rising.{$limit}.{$this->viewerCacheSegment()}", 120, function () use ($limit, $cutoff): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
])
->paginate($limit, 'page', 1);
$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));
@@ -327,7 +466,7 @@ final class HomepageService
return $this->getRisingLowSignalFromDb($limit);
}
return $items
return $this->fillArtworkRailFromArchive($items, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -346,8 +485,10 @@ final class HomepageService
*/
private function getRisingFromDb(int $limit): array
{
return Artwork::public()
$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.*')
@@ -355,7 +496,9 @@ final class HomepageService
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get()
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -363,8 +506,10 @@ final class HomepageService
private function getRisingLowSignalFromDb(int $limit): array
{
return Artwork::public()
$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');
@@ -375,7 +520,9 @@ final class HomepageService
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit($limit)
->get()
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -392,14 +539,12 @@ final class HomepageService
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
return Cache::remember("homepage.trending.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
])
->paginate($limit, 'page', 1);
$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));
@@ -407,7 +552,7 @@ final class HomepageService
return $this->getTrendingFromDb($limit);
}
return $items
return $this->fillArtworkRailFromArchive($items, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -427,8 +572,10 @@ final class HomepageService
*/
private function getTrendingFromDb(int $limit): array
{
return Artwork::public()
$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.*')
@@ -436,7 +583,9 @@ final class HomepageService
->orderByDesc('artwork_stats.ranking_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get()
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -450,11 +599,13 @@ final class HomepageService
{
// 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}";
$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)
@@ -541,6 +692,7 @@ final class HomepageService
$latestArtworkIds = Artwork::public()
->published()
->withoutMissingThumbnails()
->whereIn('user_id', $userIds)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
@@ -698,7 +850,7 @@ final class HomepageService
'u.username',
'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.artworks_count, 0) as artworks_count'),
DB::raw('COALESCE(us.uploads_count, 0) as artworks_count'),
)
->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
@@ -738,11 +890,13 @@ final class HomepageService
}
return Cache::remember(
"homepage.following.{$user->id}",
"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')
@@ -766,7 +920,13 @@ final class HomepageService
try {
$results = $this->search->discoverByTags($tagSlugs, 12);
$items = $this->searchResultCollection($results);
$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))
@@ -790,7 +950,13 @@ final class HomepageService
try {
$results = $this->search->discoverByCategories($categorySlugs, 12);
$items = $this->searchResultCollection($results);
$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))
@@ -839,7 +1005,87 @@ final class HomepageService
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
return $artworks;
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
@@ -875,18 +1121,32 @@ final class HomepageService
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();
$authorId = $artwork->user_id;
$authorName = $artwork->user?->name ?? 'Artist';
$authorUsername = $artwork->user?->username ?? '';
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
$thumbSrcset = collect([
$thumbSm ? $thumbSm . ' 320w' : null,
$thumbMd ? $thumbMd . ' 640w' : null,
$thumbLg ? $thumbLg . ' 1280w' : null,
$thumbXl ? $thumbXl . ' 1920w' : null,
])->filter()->implode(', ');
return [
$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,
@@ -894,9 +1154,14 @@ final class HomepageService
'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 ?? '',
@@ -905,6 +1170,65 @@ final class HomepageService
'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'];
}
}