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();
|
||||
$sections = $user
|
||||
? $this->homepage->allForUser($user)
|
||||
: $this->homepage->all();
|
||||
: array_merge($this->homepage->all(), ['is_logged_in' => false]);
|
||||
|
||||
$hero = $sections['hero'];
|
||||
|
||||
@@ -30,9 +30,8 @@ final class HomeController extends Controller
|
||||
];
|
||||
|
||||
return view('web.home', [
|
||||
'meta' => $meta,
|
||||
'props' => $sections,
|
||||
'is_logged_in' => (bool) $user,
|
||||
'meta' => $meta,
|
||||
'props' => $sections,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
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'
|
||||
|
||||
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 & 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
|
||||
|
||||
@@ -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'
|
||||
|
||||
const HomeTrending = lazy(() => import('./HomeTrending'))
|
||||
const HomeFresh = lazy(() => import('./HomeFresh'))
|
||||
const HomeTags = lazy(() => import('./HomeTags'))
|
||||
const HomeCreators = lazy(() => import('./HomeCreators'))
|
||||
const HomeNews = lazy(() => import('./HomeNews'))
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
🔥 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>
|
||||
|
||||
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