287 lines
13 KiB
JavaScript
287 lines
13 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react'
|
|
import ArtworkGallery from '../../artwork/ArtworkGallery'
|
|
|
|
function slugify(value) {
|
|
return String(value ?? '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
return Number(value ?? 0).toLocaleString()
|
|
}
|
|
|
|
function sortByPublishedAt(items) {
|
|
return [...items].sort((left, right) => {
|
|
const leftTime = left?.published_at ? new Date(left.published_at).getTime() : 0
|
|
const rightTime = right?.published_at ? new Date(right.published_at).getTime() : 0
|
|
return rightTime - leftTime
|
|
})
|
|
}
|
|
|
|
function isWallpaperArtwork(item) {
|
|
const contentType = String(item?.content_type_slug || item?.content_type || '').toLowerCase()
|
|
const category = String(item?.category_slug || item?.category || '').toLowerCase()
|
|
|
|
return contentType.includes('wallpaper') || category.includes('wallpaper')
|
|
}
|
|
|
|
function useArtworkPreview(username, sort) {
|
|
const [items, setItems] = useState([])
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
|
|
async function load() {
|
|
try {
|
|
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`, {
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
|
|
if (!response.ok) return
|
|
|
|
const data = await response.json()
|
|
if (active) {
|
|
setItems(Array.isArray(data?.data) ? data.data : [])
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
load()
|
|
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [sort, username])
|
|
|
|
return items
|
|
}
|
|
|
|
function SectionHeader({ eyebrow, title, description, action }) {
|
|
return (
|
|
<div className="mb-5 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300/80">{eyebrow}</p>
|
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">{title}</h2>
|
|
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">{description}</p> : null}
|
|
</div>
|
|
{action}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function artworkMeta(art) {
|
|
return [art?.content_type, art?.category].filter(Boolean).join(' • ')
|
|
}
|
|
|
|
function artworkStats(art) {
|
|
return [
|
|
{ label: 'Views', value: formatNumber(art?.views ?? 0), icon: 'fa-regular fa-eye' },
|
|
{ label: 'Likes', value: formatNumber(art?.likes ?? 0), icon: 'fa-regular fa-heart' },
|
|
{ label: 'Downloads', value: formatNumber(art?.downloads ?? 0), icon: 'fa-solid fa-download' },
|
|
]
|
|
}
|
|
|
|
function FeaturedShowcase({ featuredArtworks }) {
|
|
if (!featuredArtworks?.length) return null
|
|
|
|
const leadArtwork = featuredArtworks[0]
|
|
const secondaryArtworks = featuredArtworks.slice(1, 4)
|
|
const leadMeta = artworkMeta(leadArtwork)
|
|
const leadStats = artworkStats(leadArtwork)
|
|
|
|
return (
|
|
<section className="relative mt-8 overflow-hidden rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(255,255,255,0.04),rgba(249,115,22,0.12))] shadow-[0_30px_90px_rgba(2,6,23,0.3)]">
|
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(250,204,21,0.12),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(56,189,248,0.14),transparent_34%)]" />
|
|
<div className="relative grid gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.28fr)_380px]">
|
|
<a
|
|
href={`/art/${leadArtwork.id}/${slugify(leadArtwork.name)}`}
|
|
className="group relative overflow-hidden rounded-[30px] border border-white/10 bg-slate-950/60 shadow-[0_24px_60px_rgba(2,6,23,0.28)]"
|
|
>
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.24),transparent_46%),linear-gradient(to_top,rgba(2,6,23,0.9),rgba(2,6,23,0.08))]" />
|
|
<div className="aspect-[16/9] overflow-hidden">
|
|
<img
|
|
src={leadArtwork.thumb}
|
|
alt={leadArtwork.name}
|
|
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.05]"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
<div className="absolute inset-x-0 top-0 flex items-start justify-between p-5 md:p-7">
|
|
<div className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100 backdrop-blur-sm">
|
|
<i className="fa-solid fa-star text-[10px]" />
|
|
Featured spotlight
|
|
</div>
|
|
<div className="hidden rounded-2xl border border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-md md:block">
|
|
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Featured set</div>
|
|
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{formatNumber(featuredArtworks.length)}</div>
|
|
</div>
|
|
</div>
|
|
<div className="absolute inset-x-0 bottom-0 p-5 md:p-7">
|
|
{leadMeta ? (
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/85">{leadMeta}</div>
|
|
) : null}
|
|
<h2 className="mt-3 max-w-2xl text-2xl font-semibold tracking-[-0.04em] text-white md:text-[2.7rem] md:leading-[1.02]">
|
|
{leadArtwork.name}
|
|
</h2>
|
|
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-200/90 md:text-[15px]">
|
|
A standout first impression for the artwork landing page, built to pull attention before visitors move into trending picks and the full archive.
|
|
</p>
|
|
<div className="mt-5 flex flex-wrap gap-2.5">
|
|
<span className="rounded-full border border-white/15 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100/90">Top pick</span>
|
|
{leadArtwork.width && leadArtwork.height ? (
|
|
<span className="rounded-full border border-white/15 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100/90">
|
|
{leadArtwork.width}x{leadArtwork.height}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
|
{leadStats.map((stat) => (
|
|
<div key={stat.label} className="rounded-2xl border border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-md">
|
|
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300/75">
|
|
<i className={`${stat.icon} text-[10px]`} />
|
|
{stat.label}
|
|
</div>
|
|
<div className="mt-1 text-xl font-semibold tracking-tight text-white">{stat.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.66),rgba(2,6,23,0.5))] p-5 backdrop-blur-sm">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Featured</p>
|
|
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated gallery highlights</h3>
|
|
<p className="mt-2 text-sm leading-relaxed text-slate-300">
|
|
These picks create a cleaner visual entry point and give the artwork page more personality than a simple list of thumbnails.
|
|
</p>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200/85">Editorial layout</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200/85">Hero-led showcase</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
|
{secondaryArtworks.map((art, index) => (
|
|
<a
|
|
key={art.id}
|
|
href={`/art/${art.id}/${slugify(art.name)}`}
|
|
className="group flex gap-4 rounded-[26px] border border-white/10 bg-white/[0.045] p-4 shadow-[0_14px_36px_rgba(2,6,23,0.18)] transition-all hover:-translate-y-0.5 hover:bg-white/[0.08]"
|
|
>
|
|
<div className="h-24 w-28 shrink-0 overflow-hidden rounded-[18px] bg-black/30 ring-1 ring-white/10">
|
|
<img
|
|
src={art.thumb}
|
|
alt={art.name}
|
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Feature {index + 2}</div>
|
|
{artworkMeta(art) ? <div className="truncate text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100/70">{artworkMeta(art)}</div> : null}
|
|
</div>
|
|
<div className="mt-2 truncate text-lg font-semibold text-white">{art.name}</div>
|
|
{art.label ? <div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{art.label}</div> : null}
|
|
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/80">
|
|
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{formatNumber(art?.views ?? 0)} views</span>
|
|
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{formatNumber(art?.likes ?? 0)} likes</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function PreviewRail({ eyebrow, title, description, items }) {
|
|
if (!items.length) return null
|
|
|
|
return (
|
|
<section className="mt-10">
|
|
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
|
<ArtworkGallery
|
|
items={items}
|
|
compact
|
|
className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4"
|
|
resolveCardProps={() => ({ showActions: false })}
|
|
/>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function FullGalleryCta({ galleryUrl, username }) {
|
|
return (
|
|
<section className="mt-10 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 md:p-8">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300/80">Full archive</p>
|
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Want the complete gallery?</h2>
|
|
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">
|
|
The curated sections above are a friendlier starting point. The full gallery has the infinite-scroll archive with everything published by @{username}.
|
|
</p>
|
|
</div>
|
|
<a
|
|
href={galleryUrl || '#'}
|
|
className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition-colors hover:bg-sky-400/15"
|
|
>
|
|
<i className="fa-solid fa-arrow-right fa-fw" />
|
|
Browse full gallery
|
|
</a>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export default function TabArtworks({ artworks, featuredArtworks, username, galleryUrl }) {
|
|
const initialItems = artworks?.data ?? artworks ?? []
|
|
const trendingItems = useArtworkPreview(username, 'trending')
|
|
const popularItems = useArtworkPreview(username, 'views')
|
|
|
|
const wallpaperItems = useMemo(() => {
|
|
const wallpapers = popularItems.filter(isWallpaperArtwork)
|
|
return (wallpapers.length ? wallpapers : popularItems).slice(0, 4)
|
|
}, [popularItems])
|
|
|
|
const latestItems = useMemo(() => sortByPublishedAt(initialItems).slice(0, 4), [initialItems])
|
|
|
|
return (
|
|
<div
|
|
id="tabpanel-artworks"
|
|
role="tabpanel"
|
|
aria-labelledby="tab-artworks"
|
|
className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
|
|
>
|
|
<FeaturedShowcase featuredArtworks={featuredArtworks ?? []} />
|
|
|
|
<PreviewRail
|
|
eyebrow="Trending"
|
|
title="Trending artworks right now"
|
|
description="A quick scan of the work currently pulling the most momentum on the creator profile."
|
|
items={trendingItems.slice(0, 4)}
|
|
/>
|
|
|
|
<PreviewRail
|
|
eyebrow="Wallpaper picks"
|
|
title="Popular wallpapers"
|
|
description="Surface the strongest wallpaper-friendly pieces before sending people into the full archive."
|
|
items={wallpaperItems}
|
|
/>
|
|
|
|
<PreviewRail
|
|
eyebrow="Latest"
|
|
title="Recent additions"
|
|
description="Fresh uploads from the profile, presented as a preview instead of the full endless gallery."
|
|
items={latestItems}
|
|
/>
|
|
|
|
<FullGalleryCta galleryUrl={galleryUrl} username={username} />
|
|
</div>
|
|
)
|
|
}
|