Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz
This commit is contained in:
@@ -1,20 +1,286 @@
|
||||
import React from 'react'
|
||||
import ProfileGalleryPanel from '../ProfileGalleryPanel'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ArtworkGallery from '../../artwork/ArtworkGallery'
|
||||
|
||||
export default function TabArtworks({ artworks, featuredArtworks, username, isActive }) {
|
||||
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="pt-6"
|
||||
className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
|
||||
>
|
||||
<ProfileGalleryPanel
|
||||
artworks={artworks}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={username}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user