Files
SkinbaseNova/app/Services/HomepageService.php
Gregor Klevze 4f9b43bbba feat(homepage): Nova homepage layout — guest/auth split, mascot category tiles, 5-col artwork grids
- HomeController: is_logged_in now lives inside props JSON (not separate view var)
- HomepageService: allForUser() adds user_data, fresh, suggested_creators; auth
  payload includes is_logged_in:true; guest payload merged with is_logged_in:false
- getTrending/getFreshUploads/getFollowingFeed: limit 12→10 (2 clean rows of 5)
- New getUserData() — unread messages + notifications counts via DB
- New getSuggestedCreators() — top followed-count creators not yet followed, cached 5 min

React — new components:
- HomeWelcomeRow: greeting bar with avatar, unread message/notification badges, upload CTA
- HomeFromFollowing: art grid from followed creators with empty-state
- HomeTrendingForYou: personalized grid adapts heading/link to user's top tag
- HomeBecauseYouLike: secondary personalized section keyed on top tag
- HomeSuggestedCreators: 4-col creator cards with avatar, stats, View Profile link
- HomeCTA: gradient upload banner; guest sees Create Account second CTA
- HomeCategories: 5-tile static category grid with mascot images

React — updated:
- HomePage: split into GuestHomePage + AuthHomePage; routes on is_logged_in prop
- HomeHero: isLoggedIn prop; upload href gates on auth; CTA → /discover/trending
- HomeTrending: see-all → /discover/trending; grid 4→5 cols; slice to multiple of 5
- HomeFresh: see-all → /discover/fresh; grid 4→5 cols; slice to multiple of 5
- HomeFromFollowing/TrendingForYou/BecauseYouLike: 5-col grid, slice to multiple of 5
- HomeCategories: mascots per category (wallpapers/photography/skins/other), smaller tiles
2026-02-27 10:48:35 +01:00

521 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\QueryException;
/**
* HomepageService
*
* Aggregates all data sections needed for the Nova homepage.
* All results are cached for CACHE_TTL seconds.
* Controllers stay thin — only call the aggregator.
*/
final class HomepageService
{
private const CACHE_TTL = 300; // 5 minutes
public function __construct(
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly UserPreferenceService $prefs,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Public aggregator
// ─────────────────────────────────────────────────────────────────────────
/**
* Return all homepage section data as a single array ready to JSON-encode.
*/
public function all(): array
{
return [
'hero' => $this->getHeroArtwork(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
];
}
/**
* Personalized homepage data for an authenticated user.
*
* Sections:
* 1. user_data welcome row counts (messages, notifications, new followers)
* 2. from_following artworks from creators you follow
* 3. trending same trending feed as guests
* 4. by_tags artworks matching user's top tags (Trending For You)
* 5. by_categories fresh uploads in user's favourite categories
* 6. suggested_creators creators the user might want to follow
* 7. tags / creators / news shared with guest homepage
*/
public function allForUser(\App\Models\User $user): array
{
$prefs = $this->prefs->build($user);
return [
'is_logged_in' => true,
'user_data' => $this->getUserData($user),
'hero' => $this->getHeroArtwork(),
'from_following' => $this->getFollowingFeed($user, $prefs),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
'preferences' => [
'top_tags' => $prefs['top_tags'] ?? [],
'top_categories' => $prefs['top_categories'] ?? [],
],
];
}
// ─────────────────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────────────────
/**
* Hero artwork: first item from the featured list.
*/
public function getHeroArtwork(): ?array
{
return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array {
$result = $this->artworks->getFeaturedArtworks(null, 1);
/** @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;
}
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
});
}
/**
* Trending: up to 12 artworks sorted by pre-computed trending_score_7d.
*
* Uses Meilisearch sorted by the pre-computed score (updated every 30 min).
* Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable.
* Spec: no heavy joins in the hot path.
*/
public function getTrending(int $limit = 10): array
{
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true',
'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
])
->paginate($limit, 'page', 1);
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
if ($results->isEmpty()) {
return $this->getTrendingFromDb($limit);
}
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
'error' => $e->getMessage(),
]);
return $this->getTrendingFromDb($limit);
}
});
}
/**
* DB-only fallback for trending (Meilisearch unavailable).
* Uses pre-computed trending_score_7d column — no correlated subqueries.
*/
private function getTrendingFromDb(int $limit): array
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->orderByDesc('trending_score_7d')
->orderByDesc('trending_score_24h')
->limit($limit)
->get()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Fresh uploads: latest 12 approved public artworks.
*/
public function getFreshUploads(int $limit = 10): array
{
return Cache::remember("homepage.fresh.{$limit}", self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->orderByDesc('published_at')
->limit($limit)
->get();
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
});
}
/**
* Top 12 popular tags by usage_count.
*/
public function getPopularTags(int $limit = 12): array
{
return Cache::remember("homepage.tags.{$limit}", self::CACHE_TTL, function () use ($limit): array {
return Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit($limit)
->get(['id', 'name', 'slug', 'usage_count'])
->map(fn ($t) => [
'id' => $t->id,
'name' => $t->name,
'slug' => $t->slug,
'count' => (int) $t->usage_count,
])
->values()
->all();
});
}
/**
* Creator spotlight: top 6 creators by weekly uploads, awards, and engagement.
* "Weekly uploads" drives ranking per spec; ties broken by total awards then views.
*/
public function getCreatorSpotlight(int $limit = 6): array
{
return Cache::remember("homepage.creators.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$since = now()->subWeek();
$rows = DB::table('artworks')
->join('users as u', 'u.id', '=', 'artworks.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
->select(
'u.id',
'u.name',
'u.username',
'up.avatar_hash',
DB::raw('COUNT(DISTINCT artworks.id) as upload_count'),
DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'),
DB::raw('COALESCE(SUM(s.views), 0) as total_views'),
DB::raw('COUNT(DISTINCT aw.id) as total_awards')
)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at')
->whereNotNull('artworks.published_at')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash')
->orderByDesc('weekly_uploads')
->orderByDesc('total_awards')
->orderByDesc('total_views')
->limit($limit)
->get();
$userIds = $rows->pluck('id')->all();
// Pick one random artwork thumbnail per creator for the card background.
$thumbsByUser = Artwork::public()
->published()
->whereIn('user_id', $userIds)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->inRandomOrder()
->get(['id', 'user_id', 'hash', 'thumb_ext'])
->groupBy('user_id');
return $rows->map(function ($u) use ($thumbsByUser) {
$artworkForBg = $thumbsByUser->get($u->id)?->first();
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
return [
'id' => $u->id,
'name' => $u->name,
'uploads' => (int) $u->upload_count,
'weekly_uploads' => (int) $u->weekly_uploads,
'views' => (int) $u->total_views,
'awards' => (int) $u->total_awards,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 128),
'bg_thumb' => $bgThumb,
];
})->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getCreatorSpotlight DB error', [
'exception' => $e->getMessage(),
]);
return [];
}
});
}
/**
* Latest 5 news posts from the forum news category.
*/
public function getNews(int $limit = 5): array
{
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$items = DB::table('forum_threads as t')
->leftJoin('forum_categories as c', 'c.id', '=', 't.category_id')
->select('t.id', 't.title', 't.created_at', 't.slug as thread_slug')
->where(function ($q) {
$q->where('t.category_id', 2876)
->orWhereIn('c.slug', ['news', 'forum-news']);
})
->whereNull('t.deleted_at')
->orderByDesc('t.created_at')
->limit($limit)
->get();
return $items->map(fn ($row) => [
'id' => $row->id,
'title' => $row->title,
'date' => $row->created_at,
'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'),
])->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getNews DB error', [
'exception' => $e->getMessage(),
]);
return [];
}
});
}
// ─────────────────────────────────────────────────────────────────────────
// Personalized sections (auth only)
// ─────────────────────────────────────────────────────────────────────────
/**
* Welcome-row counts: unread messages, unread notifications, new followers.
* Returns quickly from DB using simple COUNTs; never throws.
*/
public function getUserData(\App\Models\User $user): array
{
try {
$unreadMessages = DB::table('conversations as c')
->join('conversation_participants as cp', 'cp.conversation_id', '=', 'c.id')
->join('messages as m', 'm.conversation_id', '=', 'c.id')
->where('cp.user_id', $user->id)
->where('m.user_id', '!=', $user->id)
->whereColumn('m.created_at', '>', 'cp.last_read_at')
->distinct('c.id')
->count('c.id');
} catch (\Throwable) {
$unreadMessages = 0;
}
try {
$unreadNotifications = DB::table('notifications')
->where('user_id', $user->id)
->whereNull('read_at')
->count();
} catch (\Throwable) {
$unreadNotifications = 0;
}
return [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 64),
'messages_unread' => (int) $unreadMessages,
'notifications_unread' => (int) $unreadNotifications,
'followers_count' => (int) ($user->statistics?->followers_count ?? 0),
];
}
/**
* Suggested creators: active public uploaders NOT already followed by the user,
* ranked by follower count. Optionally filtered to the user's top categories.
*/
public function getSuggestedCreators(\App\Models\User $user, array $prefs, int $limit = 8): array
{
return Cache::remember(
"homepage.suggested.{$user->id}",
300,
function () use ($user, $prefs, $limit): array {
try {
$followingIds = $prefs['followed_creators'] ?? [];
$query = DB::table('users as u')
->join('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->select(
'u.id',
'u.name',
'u.username',
'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.artworks_count, 0) as artworks_count'),
)
->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
->where('u.is_active', true)
->orderByDesc('followers_count')
->orderByDesc('artworks_count')
->limit($limit);
$rows = $query->get();
return $rows->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 64),
'followers_count' => (int) $u->followers_count,
'artworks_count' => (int) $u->artworks_count,
])->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getSuggestedCreators failed', ['error' => $e->getMessage()]);
return [];
}
}
);
}
/**
* Latest artworks from creators the user follows (max 12).
*/
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
{
$followingIds = $prefs['followed_creators'] ?? [];
if (empty($followingIds)) {
return [];
}
return Cache::remember(
"homepage.following.{$user->id}",
60, // short TTL personal data
function () use ($followingIds): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->limit(10)
->get();
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
}
);
}
/**
* Artworks matching the user's top tags (max 12).
* Powered by Meilisearch.
*/
public function getByTags(array $tagSlugs): array
{
if (empty($tagSlugs)) {
return [];
}
try {
$results = $this->search->discoverByTags($tagSlugs, 12);
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
return [];
}
}
/**
* Fresh artworks in the user's favourite categories (max 12).
* Powered by Meilisearch.
*/
public function getByCategories(array $categorySlugs): array
{
if (empty($categorySlugs)) {
return [];
}
try {
$results = $this->search->discoverByCategories($categorySlugs, 12);
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
return [];
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{
$thumbMd = $artwork->thumbUrl('md');
$thumbLg = $artwork->thumbUrl('lg');
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
$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, 40);
return [
'id' => $artwork->id,
'title' => $artwork->title ?? 'Untitled',
'slug' => $artwork->slug,
'author' => $authorName,
'author_id' => $authorId,
'author_username' => $authorUsername,
'author_avatar' => $authorAvatar,
'thumb' => $thumb,
'thumb_md' => $thumbMd,
'thumb_lg' => $thumbLg,
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
];
}
}