Files
SkinbaseNova/resources/js/components/profile/tabs/TabArtworks.jsx

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