Save workspace changes
This commit is contained in:
@@ -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/default/avatar_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>
|
||||
)
|
||||
}
|
||||
@@ -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="btn-accent-solid rounded-xl px-6 py-2.5 text-sm font-semibold"
|
||||
>
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
|
||||
function normalizeItems(items) {
|
||||
if (!Array.isArray(items)) return []
|
||||
|
||||
return items.filter((item) => item && typeof item === 'object')
|
||||
}
|
||||
|
||||
export default function HomeCollections({
|
||||
featured,
|
||||
recent,
|
||||
trending,
|
||||
editorial,
|
||||
community,
|
||||
}) {
|
||||
const featuredItems = normalizeItems(featured)
|
||||
const recentItems = normalizeItems(recent)
|
||||
const trendingItems = normalizeItems(trending)
|
||||
const editorialItems = normalizeItems(editorial)
|
||||
const communityItems = normalizeItems(community)
|
||||
const displayItems = (
|
||||
trendingItems.length ? trendingItems :
|
||||
featuredItems.length ? featuredItems :
|
||||
recentItems.length ? recentItems :
|
||||
editorialItems.length ? editorialItems :
|
||||
communityItems
|
||||
).slice(0, 3)
|
||||
|
||||
if (!displayItems.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Trending Collections</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-nova-300">
|
||||
Collections getting the strongest mix of follows, saves, and engagement right now.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/collections/trending" className="shrink-0 text-sm text-nova-300 transition hover:text-white">
|
||||
All collections →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{displayItems.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function CreatorCard({ creator }) {
|
||||
return (
|
||||
<article className="group relative flex flex-col items-center gap-3 overflow-hidden rounded-xl bg-panel p-5 shadow-sm text-center transition hover:ring-1 hover:ring-nova-500">
|
||||
{/* Background artwork thumbnail */}
|
||||
{creator.bg_thumb && (
|
||||
<>
|
||||
<img
|
||||
src={creator.bg_thumb}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-50 transition duration-500 group-hover:opacity-20 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-panel via-panel/80 to-panel/60" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<a href={creator.url} className="relative block">
|
||||
<img
|
||||
src={creator.avatar}
|
||||
alt=""
|
||||
className="mx-auto h-16 w-16 rounded-full object-cover ring-4 bg-nova-800/80 ring-nova-800"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||
/>
|
||||
<h3 className="mt-2 text-sm font-semibold text-white">{creator.name}</h3>
|
||||
</a>
|
||||
<div className="relative flex flex-wrap justify-center gap-3 text-xs text-soft">
|
||||
<span title="Total uploads">📁 {creator.uploads}</span>
|
||||
{creator.weekly_uploads > 0 && (
|
||||
<span title="Uploads this week" className="text-accent font-semibold">↑{creator.weekly_uploads} this week</span>
|
||||
)}
|
||||
<span title="Views">👁 {creator.views.toLocaleString()}</span>
|
||||
{creator.awards > 0 && <span title="Awards">🏆 {creator.awards}</span>}
|
||||
</div>
|
||||
<a
|
||||
href={creator.url}
|
||||
className="relative mt-1 rounded-lg bg-nova-700 px-4 py-1.5 text-xs font-semibold text-white transition hover:bg-nova-600"
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeCreators({ 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">
|
||||
<h2 className="text-xl font-bold text-white">👤 Creator Spotlight</h2>
|
||||
<a href="/members" className="text-sm text-nova-300 hover:text-white transition">
|
||||
All creators →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{creators.map((c) => (
|
||||
<CreatorCard key={c.id} creator={c} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||
|
||||
export default function HomeFresh({ items }) {
|
||||
if (!Array.isArray(items) || items.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">
|
||||
<h2 className="text-xl font-bold text-white">🆕 Fresh Uploads</h2>
|
||||
<a href="/discover/fresh" className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ArtworkGalleryGrid
|
||||
items={items.slice(0, 8)}
|
||||
showStats={false}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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/default/avatar_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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
|
||||
function GroupSpotlightCard({ group }) {
|
||||
if (!group) return null
|
||||
|
||||
const stats = [
|
||||
{ key: 'artworks', label: 'artworks', value: Number(group.counts?.artworks || 0) },
|
||||
{ key: 'members', label: 'members', value: Number(group.counts?.members || 0) },
|
||||
{ key: 'followers', label: 'followers', value: Number(group.counts?.followers || 0) },
|
||||
].filter((item) => item.value > 0)
|
||||
|
||||
return (
|
||||
<article className="group relative flex flex-col overflow-hidden rounded-xl bg-panel p-5 shadow-sm transition hover:ring-1 hover:ring-nova-500">
|
||||
{group.banner_url ? (
|
||||
<>
|
||||
<img
|
||||
src={group.banner_url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-40 transition duration-500 group-hover:scale-105 group-hover:opacity-20"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-panel via-panel/85 to-panel/70" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<a href={group.urls?.public || '/groups'} className="relative block">
|
||||
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-nova-800/80 ring-4 ring-nova-800">
|
||||
{group.avatar_url ? (
|
||||
<img
|
||||
src={group.avatar_url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<i className="fa-solid fa-people-group text-2xl text-white" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-3 text-base font-semibold text-white">{group.name}</h3>
|
||||
</a>
|
||||
|
||||
<p className="relative mt-2 line-clamp-3 text-sm text-soft">
|
||||
{group.headline || group.bio_excerpt || 'Shared publishing identity for collaborative releases and artwork.'}
|
||||
</p>
|
||||
|
||||
<div className="relative mt-3 flex flex-wrap gap-2 text-xs text-soft">
|
||||
{group.is_recruiting ? <span className="rounded-full bg-emerald-400/15 px-2.5 py-1 font-semibold text-emerald-200">Recruiting</span> : null}
|
||||
{group.is_verified ? <span className="rounded-full bg-sky-400/15 px-2.5 py-1 font-semibold text-sky-200">Verified</span> : null}
|
||||
{group.owner?.username || group.owner?.name ? <span>Led by {group.owner?.username || group.owner?.name}</span> : null}
|
||||
</div>
|
||||
|
||||
{stats.length > 0 ? (
|
||||
<div className="relative mt-4 flex flex-wrap gap-3 text-xs text-soft">
|
||||
{stats.map((item) => (
|
||||
<span key={item.key}>
|
||||
{item.value.toLocaleString()} {item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<a
|
||||
href={group.urls?.public || '/groups'}
|
||||
className="relative mt-4 inline-flex w-fit rounded-lg bg-nova-700 px-4 py-1.5 text-xs font-semibold text-white transition hover:bg-nova-600"
|
||||
>
|
||||
View Group
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeGroups({ groups }) {
|
||||
const spotlightGroups = [
|
||||
groups?.spotlight,
|
||||
...(Array.isArray(groups?.featured) ? groups.featured : []),
|
||||
...(Array.isArray(groups?.recruiting) ? groups.recruiting : []),
|
||||
...(Array.isArray(groups?.rising) ? groups.rising : []),
|
||||
].filter(Boolean)
|
||||
|
||||
const uniqueGroups = spotlightGroups.filter((group, index, items) => (
|
||||
items.findIndex((candidate) => candidate?.id === group?.id) === index
|
||||
)).slice(0, 4)
|
||||
|
||||
if (uniqueGroups.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">
|
||||
<h2 className="text-xl font-bold text-white">Group Spotlight</h2>
|
||||
<a href="/groups" className="text-sm text-nova-300 transition hover:text-white">
|
||||
All groups ->
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{uniqueGroups.map((group) => (
|
||||
<GroupSpotlightCard key={group.id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||
const HERO_SIZES = '100vw'
|
||||
|
||||
export default function HomeHero({ artwork }) {
|
||||
if (!artwork) {
|
||||
return (
|
||||
<section className="relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
|
||||
<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">
|
||||
Skinbase Nova
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl text-sm text-soft">
|
||||
Discover. Create. Inspire.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<a href="/discover/trending" className="btn-accent-solid rounded-xl px-5 py-2 text-sm font-semibold">Explore Trending</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const src = artwork.thumb_lg || artwork.thumb || FALLBACK
|
||||
const srcSet = artwork.thumb_srcset || null
|
||||
|
||||
return (
|
||||
<section className="group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
|
||||
{/* Background image */}
|
||||
<img
|
||||
src={src}
|
||||
srcSet={srcSet || undefined}
|
||||
sizes={srcSet ? HERO_SIZES : undefined}
|
||||
alt={artwork.title}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/55 to-transparent" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-widest text-accent">
|
||||
Featured Artwork
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white drop-shadow sm:text-4xl lg:text-5xl">
|
||||
{artwork.title}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-soft">
|
||||
by <a href={artwork.url} className="text-nova-200 hover:text-white transition">{artwork.author}</a>
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<a
|
||||
href="/discover/trending"
|
||||
className="btn-accent-solid rounded-xl px-5 py-2 text-sm font-semibold"
|
||||
>
|
||||
Explore Trending
|
||||
</a>
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="rounded-xl border border-nova-600 px-5 py-2 text-sm font-semibold text-nova-200 shadow transition hover:border-nova-400 hover:text-white"
|
||||
>
|
||||
View Artwork
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||
|
||||
export default function HomeMedalHighlights({ title, href = null, items, description = '' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 max-w-2xl text-sm text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
{href ? (
|
||||
<a href={href} className="text-sm text-nova-300 transition hover:text-white">
|
||||
See all →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ArtworkGalleryGrid items={items.slice(0, 8)} className="xl:grid-cols-4" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export default function HomeNews({ items }) {
|
||||
if (!Array.isArray(items) || items.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">
|
||||
<h2 className="text-xl font-bold text-white">News & Updates</h2>
|
||||
<a href="/news" className="text-sm text-nova-300 hover:text-white transition">
|
||||
All news →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-nova-800 overflow-hidden rounded-[24px] border border-white/10 bg-panel">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.url}
|
||||
className="grid gap-3 px-5 py-4 transition hover:bg-nova-800 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{item.eyebrow ? <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-nova-300">{item.eyebrow}</div> : null}
|
||||
<div className="mt-1 text-sm font-medium text-white line-clamp-2">{item.title}</div>
|
||||
{item.excerpt ? <p className="mt-2 text-sm leading-6 text-soft line-clamp-2">{item.excerpt}</p> : null}
|
||||
</div>
|
||||
{item.date ? <span className="flex-shrink-0 text-xs text-soft">{formatDate(item.date)}</span> : null}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { lazy, Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
// 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 HomeMedalHighlights = lazy(() => import('./HomeMedalHighlights'))
|
||||
const HomeRising = lazy(() => import('./HomeRising'))
|
||||
const HomeFresh = lazy(() => import('./HomeFresh'))
|
||||
const HomeCollections = lazy(() => import('./HomeCollections'))
|
||||
const HomeGroups = lazy(() => import('./HomeGroups'))
|
||||
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 cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function SectionFallback({ variant = 'gallery' }) {
|
||||
if (variant === 'welcome') {
|
||||
return (
|
||||
<div className="mt-10 px-4 sm:px-6 lg:px-8" aria-hidden="true">
|
||||
<div className="h-20 animate-pulse rounded-[28px] border border-white/10 bg-nova-800/70" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'tags') {
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
|
||||
<div className="mb-5 h-8 w-48 animate-pulse rounded-xl bg-nova-800/70" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-9 animate-pulse rounded-full bg-nova-800/70"
|
||||
style={{ width: `${88 + (index % 4) * 16}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'cta') {
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
|
||||
<div className="h-40 animate-pulse rounded-[28px] border border-white/10 bg-nova-800/70" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const cardClassName = variant === 'categories'
|
||||
? 'h-28 rounded-2xl'
|
||||
: variant === 'news'
|
||||
? 'h-24 rounded-2xl'
|
||||
: variant === 'creators'
|
||||
? 'h-64 rounded-2xl'
|
||||
: variant === 'collections'
|
||||
? 'h-80 rounded-[28px]'
|
||||
: variant === 'groups'
|
||||
? 'h-80 rounded-[28px]'
|
||||
: 'aspect-[4/3] rounded-2xl'
|
||||
const gridClassName = variant === 'creators'
|
||||
? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6'
|
||||
: variant === 'news'
|
||||
? 'grid-cols-1'
|
||||
: variant === 'categories'
|
||||
? 'grid-cols-2 lg:grid-cols-4'
|
||||
: variant === 'collections'
|
||||
? 'grid-cols-1 lg:grid-cols-2 xl:grid-cols-3'
|
||||
: variant === 'groups'
|
||||
? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
|
||||
: 'grid-cols-2 xl:grid-cols-4'
|
||||
const cardCount = variant === 'creators' ? 6 : variant === 'news' ? 4 : 4
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="h-8 w-48 animate-pulse rounded-xl bg-nova-800/70" />
|
||||
{(variant === 'collections' || variant === 'groups' || variant === 'news') && (
|
||||
<div className="mt-3 h-4 w-80 max-w-full animate-pulse rounded bg-nova-800/60" />
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden h-5 w-24 animate-pulse rounded bg-nova-800/60 sm:block" />
|
||||
</div>
|
||||
<div className={cx('grid gap-4', gridClassName)}>
|
||||
{Array.from({ length: cardCount }).map((_, index) => (
|
||||
<div key={index} className={cx('animate-pulse bg-nova-800/70', cardClassName)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GuestHomePage(props) {
|
||||
const { rising, trending, community_favorites, hall_of_fame, fresh, tags, creators, news, collections_featured, collections_trending, collections_editorial, collections_community, groups } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeRising items={rising} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeTrending items={trending} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeMedalHighlights
|
||||
title="Community Favorites"
|
||||
href="/explore/top-rated"
|
||||
description="Recent medal momentum from the community. This rail highlights the strongest 30-day medal signal."
|
||||
items={community_favorites}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeMedalHighlights
|
||||
title="Hall of Fame"
|
||||
href="/explore/best"
|
||||
description="All-time medal standouts that keep being remembered long after publication."
|
||||
items={hall_of_fame}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* 3. Fresh Uploads */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeFresh items={fresh} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<SectionFallback variant="collections" />}>
|
||||
<HomeCollections
|
||||
featured={collections_featured}
|
||||
trending={collections_trending}
|
||||
editorial={collections_editorial}
|
||||
community={collections_community}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<SectionFallback variant="groups" />}>
|
||||
<HomeGroups groups={groups} />
|
||||
</Suspense>
|
||||
|
||||
{/* 4. Explore Categories */}
|
||||
<Suspense fallback={<SectionFallback variant="categories" />}>
|
||||
<HomeCategories />
|
||||
</Suspense>
|
||||
|
||||
{/* 5. Popular Tags */}
|
||||
<Suspense fallback={<SectionFallback variant="tags" />}>
|
||||
<HomeTags tags={tags} />
|
||||
</Suspense>
|
||||
|
||||
{/* 6. Top Creators */}
|
||||
<Suspense fallback={<SectionFallback variant="creators" />}>
|
||||
<HomeCreators creators={creators} />
|
||||
</Suspense>
|
||||
|
||||
{/* 7. News */}
|
||||
<Suspense fallback={<SectionFallback variant="news" />}>
|
||||
<HomeNews items={news} />
|
||||
</Suspense>
|
||||
|
||||
{/* 8. CTA Upload */}
|
||||
<Suspense fallback={<SectionFallback variant="cta" />}>
|
||||
<HomeCTA isLoggedIn={false} />
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthHomePage(props) {
|
||||
const {
|
||||
user_data,
|
||||
for_you,
|
||||
from_following,
|
||||
rising,
|
||||
trending,
|
||||
community_favorites,
|
||||
hall_of_fame,
|
||||
fresh,
|
||||
collections_featured,
|
||||
collections_recent,
|
||||
collections_trending,
|
||||
collections_editorial,
|
||||
collections_community,
|
||||
groups,
|
||||
by_categories,
|
||||
suggested_creators,
|
||||
tags,
|
||||
creators,
|
||||
news,
|
||||
preferences,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* P0. Welcome/status row — below hero so featured image sits at 0px */}
|
||||
<Suspense fallback={<SectionFallback variant="welcome" />}>
|
||||
<HomeWelcomeRow user_data={user_data} />
|
||||
</Suspense>
|
||||
|
||||
{/* P2. From Creators You Follow */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeFromFollowing items={from_following} />
|
||||
</Suspense>
|
||||
|
||||
{/* P3. Personalized For You preview */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeTrendingForYou items={for_you} preferences={preferences} />
|
||||
</Suspense>
|
||||
|
||||
{/* Rising Now */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeRising items={rising} />
|
||||
</Suspense>
|
||||
|
||||
{/* 2. Global Trending Now */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeTrending items={trending} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeMedalHighlights
|
||||
title="Community Favorites"
|
||||
href="/explore/top-rated"
|
||||
description="Recent medal momentum from the community. This rail highlights the strongest 30-day medal signal."
|
||||
items={community_favorites}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeMedalHighlights
|
||||
title="Hall of Fame"
|
||||
href="/explore/best"
|
||||
description="All-time medal standouts that keep being remembered long after publication."
|
||||
items={hall_of_fame}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* P4. Because You Like {top tag} — uses by_categories for variety */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeBecauseYouLike items={by_categories} preferences={preferences} />
|
||||
</Suspense>
|
||||
|
||||
{/* 3. Fresh Uploads */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeFresh items={fresh} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<SectionFallback variant="collections" />}>
|
||||
<HomeCollections
|
||||
featured={collections_featured}
|
||||
recent={collections_recent}
|
||||
trending={collections_trending}
|
||||
editorial={collections_editorial}
|
||||
community={collections_community}
|
||||
isLoggedIn
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<SectionFallback variant="groups" />}>
|
||||
<HomeGroups groups={groups} />
|
||||
</Suspense>
|
||||
|
||||
{/* 4. Explore Categories */}
|
||||
<Suspense fallback={<SectionFallback variant="categories" />}>
|
||||
<HomeCategories />
|
||||
</Suspense>
|
||||
|
||||
{/* P5. Suggested Creators */}
|
||||
<Suspense fallback={<SectionFallback variant="creators" />}>
|
||||
<HomeSuggestedCreators creators={suggested_creators} />
|
||||
</Suspense>
|
||||
|
||||
{/* 5. Popular Tags */}
|
||||
<Suspense fallback={<SectionFallback variant="tags" />}>
|
||||
<HomeTags tags={tags} />
|
||||
</Suspense>
|
||||
|
||||
{/* 6. Top Creators */}
|
||||
<Suspense fallback={<SectionFallback variant="creators" />}>
|
||||
<HomeCreators creators={creators} />
|
||||
</Suspense>
|
||||
|
||||
{/* 7. News */}
|
||||
<Suspense fallback={<SectionFallback variant="news" />}>
|
||||
<HomeNews items={news} />
|
||||
</Suspense>
|
||||
|
||||
{/* 8. CTA Upload */}
|
||||
<Suspense fallback={<SectionFallback variant="cta" />}>
|
||||
<HomeCTA isLoggedIn />
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function HomePage(props) {
|
||||
return (
|
||||
<div className="pb-24">
|
||||
{props.is_logged_in
|
||||
? <AuthHomePage {...props} />
|
||||
: <GuestHomePage {...props} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-mount when the Blade view provides #homepage-root
|
||||
const mountEl = document.getElementById('homepage-root')
|
||||
if (mountEl) {
|
||||
let props = {}
|
||||
try {
|
||||
const propsEl = document.getElementById('homepage-props')
|
||||
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
|
||||
createRoot(mountEl).render(<HomePage {...props} />)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function ArtCard({ item }) {
|
||||
const username = item.author_username ? `@${item.author_username}` : null
|
||||
|
||||
return (
|
||||
<article className="min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0">
|
||||
<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">
|
||||
{/* Gloss sheen */}
|
||||
<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 }}
|
||||
/>
|
||||
|
||||
{/* Rising badge */}
|
||||
<div className="absolute left-3 top-3 z-30">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/80 px-2 py-1 text-[11px] font-bold text-white ring-1 ring-white/10 backdrop-blur-sm">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
Rising
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Top-right View badge */}
|
||||
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom info overlay */}
|
||||
<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 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible: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-6 h-6 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 HomeRising({ items }) {
|
||||
if (!Array.isArray(items) || items.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">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span className="text-emerald-400">🚀</span> Rising Now
|
||||
</h2>
|
||||
<a href="/discover/rising" 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-5 lg:overflow-visible">
|
||||
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
||||
<ArtCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function HomeTags({ tags }) {
|
||||
if (!Array.isArray(tags) || tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="mb-5 text-xl font-bold text-white">🏷️ Popular Tags</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<a
|
||||
key={tag.id}
|
||||
href={`/tag/${tag.slug}`}
|
||||
className="rounded-full bg-nova-800 px-4 py-1.5 text-sm font-medium text-nova-200 transition hover:bg-nova-700 hover:text-white"
|
||||
>
|
||||
{tag.name}
|
||||
{tag.count > 0 && (
|
||||
<span className="ml-1.5 text-xs text-soft">{tag.count.toLocaleString()}</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||
|
||||
export default function HomeTrending({ items }) {
|
||||
if (!Array.isArray(items) || items.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">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
🔥 Trending This Week
|
||||
</h2>
|
||||
<a href="/discover/trending" className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ArtworkGalleryGrid
|
||||
items={items.slice(0, 8)}
|
||||
className="xl:grid-cols-4"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||
|
||||
export default function HomeTrendingForYou({ items, preferences }) {
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
const topTag = preferences?.top_tags?.[0]
|
||||
const heading = 'Picked For You'
|
||||
const subheading = topTag
|
||||
? `Fresh recommendations informed by your recent interest in #${topTag}.`
|
||||
: 'A live preview of your personalized discovery feed.'
|
||||
const link = '/discover/for-you'
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.28em] text-sky-200/70">Personalized feed</p>
|
||||
<h2 className="mt-2 text-xl font-bold text-white">{heading}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-slate-300">{subheading}</p>
|
||||
</div>
|
||||
<a href={link} className="text-sm text-nova-300 transition hover:text-white">
|
||||
Open full feed →
|
||||
</a>
|
||||
</div>
|
||||
<ArtworkGalleryGrid items={items.slice(0, 8)} compact />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_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="btn-accent-solid inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold"
|
||||
>
|
||||
<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