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
This commit is contained in:
2026-02-27 10:48:35 +01:00
parent f0cca76eb3
commit 4f9b43bbba
13 changed files with 771 additions and 47 deletions

View File

@@ -54,26 +54,32 @@ final class HomepageService
* Personalized homepage data for an authenticated user.
*
* Sections:
* 1. from_following artworks from creators you follow
* 2. trending same trending feed as guests
* 3. by_tags artworks matching user's top tags
* 4. by_categories fresh uploads in user's favourite categories
* 5. tags / creators / news shared with guest homepage
* 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 [
'hero' => $this->getHeroArtwork(),
'from_following' => $this->getFollowingFeed($user, $prefs),
'trending' => $this->getTrending(),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
'preferences' => [
'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'] ?? [],
],
@@ -114,7 +120,7 @@ final class HomepageService
* 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 = 12): array
public function getTrending(int $limit = 10): array
{
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
@@ -166,7 +172,7 @@ final class HomepageService
/**
* Fresh uploads: latest 12 approved public artworks.
*/
public function getFreshUploads(int $limit = 12): array
public function getFreshUploads(int $limit = 10): array
{
return Cache::remember("homepage.fresh.{$limit}", self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public()
@@ -315,6 +321,95 @@ final class HomepageService
// 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).
*/
@@ -335,7 +430,7 @@ final class HomepageService
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->limit(12)
->limit(10)
->get();
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();