Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,74 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/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>
)
}

View File

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

View File

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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,83 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/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>
)
}

View File

@@ -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 -&gt;
</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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 &amp; 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>
)
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}