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:
@@ -17,7 +17,7 @@ final class HomeController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$sections = $user
|
$sections = $user
|
||||||
? $this->homepage->allForUser($user)
|
? $this->homepage->allForUser($user)
|
||||||
: $this->homepage->all();
|
: array_merge($this->homepage->all(), ['is_logged_in' => false]);
|
||||||
|
|
||||||
$hero = $sections['hero'];
|
$hero = $sections['hero'];
|
||||||
|
|
||||||
@@ -30,9 +30,8 @@ final class HomeController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
return view('web.home', [
|
return view('web.home', [
|
||||||
'meta' => $meta,
|
'meta' => $meta,
|
||||||
'props' => $sections,
|
'props' => $sections,
|
||||||
'is_logged_in' => (bool) $user,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,26 +54,32 @@ final class HomepageService
|
|||||||
* Personalized homepage data for an authenticated user.
|
* Personalized homepage data for an authenticated user.
|
||||||
*
|
*
|
||||||
* Sections:
|
* Sections:
|
||||||
* 1. from_following – artworks from creators you follow
|
* 1. user_data – welcome row counts (messages, notifications, new followers)
|
||||||
* 2. trending – same trending feed as guests
|
* 2. from_following – artworks from creators you follow
|
||||||
* 3. by_tags – artworks matching user's top tags
|
* 3. trending – same trending feed as guests
|
||||||
* 4. by_categories – fresh uploads in user's favourite categories
|
* 4. by_tags – artworks matching user's top tags (Trending For You)
|
||||||
* 5. tags / creators / news – shared with guest homepage
|
* 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
|
public function allForUser(\App\Models\User $user): array
|
||||||
{
|
{
|
||||||
$prefs = $this->prefs->build($user);
|
$prefs = $this->prefs->build($user);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'hero' => $this->getHeroArtwork(),
|
'is_logged_in' => true,
|
||||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
'user_data' => $this->getUserData($user),
|
||||||
'trending' => $this->getTrending(),
|
'hero' => $this->getHeroArtwork(),
|
||||||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||||
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
|
'trending' => $this->getTrending(),
|
||||||
'tags' => $this->getPopularTags(),
|
'fresh' => $this->getFreshUploads(),
|
||||||
'creators' => $this->getCreatorSpotlight(),
|
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||||||
'news' => $this->getNews(),
|
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
|
||||||
'preferences' => [
|
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
|
||||||
|
'tags' => $this->getPopularTags(),
|
||||||
|
'creators' => $this->getCreatorSpotlight(),
|
||||||
|
'news' => $this->getNews(),
|
||||||
|
'preferences' => [
|
||||||
'top_tags' => $prefs['top_tags'] ?? [],
|
'top_tags' => $prefs['top_tags'] ?? [],
|
||||||
'top_categories' => $prefs['top_categories'] ?? [],
|
'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.
|
* Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable.
|
||||||
* Spec: no heavy joins in the hot path.
|
* 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 {
|
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||||||
try {
|
try {
|
||||||
@@ -166,7 +172,7 @@ final class HomepageService
|
|||||||
/**
|
/**
|
||||||
* Fresh uploads: latest 12 approved public artworks.
|
* 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 {
|
return Cache::remember("homepage.fresh.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||||||
$artworks = Artwork::public()
|
$artworks = Artwork::public()
|
||||||
@@ -315,6 +321,95 @@ final class HomepageService
|
|||||||
// Personalized sections (auth only)
|
// 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).
|
* 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'])
|
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||||
->whereIn('user_id', $followingIds)
|
->whereIn('user_id', $followingIds)
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->limit(12)
|
->limit(10)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
|
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
|
||||||
|
|||||||
74
resources/js/Pages/Home/HomeBecauseYouLike.jsx
Normal file
74
resources/js/Pages/Home/HomeBecauseYouLike.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
resources/js/Pages/Home/HomeCTA.jsx
Normal file
44
resources/js/Pages/Home/HomeCTA.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
resources/js/Pages/Home/HomeCategories.jsx
Normal file
99
resources/js/Pages/Home/HomeCategories.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,13 +58,13 @@ export default function HomeFresh({ items }) {
|
|||||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<h2 className="text-xl font-bold text-white">🆕 Fresh Uploads</h2>
|
<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 →
|
See all →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
{items.map((item) => (
|
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
||||||
<FreshCard key={item.id} item={item} />
|
<FreshCard key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
83
resources/js/Pages/Home/HomeFromFollowing.jsx
Normal file
83
resources/js/Pages/Home/HomeFromFollowing.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,21 +2,23 @@ import React from 'react'
|
|||||||
|
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_lg.webp'
|
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) {
|
if (!artwork) {
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-[27vw] max-h-[300px] w-full items-end overflow-hidden bg-nova-900">
|
<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="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">
|
<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">
|
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-4xl">
|
||||||
Discover Digital Art
|
Skinbase Nova
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-xl text-sm text-soft">
|
<p className="mt-2 max-w-xl text-sm text-soft">
|
||||||
Wallpapers, skins & digital creations from a global community.
|
Discover. Create. Inspire.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex flex-wrap gap-3">
|
<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="/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="/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={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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -53,13 +55,13 @@ export default function HomeHero({ artwork }) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex flex-wrap gap-3">
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
<a
|
<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"
|
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>
|
||||||
<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"
|
className="rounded-xl bg-nova-700 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-nova-600"
|
||||||
>
|
>
|
||||||
Upload
|
Upload
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import React, { lazy, Suspense } from 'react'
|
import React, { lazy, Suspense } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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'
|
import HomeHero from './HomeHero'
|
||||||
|
|
||||||
const HomeTrending = lazy(() => import('./HomeTrending'))
|
// Below-fold — lazy-loaded to keep initial bundle small
|
||||||
const HomeFresh = lazy(() => import('./HomeFresh'))
|
const HomeWelcomeRow = lazy(() => import('./HomeWelcomeRow'))
|
||||||
const HomeTags = lazy(() => import('./HomeTags'))
|
const HomeFromFollowing = lazy(() => import('./HomeFromFollowing'))
|
||||||
const HomeCreators = lazy(() => import('./HomeCreators'))
|
const HomeTrendingForYou = lazy(() => import('./HomeTrendingForYou'))
|
||||||
const HomeNews = lazy(() => import('./HomeNews'))
|
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() {
|
function SectionFallback() {
|
||||||
return (
|
return (
|
||||||
@@ -16,32 +24,141 @@ function SectionFallback() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomePage({ hero, trending, fresh, tags, creators, news }) {
|
function GuestHomePage(props) {
|
||||||
return (
|
const { hero, trending, fresh, tags, creators, news } = props
|
||||||
<div className="pb-24">
|
|
||||||
{/* Hero — above-fold, eager */}
|
|
||||||
<HomeHero artwork={hero} />
|
|
||||||
|
|
||||||
{/* Below-fold sections — lazy */}
|
return (
|
||||||
|
<>
|
||||||
|
{/* 1. Hero */}
|
||||||
|
<HomeHero artwork={hero} isLoggedIn={false} />
|
||||||
<Suspense fallback={<SectionFallback />}>
|
<Suspense fallback={<SectionFallback />}>
|
||||||
<HomeTrending items={trending} />
|
<HomeTrending items={trending} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* 3. Fresh Uploads */}
|
||||||
<Suspense fallback={<SectionFallback />}>
|
<Suspense fallback={<SectionFallback />}>
|
||||||
<HomeFresh items={fresh} />
|
<HomeFresh items={fresh} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* 4. Explore Categories */}
|
||||||
|
<Suspense fallback={<SectionFallback />}>
|
||||||
|
<HomeCategories />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* 5. Popular Tags */}
|
||||||
<Suspense fallback={<SectionFallback />}>
|
<Suspense fallback={<SectionFallback />}>
|
||||||
<HomeTags tags={tags} />
|
<HomeTags tags={tags} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* 6. Top Creators */}
|
||||||
<Suspense fallback={<SectionFallback />}>
|
<Suspense fallback={<SectionFallback />}>
|
||||||
<HomeCreators creators={creators} />
|
<HomeCreators creators={creators} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* 7. News */}
|
||||||
<Suspense fallback={<SectionFallback />}>
|
<Suspense fallback={<SectionFallback />}>
|
||||||
<HomeNews items={news} />
|
<HomeNews items={news} />
|
||||||
</Suspense>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
67
resources/js/Pages/Home/HomeSuggestedCreators.jsx
Normal file
67
resources/js/Pages/Home/HomeSuggestedCreators.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -60,13 +60,13 @@ export default function HomeTrending({ items }) {
|
|||||||
<h2 className="text-xl font-bold text-white">
|
<h2 className="text-xl font-bold text-white">
|
||||||
🔥 Trending This Week
|
🔥 Trending This Week
|
||||||
</h2>
|
</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 →
|
See all →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-5 lg:overflow-visible">
|
||||||
{items.map((item) => (
|
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
||||||
<ArtCard key={item.id} item={item} />
|
<ArtCard key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
70
resources/js/Pages/Home/HomeTrendingForYou.jsx
Normal file
70
resources/js/Pages/Home/HomeTrendingForYou.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
resources/js/Pages/Home/HomeWelcomeRow.jsx
Normal file
74
resources/js/Pages/Home/HomeWelcomeRow.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user