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

@@ -17,7 +17,7 @@ final class HomeController extends Controller
$user = $request->user();
$sections = $user
? $this->homepage->allForUser($user)
: $this->homepage->all();
: array_merge($this->homepage->all(), ['is_logged_in' => false]);
$hero = $sections['hero'];
@@ -32,7 +32,6 @@ final class HomeController extends Controller
return view('web.home', [
'meta' => $meta,
'props' => $sections,
'is_logged_in' => (bool) $user,
]);
}
}

View File

@@ -54,22 +54,28 @@ 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 [
'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(),
@@ -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();

View File

@@ -0,0 +1,74 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null
return (
<article>
<a
href={item.url}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5"
>
<div className="relative aspect-video overflow-hidden bg-neutral-900">
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
<img
src={item.thumb || FALLBACK}
alt={item.title}
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
<img
src={item.author_avatar || AVATAR_FALLBACK}
alt={item.author}
className="w-5 h-5 rounded-full object-cover shrink-0"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
<span className="truncate">{item.author}</span>
{username && <span className="text-white/50 shrink-0">{username}</span>}
</div>
</div>
</div>
<span className="sr-only">{item.title} by {item.author}</span>
</a>
</article>
)
}
/**
* Because You Like {tag}: fresh or trending artworks for the user's top tag.
* Only rendered when by_categories data is available and a top tag is known.
*/
export default function HomeBecauseYouLike({ items, preferences }) {
const topTag = preferences?.top_tags?.[0]
if (!Array.isArray(items) || items.length === 0 || !topTag) return null
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">
Because You Like{' '}
<span className="text-accent">#{topTag}</span>
</h2>
<a
href={`/browse?tags=${encodeURIComponent(topTag)}`}
className="text-sm text-nova-300 hover:text-white transition"
>
See all
</a>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<ArtCard key={item.id} item={item} />
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
/**
* Upload CTA banner — shown at the bottom of both guest and logged-in homepages.
*/
export default function HomeCTA({ isLoggedIn }) {
const uploadHref = isLoggedIn ? '/upload' : '/login?redirect=/upload'
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/20 via-nova-800 to-nova-900 px-8 py-12 text-center ring-1 ring-white/5">
{/* Decorative blobs */}
<div className="pointer-events-none absolute -top-12 -right-12 h-40 w-40 rounded-full bg-accent/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-10 -left-10 h-32 w-32 rounded-full bg-sky-500/10 blur-2xl" />
<div className="relative z-10">
<p className="text-xs font-semibold uppercase tracking-widest text-accent">Join the community</p>
<h2 className="mt-2 text-2xl font-bold text-white sm:text-3xl">
Ready to share your creativity?
</h2>
<p className="mx-auto mt-3 max-w-md text-sm text-nova-300">
Upload your artworks, wallpapers, and skins to reach thousands of enthusiasts around the world.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<a
href={uploadHref}
className="rounded-xl bg-accent px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-accent/20 transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
Upload your artwork
</a>
{!isLoggedIn && (
<a
href="/register"
className="rounded-xl border border-white/10 bg-nova-700 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-nova-600"
>
Create account
</a>
)}
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,99 @@
import React from 'react'
const CATEGORIES = [
{
label: 'Wallpapers',
description: 'Desktop & mobile backgrounds',
href: '/wallpapers',
icon: '🖥️',
mascot: '/gfx/mascot_wallpapers.webp',
color: 'from-sky-500/20 to-sky-900/40',
},
{
label: 'Photography',
description: 'Real-world captures & edits',
href: '/photography',
icon: '📷',
mascot: '/gfx/mascot_photography.webp',
color: 'from-emerald-500/20 to-emerald-900/40',
},
{
label: 'Skins',
description: 'App & game skins',
href: '/skins',
icon: '🎨',
mascot: '/gfx/mascot_skins.webp',
color: 'from-purple-500/20 to-purple-900/40',
},
{
label: 'Digital Art',
description: 'Illustrations & concept art',
href: '/other',
icon: '✏️',
mascot: '/gfx/mascot_other.webp',
color: 'from-rose-500/20 to-rose-900/40',
},
{
label: 'Tags Hub',
description: 'Browse by theme or style',
href: '/tags',
icon: '🏷️',
mascot: '/gfx/mascot_other.webp',
color: 'from-amber-500/20 to-amber-900/40',
},
]
function CategoryTile({ cat }) {
return (
<a
href={cat.href}
className={`group relative flex flex-col justify-end overflow-hidden rounded-2xl bg-gradient-to-br ${cat.color} ring-1 ring-white/5 transition hover:-translate-y-1 hover:ring-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70`}
style={{ minHeight: '7rem' }}
>
<div className="pointer-events-none absolute inset-0 bg-nova-900/20 transition group-hover:bg-nova-900/10" />
{/* Mascot image — bottom-right, partially overflowing bottom edge */}
{cat.mascot && (
<img
src={cat.mascot}
alt=""
aria-hidden="true"
className="pointer-events-none absolute bottom-0 right-0 h-24 w-auto translate-y-2 object-contain drop-shadow-xl transition-transform duration-300 group-hover:-translate-y-1 group-hover:scale-105"
loading="lazy"
/>
)}
{/* Text label — bottom-left, always readable */}
<div className="relative z-10 p-3 pr-24">
{!cat.mascot && (
<span className="mb-2 block text-2xl" role="img" aria-label={cat.label}>{cat.icon}</span>
)}
<p className="font-semibold leading-tight text-white">{cat.label}</p>
<p className="mt-0.5 text-xs text-nova-300">{cat.description}</p>
</div>
</a>
)
}
/**
* Static category quick-links. No backend data needed — these are fixed routes.
*/
export default function HomeCategories() {
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">🗂 Explore Categories</h2>
<a href="/browse" className="text-sm text-nova-300 hover:text-white transition">
Browse all
</a>
</div>
{/* 5 tiles: 2 rows on mobile, single row on xl */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{CATEGORIES.map((cat) => (
<CategoryTile key={cat.href} cat={cat} />
))}
</div>
</section>
)
}

View File

@@ -58,13 +58,13 @@ export default function HomeFresh({ items }) {
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">🆕 Fresh Uploads</h2>
<a href="/browse" className="text-sm text-nova-300 hover:text-white transition">
<a href="/discover/fresh" className="text-sm text-nova-300 hover:text-white transition">
See all
</a>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6">
{items.map((item) => (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<FreshCard key={item.id} item={item} />
))}
</div>

View File

@@ -0,0 +1,83 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null
return (
<article>
<a
href={item.url}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
<div className="relative aspect-video overflow-hidden bg-neutral-900">
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
<img
src={item.thumb || FALLBACK}
alt={item.title}
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
<img
src={item.author_avatar || AVATAR_FALLBACK}
alt={item.author}
className="w-5 h-5 rounded-full object-cover shrink-0"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
<span className="truncate">{item.author}</span>
{username && <span className="text-white/50 shrink-0">{username}</span>}
</div>
</div>
</div>
<span className="sr-only">{item.title} by {item.author}</span>
</a>
</article>
)
}
export default function HomeFromFollowing({ items }) {
// Empty state: user follows nobody
if (!Array.isArray(items) || items.length === 0) {
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">👥 From Creators You Follow</h2>
</div>
<div className="rounded-2xl border border-white/5 bg-nova-800/40 px-6 py-10 text-center">
<p className="text-sm text-soft">You're not following anyone yet.</p>
<p className="mt-1 text-xs text-nova-400">
Follow creators you love to see their latest uploads here.
</p>
<a
href="/creators/top"
className="mt-4 inline-flex items-center rounded-xl bg-nova-700 px-4 py-2 text-sm font-medium text-white hover:bg-nova-600 transition"
>
Discover creators
</a>
</div>
</section>
)
}
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">👥 From Creators You Follow</h2>
<a href="/discover/following" className="text-sm text-nova-300 hover:text-white transition">
See all
</a>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<ArtCard key={item.id} item={item} />
))}
</div>
</section>
)
}

View File

@@ -2,21 +2,23 @@ import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_lg.webp'
export default function HomeHero({ artwork }) {
export default function HomeHero({ artwork, isLoggedIn }) {
const uploadHref = isLoggedIn ? '/upload' : '/login?redirect=/upload'
if (!artwork) {
return (
<section className="relative flex min-h-[27vw] max-h-[300px] w-full items-end overflow-hidden bg-nova-900">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent" />
<div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-4xl">
Discover Digital Art
Skinbase Nova
</h1>
<p className="mt-2 max-w-xl text-sm text-soft">
Wallpapers, skins &amp; digital creations from a global community.
Discover. Create. Inspire.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<a href="/browse" className="rounded-xl bg-accent px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:brightness-110">Explore</a>
<a href="/upload" className="rounded-xl bg-nova-700 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-nova-600">Upload</a>
<a href="/discover/trending" className="rounded-xl bg-accent px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:brightness-110">Explore Trending</a>
<a href={uploadHref} className="rounded-xl bg-nova-700 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-nova-600">Upload</a>
</div>
</div>
</section>
@@ -53,13 +55,13 @@ export default function HomeHero({ artwork }) {
</p>
<div className="mt-4 flex flex-wrap gap-3">
<a
href="/browse"
href="/discover/trending"
className="rounded-xl bg-accent px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:brightness-110"
>
Explore
Explore Trending
</a>
<a
href="/upload"
href={uploadHref}
className="rounded-xl bg-nova-700 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-nova-600"
>
Upload

View File

@@ -1,14 +1,22 @@
import React, { lazy, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
// Sub-section components — lazy-loaded so only the hero blocks the initial bundle
// Above-fold — eager
import HomeHero from './HomeHero'
// Below-fold — lazy-loaded to keep initial bundle small
const HomeWelcomeRow = lazy(() => import('./HomeWelcomeRow'))
const HomeFromFollowing = lazy(() => import('./HomeFromFollowing'))
const HomeTrendingForYou = lazy(() => import('./HomeTrendingForYou'))
const HomeBecauseYouLike = lazy(() => import('./HomeBecauseYouLike'))
const HomeSuggestedCreators = lazy(() => import('./HomeSuggestedCreators'))
const HomeTrending = lazy(() => import('./HomeTrending'))
const HomeFresh = lazy(() => import('./HomeFresh'))
const HomeCategories = lazy(() => import('./HomeCategories'))
const HomeTags = lazy(() => import('./HomeTags'))
const HomeCreators = lazy(() => import('./HomeCreators'))
const HomeNews = lazy(() => import('./HomeNews'))
const HomeCTA = lazy(() => import('./HomeCTA'))
function SectionFallback() {
return (
@@ -16,32 +24,141 @@ function SectionFallback() {
)
}
function HomePage({ hero, trending, fresh, tags, creators, news }) {
return (
<div className="pb-24">
{/* Hero — above-fold, eager */}
<HomeHero artwork={hero} />
function GuestHomePage(props) {
const { hero, trending, fresh, tags, creators, news } = props
{/* Below-fold sections — lazy */}
return (
<>
{/* 1. Hero */}
<HomeHero artwork={hero} isLoggedIn={false} />
<Suspense fallback={<SectionFallback />}>
<HomeTrending items={trending} />
</Suspense>
{/* 3. Fresh Uploads */}
<Suspense fallback={<SectionFallback />}>
<HomeFresh items={fresh} />
</Suspense>
{/* 4. Explore Categories */}
<Suspense fallback={<SectionFallback />}>
<HomeCategories />
</Suspense>
{/* 5. Popular Tags */}
<Suspense fallback={<SectionFallback />}>
<HomeTags tags={tags} />
</Suspense>
{/* 6. Top Creators */}
<Suspense fallback={<SectionFallback />}>
<HomeCreators creators={creators} />
</Suspense>
{/* 7. News */}
<Suspense fallback={<SectionFallback />}>
<HomeNews items={news} />
</Suspense>
{/* 8. CTA Upload */}
<Suspense fallback={<SectionFallback />}>
<HomeCTA isLoggedIn={false} />
</Suspense>
</>
)
}
function AuthHomePage(props) {
const {
user_data,
hero,
from_following,
trending,
fresh,
by_tags,
by_categories,
suggested_creators,
tags,
creators,
news,
preferences,
} = props
return (
<>
{/* P0. Welcome/status row */}
<Suspense fallback={null}>
<HomeWelcomeRow user_data={user_data} />
</Suspense>
{/* 1. Hero */}
<HomeHero artwork={hero} isLoggedIn />
{/* P2. From Creators You Follow */}
<Suspense fallback={<SectionFallback />}>
<HomeFromFollowing items={from_following} />
</Suspense>
{/* P3. Trending For You (by_tags = Meilisearch tag overlap sorted by trending) */}
<Suspense fallback={<SectionFallback />}>
<HomeTrendingForYou items={by_tags} preferences={preferences} />
</Suspense>
{/* 2. Global Trending Now */}
<Suspense fallback={<SectionFallback />}>
<HomeTrending items={trending} />
</Suspense>
{/* P4. Because You Like {top tag} — uses by_categories for variety */}
<Suspense fallback={<SectionFallback />}>
<HomeBecauseYouLike items={by_categories} preferences={preferences} />
</Suspense>
{/* 3. Fresh Uploads */}
<Suspense fallback={<SectionFallback />}>
<HomeFresh items={fresh} />
</Suspense>
{/* 4. Explore Categories */}
<Suspense fallback={<SectionFallback />}>
<HomeCategories />
</Suspense>
{/* P5. Suggested Creators */}
<Suspense fallback={<SectionFallback />}>
<HomeSuggestedCreators creators={suggested_creators} />
</Suspense>
{/* 5. Popular Tags */}
<Suspense fallback={<SectionFallback />}>
<HomeTags tags={tags} />
</Suspense>
{/* 6. Top Creators */}
<Suspense fallback={<SectionFallback />}>
<HomeCreators creators={creators} />
</Suspense>
{/* 7. News */}
<Suspense fallback={<SectionFallback />}>
<HomeNews items={news} />
</Suspense>
{/* 8. CTA Upload */}
<Suspense fallback={<SectionFallback />}>
<HomeCTA isLoggedIn />
</Suspense>
</>
)
}
function HomePage(props) {
return (
<div className="pb-24">
{props.is_logged_in
? <AuthHomePage {...props} />
: <GuestHomePage {...props} />
}
</div>
)
}

View File

@@ -0,0 +1,67 @@
import React from 'react'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
function CreatorCard({ creator }) {
return (
<article className="group flex flex-col items-center rounded-2xl bg-nova-800/60 p-5 ring-1 ring-white/5 hover:ring-white/10 hover:bg-nova-800 transition">
<a href={creator.url} className="block">
<img
src={creator.avatar || AVATAR_FALLBACK}
alt={creator.name}
className="mx-auto h-14 w-14 rounded-full object-cover ring-2 ring-white/10 group-hover:ring-accent/50 transition"
loading="lazy"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
</a>
<div className="mt-3 w-full text-center">
<a href={creator.url} className="block truncate text-sm font-semibold text-white hover:text-accent transition">
{creator.name}
</a>
{creator.username && (
<p className="truncate text-xs text-nova-400">@{creator.username}</p>
)}
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-nova-500">
{creator.followers_count > 0 && (
<span title="Followers">{creator.followers_count.toLocaleString()} followers</span>
)}
{creator.artworks_count > 0 && (
<span title="Artworks">{creator.artworks_count.toLocaleString()} artworks</span>
)}
</div>
</div>
<a
href={creator.url}
className="mt-4 w-full rounded-lg bg-nova-700 py-1.5 text-center text-xs font-medium text-white hover:bg-nova-600 transition"
>
View Profile
</a>
</article>
)
}
export default function HomeSuggestedCreators({ creators }) {
if (!Array.isArray(creators) || creators.length === 0) return null
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">💡 Suggested Creators</h2>
<p className="mt-0.5 text-xs text-nova-400">Creators you might enjoy following</p>
</div>
<a href="/creators/top" className="text-sm text-nova-300 hover:text-white transition">
Explore all
</a>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4">
{creators.map((creator) => (
<CreatorCard key={creator.id} creator={creator} />
))}
</div>
</section>
)
}

View File

@@ -60,13 +60,13 @@ export default function HomeTrending({ items }) {
<h2 className="text-xl font-bold text-white">
🔥 Trending This Week
</h2>
<a href="/browse" className="text-sm text-nova-300 hover:text-white transition">
<a href="/discover/trending" className="text-sm text-nova-300 hover:text-white transition">
See all
</a>
</div>
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-4 xl:grid-cols-6 lg:overflow-visible">
{items.map((item) => (
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-5 lg:overflow-visible">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<ArtCard key={item.id} item={item} />
))}
</div>

View File

@@ -0,0 +1,70 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null
return (
<article>
<a
href={item.url}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
<div className="relative aspect-video overflow-hidden bg-neutral-900">
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
<img
src={item.thumb || FALLBACK}
alt={item.title}
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
<img
src={item.author_avatar || AVATAR_FALLBACK}
alt={item.author}
className="w-5 h-5 rounded-full object-cover shrink-0"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
<span className="truncate">{item.author}</span>
{username && <span className="text-white/50 shrink-0">{username}</span>}
</div>
</div>
</div>
<span className="sr-only">{item.title} by {item.author}</span>
</a>
</article>
)
}
/**
* Personalized trending: artworks matching user's top tags, sorted by trending score.
* Label and browse link adapt to the user's first top tag.
*/
export default function HomeTrendingForYou({ items, preferences }) {
if (!Array.isArray(items) || items.length === 0) return null
const topTag = preferences?.top_tags?.[0]
const heading = topTag ? `🎯 Trending in #${topTag}` : '🎯 Trending For You'
const link = topTag ? `/browse?tags=${encodeURIComponent(topTag)}&sort=trending` : '/discover/trending'
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">{heading}</h2>
<a href={link} className="text-sm text-nova-300 hover:text-white transition">
See all
</a>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<ArtCard key={item.id} item={item} />
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,74 @@
import React from 'react'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
export default function HomeWelcomeRow({ user_data }) {
if (!user_data) return null
const { name, avatar, messages_unread, notifications_unread, url } = user_data
const firstName = name?.split(' ')[0] || name || 'there'
return (
<section className="border-b border-white/5 bg-nova-900/60 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
{/* Left: greeting */}
<div className="flex items-center gap-3">
<a href={url || '/profile'}>
<img
src={avatar || AVATAR_FALLBACK}
alt={name}
className="h-9 w-9 rounded-full object-cover ring-2 ring-white/10 hover:ring-accent/60 transition"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
</a>
<div>
<p className="text-sm text-soft">Welcome back,</p>
<p className="text-sm font-semibold text-white leading-tight">{firstName}</p>
</div>
</div>
{/* Right: action badges */}
<div className="flex items-center gap-2">
{messages_unread > 0 && (
<a
href="/messages"
className="inline-flex items-center gap-1.5 rounded-lg bg-nova-800 px-3 py-1.5 text-xs font-medium text-white ring-1 ring-white/10 hover:bg-nova-700 transition"
>
<svg className="h-3.5 w-3.5 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{messages_unread} new
</a>
)}
{notifications_unread > 0 && (
<a
href="/notifications"
className="inline-flex items-center gap-1.5 rounded-lg bg-nova-800 px-3 py-1.5 text-xs font-medium text-white ring-1 ring-white/10 hover:bg-nova-700 transition"
>
<svg className="h-3.5 w-3.5 text-yellow-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{notifications_unread}
</a>
)}
<a
href="/upload"
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-white shadow hover:brightness-110 transition"
>
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload
</a>
</div>
</div>
</div>
</section>
)
}