feat: ship creator journey v2 and profile updates
This commit is contained in:
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user