Wire homepage hero to featured thumbnail family; add featured-picture component
This commit is contained in:
@@ -27,7 +27,7 @@ final class HomeController extends Controller
|
|||||||
'title' => 'Skinbase – Digital Art & Wallpapers',
|
'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.',
|
'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',
|
'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('/'),
|
'canonical' => url('/'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,26 +5,25 @@ declare(strict_types=1);
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Collection as CollectionModel;
|
||||||
use App\Models\Leaderboard;
|
use App\Models\Leaderboard;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Services\HomepageAnnouncementService;
|
use App\Models\User;
|
||||||
use App\Services\ArtworkSearchService;
|
|
||||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||||
use App\Services\EarlyGrowth\GridFiller;
|
use App\Services\EarlyGrowth\GridFiller;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||||
use App\Services\UserPreferenceService;
|
|
||||||
use App\Services\Worlds\WorldService;
|
use App\Services\Worlds\WorldService;
|
||||||
|
use App\Support\ArtworkFeaturedImagePath;
|
||||||
use App\Support\AvatarUrl;
|
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\Contracts\Cache\Repository as CacheRepository;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Support\Str;
|
||||||
use cPad\Plugins\News\Models\NewsArticle;
|
|
||||||
use App\Services\Maturity\ArtworkMaturityService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HomepageService
|
* HomepageService
|
||||||
@@ -36,7 +35,9 @@ use App\Services\Maturity\ArtworkMaturityService;
|
|||||||
final class HomepageService
|
final class HomepageService
|
||||||
{
|
{
|
||||||
private const CACHE_TTL = 300; // 5 minutes
|
private const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
private const DEFAULT_ARTWORK_RAIL_LIMIT = 10;
|
private const DEFAULT_ARTWORK_RAIL_LIMIT = 10;
|
||||||
|
|
||||||
private const ARTWORK_SERIALIZATION_RELATIONS = [
|
private const ARTWORK_SERIALIZATION_RELATIONS = [
|
||||||
'user:id,name,username',
|
'user:id,name,username',
|
||||||
'user.profile:user_id,avatar_hash',
|
'user.profile:user_id,avatar_hash',
|
||||||
@@ -49,6 +50,7 @@ final class HomepageService
|
|||||||
private readonly ArtworkService $artworks,
|
private readonly ArtworkService $artworks,
|
||||||
private readonly ArtworkSearchService $search,
|
private readonly ArtworkSearchService $search,
|
||||||
private readonly ArtworkMaturityService $maturity,
|
private readonly ArtworkMaturityService $maturity,
|
||||||
|
private readonly ArtworkFeaturedImagePath $featuredImages,
|
||||||
private readonly UserPreferenceService $prefs,
|
private readonly UserPreferenceService $prefs,
|
||||||
private readonly RecommendationFeedResolver $feedResolver,
|
private readonly RecommendationFeedResolver $feedResolver,
|
||||||
private readonly GridFiller $gridFiller,
|
private readonly GridFiller $gridFiller,
|
||||||
@@ -174,7 +176,7 @@ final class HomepageService
|
|||||||
* 6. suggested_creators – creators the user might want to follow
|
* 6. suggested_creators – creators the user might want to follow
|
||||||
* 7. tags / creators / news – shared with guest homepage
|
* 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);
|
$prefs = $this->prefs->build($user);
|
||||||
|
|
||||||
@@ -213,7 +215,7 @@ final class HomepageService
|
|||||||
/**
|
/**
|
||||||
* "For You" homepage preview backed by the personalized feed engine.
|
* "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 {
|
try {
|
||||||
$feed = $this->feedResolver->getFeed((int) $user->id, max($limit * 3, $limit));
|
$feed = $this->feedResolver->getFeed((int) $user->id, max($limit * 3, $limit));
|
||||||
@@ -268,6 +270,7 @@ final class HomepageService
|
|||||||
})->values()->all();
|
})->values()->all();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
|
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
return [];
|
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) {
|
if (! $viewer) {
|
||||||
return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
|
return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
|
||||||
@@ -345,7 +348,7 @@ final class HomepageService
|
|||||||
return $this->buildHomepageGroups($viewer);
|
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);
|
$featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
|
||||||
$spotlight = $featured[0] ?? null;
|
$spotlight = $featured[0] ?? null;
|
||||||
@@ -387,7 +390,14 @@ final class HomepageService
|
|||||||
$artwork->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
|
$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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,7 +819,7 @@ final class HomepageService
|
|||||||
* Welcome-row counts: unread messages, unread notifications, new followers.
|
* Welcome-row counts: unread messages, unread notifications, new followers.
|
||||||
* Returns quickly from DB using simple COUNTs; never throws.
|
* Returns quickly from DB using simple COUNTs; never throws.
|
||||||
*/
|
*/
|
||||||
public function getUserData(\App\Models\User $user): array
|
public function getUserData(User $user): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$unreadMessages = DB::table('conversations as c')
|
$unreadMessages = DB::table('conversations as c')
|
||||||
@@ -848,7 +858,7 @@ final class HomepageService
|
|||||||
* Suggested creators: active public uploaders NOT already followed by the user,
|
* Suggested creators: active public uploaders NOT already followed by the user,
|
||||||
* ranked by follower count. Optionally filtered to the user's top categories.
|
* 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(
|
return Cache::remember(
|
||||||
"homepage.suggested.{$user->id}",
|
"homepage.suggested.{$user->id}",
|
||||||
@@ -888,6 +898,7 @@ final class HomepageService
|
|||||||
])->values()->all();
|
])->values()->all();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('HomepageService::getSuggestedCreators failed', ['error' => $e->getMessage()]);
|
Log::warning('HomepageService::getSuggestedCreators failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -897,7 +908,7 @@ final class HomepageService
|
|||||||
/**
|
/**
|
||||||
* Latest artworks from creators the user follows (max 12).
|
* 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'] ?? [];
|
$followingIds = $prefs['followed_creators'] ?? [];
|
||||||
|
|
||||||
@@ -950,6 +961,7 @@ final class HomepageService
|
|||||||
->all();
|
->all();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
|
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -980,6 +992,7 @@ final class HomepageService
|
|||||||
->all();
|
->all();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
|
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1197,6 +1210,80 @@ final class HomepageService
|
|||||||
], $artwork, request()->user());
|
], $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
|
* @return array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -4,7 +4,15 @@
|
|||||||
|
|
||||||
@push('head')
|
@push('head')
|
||||||
{{-- Preload hero image for faster LCP --}}
|
{{-- 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
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
as="image"
|
as="image"
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
@php
|
@php
|
||||||
$heroArtwork = $artwork ?? null;
|
$heroArtwork = $artwork ?? null;
|
||||||
$fallbackImage = 'https://files.skinbase.org/default/missing_lg.webp';
|
$fallbackImage = 'https://files.skinbase.org/default/missing_lg.webp';
|
||||||
$heroImage = $heroArtwork['thumb_lg'] ?? $heroArtwork['thumb'] ?? $fallbackImage;
|
$heroFeaturedImage = $heroArtwork['featured_image'] ?? null;
|
||||||
$heroImageSrcset = $heroArtwork['thumb_srcset'] ?? null;
|
|
||||||
$heroImageSizes = '100vw';
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if (!$heroArtwork)
|
@if (!$heroArtwork)
|
||||||
@@ -23,17 +21,19 @@
|
|||||||
</section>
|
</section>
|
||||||
@else
|
@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]">
|
<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>
|
<x-artwork.featured-picture
|
||||||
<img
|
:image="$heroFeaturedImage ?? [
|
||||||
src="{{ $heroImage }}"
|
'alt' => $heroArtwork['title'] ?? 'Featured artwork',
|
||||||
@if($heroImageSrcset) srcset="{{ $heroImageSrcset }}" sizes="{{ $heroImageSizes }}" @endif
|
'img_src' => $heroArtwork['thumb_lg'] ?? $heroArtwork['thumb'] ?? $fallbackImage,
|
||||||
alt="{{ $heroArtwork['title'] ?? 'Featured artwork' }}"
|
'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]"
|
class="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
loading="eager"
|
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>
|
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/55 to-transparent"></div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user