Wire homepage hero to featured thumbnail family; add featured-picture component

This commit is contained in:
2026-05-06 18:55:20 +02:00
parent 8fa3adf4df
commit 7a8bc8e22a
5 changed files with 271 additions and 142 deletions

View File

@@ -27,7 +27,7 @@ final class HomeController extends Controller
'title' => 'Skinbase Digital Art & Wallpapers',
'description' => 'Discover stunning digital art, wallpapers, and skins from a global community of creators. Browse trending works, fresh uploads, and beloved classics.',
'keywords' => 'wallpapers, digital art, skins, photography, community, wallpaper downloads',
'og_image' => $hero['thumb_lg'] ?? $hero['thumb'] ?? null,
'og_image' => $hero['featured_image']['preload_url'] ?? $hero['thumb_lg'] ?? $hero['thumb'] ?? null,
'canonical' => url('/'),
];

View File

@@ -5,26 +5,25 @@ declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection as CollectionModel;
use App\Models\Leaderboard;
use App\Models\Tag;
use App\Services\HomepageAnnouncementService;
use App\Services\ArtworkSearchService;
use App\Models\User;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserPreferenceService;
use App\Services\Worlds\WorldService;
use App\Support\ArtworkFeaturedImagePath;
use App\Support\AvatarUrl;
use App\Models\Collection as CollectionModel;
use cPad\Plugins\News\Models\NewsArticle;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Database\QueryException;
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;
use Illuminate\Support\Str;
/**
* HomepageService
@@ -36,7 +35,9 @@ use App\Services\Maturity\ArtworkMaturityService;
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',
@@ -49,6 +50,7 @@ final class HomepageService
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly ArtworkMaturityService $maturity,
private readonly ArtworkFeaturedImagePath $featuredImages,
private readonly UserPreferenceService $prefs,
private readonly RecommendationFeedResolver $feedResolver,
private readonly GridFiller $gridFiller,
@@ -118,7 +120,7 @@ final class HomepageService
{
$configuredStore = (string) config('homepage.cache_store', 'homepage');
if (is_array(config('cache.stores.' . $configuredStore))) {
if (is_array(config('cache.stores.'.$configuredStore))) {
return $configuredStore;
}
@@ -174,7 +176,7 @@ final class HomepageService
* 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
public function allForUser(User $user): array
{
$prefs = $this->prefs->build($user);
@@ -213,7 +215,7 @@ final class HomepageService
/**
* "For You" homepage preview backed by the personalized feed engine.
*/
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
public function getForYouPreview(User $user, int $limit = 12): array
{
try {
$feed = $this->feedResolver->getFeed((int) $user->id, max($limit * 3, $limit));
@@ -246,7 +248,7 @@ final class HomepageService
'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'] ?? ''))),
'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,
@@ -268,6 +270,7 @@ final class HomepageService
})->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
return [];
}
}
@@ -336,7 +339,7 @@ final class HomepageService
);
}
public function getHomepageGroups(?\App\Models\User $viewer = null): array
public function getHomepageGroups(?User $viewer = null): array
{
if (! $viewer) {
return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
@@ -345,7 +348,7 @@ final class HomepageService
return $this->buildHomepageGroups($viewer);
}
private function buildHomepageGroups(?\App\Models\User $viewer = null): array
private function buildHomepageGroups(?User $viewer = null): array
{
$featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
$spotlight = $featured[0] ?? null;
@@ -369,7 +372,7 @@ final class HomepageService
*/
public function getHeroArtwork(): ?array
{
return Cache::remember('homepage.hero.' . $this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
return Cache::remember('homepage.hero.'.$this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
$artwork = $this->artworks->getFeaturedArtworkWinner();
if (! $artwork instanceof Artwork) {
@@ -387,7 +390,14 @@ final class HomepageService
$artwork->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
}
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
if (! $artwork instanceof Artwork) {
return null;
}
$payload = $this->serializeArtwork($artwork, 'lg');
$payload['featured_image'] = $this->serializeFeaturedHeroImage($artwork);
return $payload;
});
}
@@ -468,7 +478,7 @@ final class HomepageService
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 . '"',
'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);
@@ -558,7 +568,7 @@ final class HomepageService
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 . '"',
'filter' => 'is_public = true AND is_approved = true AND created_at >= "'.$cutoff.'"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
], $limit, true, 1);
@@ -614,7 +624,7 @@ final class HomepageService
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';
$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 {
@@ -733,7 +743,7 @@ final class HomepageService
'weekly_uploads' => (int) $u->weekly_uploads,
'views' => (int) $u->total_views,
'awards' => (int) $u->total_awards,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'url' => $u->username ? '/@'.$u->username : '/profile/'.$u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 128),
'bg_thumb' => $bgThumb,
];
@@ -789,7 +799,7 @@ final class HomepageService
'id' => $row->id,
'title' => $row->title,
'date' => $row->created_at,
'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'),
'url' => '/forum/thread/'.$row->id.'-'.($row->thread_slug ?? 'post'),
])->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getNews DB error', [
@@ -809,7 +819,7 @@ final class HomepageService
* 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
public function getUserData(User $user): array
{
try {
$unreadMessages = DB::table('conversations as c')
@@ -848,7 +858,7 @@ final class HomepageService
* 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
public function getSuggestedCreators(User $user, array $prefs, int $limit = 8): array
{
return Cache::remember(
"homepage.suggested.{$user->id}",
@@ -881,13 +891,14 @@ final class HomepageService
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'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 [];
}
}
@@ -897,7 +908,7 @@ final class HomepageService
/**
* Latest artworks from creators the user follows (max 12).
*/
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
public function getFollowingFeed(User $user, array $prefs): array
{
$followingIds = $prefs['followed_creators'] ?? [];
@@ -950,6 +961,7 @@ final class HomepageService
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
return [];
}
}
@@ -980,6 +992,7 @@ final class HomepageService
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
return [];
}
}
@@ -1146,10 +1159,10 @@ final class HomepageService
$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,
$thumbSm ? $thumbSm.' 320w' : null,
$thumbMd ? $thumbMd.' 640w' : null,
$thumbLg ? $thumbLg.' 1280w' : null,
$thumbXl ? $thumbXl.' 1920w' : null,
])->filter()->implode(', ');
$publisher = $this->mapArtworkPublisherPayload($artwork);
@@ -1182,7 +1195,7 @@ final class HomepageService
'category_slug' => $primaryCategory->slug ?? '',
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
'url' => '/art/'.$artwork->id.'/'.($artwork->slug ?? ''),
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
@@ -1197,6 +1210,80 @@ final class HomepageService
], $artwork, request()->user());
}
/**
* @return array<string, mixed>
*/
private function serializeFeaturedHeroImage(Artwork $artwork): array
{
$variants = $this->featuredImages->variants();
$variantUrls = [];
foreach (array_keys($variants) as $variant) {
$variantUrls[$variant] = $artwork->hasFeaturedThumbnail($variant)
? $this->featuredImages->url($artwork, $variant)
: null;
}
$preloadSrcset = collect($variants)
->map(function (array $config, string $variant) use ($variantUrls): ?string {
$url = $variantUrls[$variant] ?? null;
return $url ? $url.' '.(int) $config['width'].'w' : null;
})
->filter()
->implode(', ');
$xsSources = collect(['xs', 'mobile_sm'])
->map(function (string $variant) use ($variantUrls, $variants): ?string {
$url = $variantUrls[$variant] ?? null;
return $url ? $url.' '.(int) ($variants[$variant]['width'] ?? 0).'w' : null;
})
->filter()
->implode(', ');
$mobileSources = collect(['mobile_sm', 'mobile'])
->map(function (string $variant) use ($variantUrls, $variants): ?string {
$url = $variantUrls[$variant] ?? null;
return $url ? $url.' '.(int) ($variants[$variant]['width'] ?? 0).'w' : null;
})
->filter()
->implode(', ');
$desktopSources = collect(['desktop', 'desktop_xl'])
->map(function (string $variant) use ($variantUrls, $variants): ?string {
$url = $variantUrls[$variant] ?? null;
return $url ? $url.' '.(int) ($variants[$variant]['width'] ?? 0).'w' : null;
})
->filter()
->implode(', ');
$pictureSources = array_values(array_filter([
$xsSources !== '' ? ['media' => '(max-width: 479px)', 'srcset' => $xsSources, 'sizes' => '100vw'] : null,
$mobileSources !== '' ? ['media' => '(max-width: 767px)', 'srcset' => $mobileSources, 'sizes' => '100vw'] : null,
! empty($variantUrls['tablet']) ? ['media' => '(max-width: 1279px)', 'srcset' => $variantUrls['tablet'].' '.(int) ($variants['tablet']['width'] ?? 0).'w', 'sizes' => '100vw'] : null,
$desktopSources !== '' ? ['media' => '(min-width: 1280px)', 'srcset' => $desktopSources, 'sizes' => '100vw'] : null,
]));
return [
'alt' => $artwork->featuredImageAltText(),
'variants' => $variantUrls,
'sources' => $pictureSources,
'img_src' => $artwork->featuredThumbnailUrl('desktop'),
'img_srcset' => $preloadSrcset !== '' ? $preloadSrcset : ($artwork->thumb_srcset ?? null),
'img_sizes' => '100vw',
'preload_url' => $variantUrls['desktop_xl']
?? $variantUrls['desktop']
?? $artwork->thumbUrl('xl')
?? $artwork->thumbUrl('lg')
?? 'https://files.skinbase.org/default/missing_xl.webp',
'preload_srcset' => $preloadSrcset !== '' ? $preloadSrcset : ($artwork->thumb_srcset ?? null),
'preload_sizes' => '100vw',
];
}
/**
* @return array<string, mixed>|null
*/
@@ -1233,8 +1320,8 @@ final class HomepageService
$payload['metric_badge'] = [
'label' => $surface === 'community_favorites'
? '30d medals: ' . $score
: 'All-time medals: ' . $score,
? '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',
@@ -1245,6 +1332,6 @@ final class HomepageService
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
return 'visibility-'.$this->maturity->viewerPreferences(request()->user())['visibility'];
}
}

View File

@@ -0,0 +1,34 @@
@props([
'image' => [],
'fetchpriority' => 'auto',
'loading' => 'lazy',
'decoding' => 'async',
])
@php
$sources = is_array($image['sources'] ?? null) ? $image['sources'] : [];
$src = $image['img_src'] ?? 'https://files.skinbase.org/default/missing_xl.webp';
$srcset = $image['img_srcset'] ?? null;
$sizes = $image['img_sizes'] ?? '100vw';
$alt = $image['alt'] ?? 'Featured artwork';
@endphp
<picture>
@foreach ($sources as $source)
<source
media="{{ $source['media'] ?? '' }}"
srcset="{{ $source['srcset'] ?? '' }}"
sizes="{{ $source['sizes'] ?? '100vw' }}"
type="image/webp"
>
@endforeach
<img
src="{{ $src }}"
@if($srcset) srcset="{{ $srcset }}" sizes="{{ $sizes }}" @endif
alt="{{ $alt }}"
{{ $attributes }}
fetchpriority="{{ $fetchpriority }}"
loading="{{ $loading }}"
decoding="{{ $decoding }}"
>
</picture>

View File

@@ -4,7 +4,15 @@
@push('head')
{{-- Preload hero image for faster LCP --}}
@if(!empty($props['hero']['thumb']) || !empty($props['hero']['thumb_lg']))
@if(!empty($props['hero']['featured_image']['preload_url']))
<link
rel="preload"
as="image"
href="{{ $props['hero']['featured_image']['preload_url'] }}"
@if(!empty($props['hero']['featured_image']['preload_srcset'])) imagesrcset="{{ $props['hero']['featured_image']['preload_srcset'] }}" imagesizes="{{ $props['hero']['featured_image']['preload_sizes'] ?? '100vw' }}" @endif
fetchpriority="high"
>
@elseif(!empty($props['hero']['thumb']) || !empty($props['hero']['thumb_lg']))
<link
rel="preload"
as="image"

View File

@@ -1,9 +1,7 @@
@php
$heroArtwork = $artwork ?? null;
$fallbackImage = 'https://files.skinbase.org/default/missing_lg.webp';
$heroImage = $heroArtwork['thumb_lg'] ?? $heroArtwork['thumb'] ?? $fallbackImage;
$heroImageSrcset = $heroArtwork['thumb_srcset'] ?? null;
$heroImageSizes = '100vw';
$heroFeaturedImage = $heroArtwork['featured_image'] ?? null;
@endphp
@if (!$heroArtwork)
@@ -23,17 +21,19 @@
</section>
@else
<section class="group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<picture>
<img
src="{{ $heroImage }}"
@if($heroImageSrcset) srcset="{{ $heroImageSrcset }}" sizes="{{ $heroImageSizes }}" @endif
alt="{{ $heroArtwork['title'] ?? 'Featured artwork' }}"
<x-artwork.featured-picture
:image="$heroFeaturedImage ?? [
'alt' => $heroArtwork['title'] ?? 'Featured artwork',
'img_src' => $heroArtwork['thumb_lg'] ?? $heroArtwork['thumb'] ?? $fallbackImage,
'img_srcset' => $heroArtwork['thumb_srcset'] ?? null,
'img_sizes' => '100vw',
'sources' => [],
]"
class="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
fetchpriority="high"
loading="eager"
decoding="sync"
decoding="async"
/>
</picture>
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/55 to-transparent"></div>