refactor: unify artwork card rendering

This commit is contained in:
2026-03-17 14:49:20 +01:00
parent 78151aabfe
commit 980a15f66e
30 changed files with 1145 additions and 656 deletions

View File

@@ -1,10 +1,13 @@
import React, { useState } from 'react'
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
export default function ArtworkAuthor({ artwork, presentSq }) {
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingFollowState, setPendingFollowState] = useState(null)
const user = artwork?.user || {}
const authorName = user.name || user.username || 'Artist'
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
@@ -13,13 +16,7 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
const onToggleFollow = async () => {
if (following) {
const confirmed = window.confirm(`Unfollow @${user.username || user.name || 'this creator'}?`)
if (!confirmed) return
}
const nextState = !following
const persistFollowState = async (nextState) => {
setFollowing(nextState)
try {
const response = await fetch(`/api/users/${user.id}/follow`, {
@@ -43,63 +40,99 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
}
}
const onToggleFollow = async () => {
const nextState = !following
if (!nextState) {
setPendingFollowState(nextState)
setConfirmOpen(true)
return
}
await persistFollowState(nextState)
}
const onConfirmUnfollow = async () => {
if (pendingFollowState === null) return
setConfirmOpen(false)
await persistFollowState(pendingFollowState)
setPendingFollowState(null)
}
const onCloseConfirm = () => {
setConfirmOpen(false)
setPendingFollowState(null)
}
return (
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Author</h2>
<>
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Author</h2>
<div className="mt-4 flex items-center gap-4">
<img
src={avatar}
alt={authorName}
className="h-14 w-14 rounded-full border border-nova-600 object-cover bg-nova-900/50 shadow-md shadow-deep/30"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
}}
/>
<div className="mt-4 flex items-center gap-4">
<img
src={avatar}
alt={authorName}
className="h-14 w-14 rounded-full border border-nova-600 object-cover bg-nova-900/50 shadow-md shadow-deep/30"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
}}
/>
<div className="min-w-0">
<a href={profileUrl} className="block truncate text-base font-semibold text-white hover:text-accent">
{authorName}
</a>
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
<div className="min-w-0">
<a href={profileUrl} className="block truncate text-base font-semibold text-white hover:text-accent">
{authorName}
</a>
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<a
href={profileUrl}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800 transition"
>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
Profile
</a>
<button
type="button"
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${following ? 'border border-nova-600 text-white hover:bg-nova-800' : 'bg-accent text-deep hover:brightness-110'}`}
onClick={onToggleFollow}
>
{following ? (
<>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z" />
</svg>
Following
</>
) : (
<>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Follow
</>
)}
</button>
</div>
</section>
<div className="mt-4 grid grid-cols-2 gap-3">
<a
href={profileUrl}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800 transition"
>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
Profile
</a>
<button
type="button"
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${following ? 'border border-nova-600 text-white hover:bg-nova-800' : 'bg-accent text-deep hover:brightness-110'}`}
onClick={onToggleFollow}
>
{following ? (
<>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z" />
</svg>
Following
</>
) : (
<>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Follow
</>
)}
</button>
</div>
</section>
<NovaConfirmDialog
open={confirmOpen}
title="Unfollow creator?"
message={`You will stop seeing updates from @${user.username || user.name || 'this creator'} in your following feed.`}
confirmLabel="Unfollow"
cancelLabel="Keep following"
confirmTone="danger"
onConfirm={onConfirmUnfollow}
onClose={onCloseConfirm}
/>
</>
)
}

View File

@@ -0,0 +1,409 @@
import React, { useEffect, useMemo, useState } from 'react'
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
const numberFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
})
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
function formatCount(value) {
const numeric = Number(value ?? 0)
if (!Number.isFinite(numeric)) return '0'
return numberFormatter.format(numeric)
}
function slugify(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function decodeHtml(value) {
const text = String(value ?? '')
if (!text.includes('&')) return text
let decoded = text
for (let index = 0; index < 3; index += 1) {
decoded = decoded
.replace(/&amp;/gi, '&')
.replace(/&(apos|#39);/gi, "'")
.replace(/&(acute|#180|#x00B4);/gi, "'")
.replace(/&(quot|#34);/gi, '"')
.replace(/&(nbsp|#160);/gi, ' ')
if (typeof document === 'undefined') {
break
}
const textarea = document.createElement('textarea')
textarea.innerHTML = decoded
const nextValue = textarea.value
if (nextValue === decoded) break
decoded = nextValue
}
return decoded
}
function normalizeContentTypeLabel(value) {
const raw = decodeHtml(value).trim()
if (!raw) return ''
const normalized = raw.toLowerCase()
const knownLabels = {
artworks: 'Artwork',
artwork: 'Artwork',
wallpapers: 'Wallpaper',
wallpaper: 'Wallpaper',
skins: 'Skin',
skin: 'Skin',
photography: 'Photography',
photo: 'Photography',
photos: 'Photography',
other: 'Other',
}
if (knownLabels[normalized]) {
return knownLabels[normalized]
}
return raw
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function HeartIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c-4.97-3.12-8.25-6.16-8.25-10.03A4.72 4.72 0 0 1 8.5 5.5c1.5 0 2.93.7 3.84 1.92A4.8 4.8 0 0 1 16.18 5.5a4.72 4.72 0 0 1 4.82 4.72c0 3.87-3.28 6.91-8.25 10.03Z" />
</svg>
)
}
function DownloadIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3.75v10.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 10.5 3.75 3.75 3.75-3.75" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 18.75h15" />
</svg>
)
}
function ViewIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12s3.75-6.75 9.75-6.75S21.75 12 21.75 12 18 18.75 12 18.75 2.25 12 2.25 12Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 14.75A2.75 2.75 0 1 0 12 9.25a2.75 2.75 0 0 0 0 5.5Z" />
</svg>
)
}
function ActionLink({ href, label, children, onClick }) {
return (
<a
href={href || '#'}
aria-label={label}
onClick={onClick}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
{children}
</a>
)
}
function ActionButton({ label, children, onClick }) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
{children}
</button>
)
}
export default function ArtworkCard({
artwork,
variant = 'default',
compact = false,
showStats = true,
showAuthor = true,
className = '',
articleClassName = '',
frameClassName = '',
mediaClassName = '',
mediaStyle,
articleStyle,
imageClassName = '',
imageSizes,
imageSrcSet,
imageWidth,
imageHeight,
loading = 'lazy',
decoding = 'async',
fetchPriority,
onLike,
showActions = true,
}) {
const item = artwork || {}
const rawAuthor = item.author
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
const author = decodeHtml(
(typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|| item.author_name
|| item.uname
|| 'Skinbase Artist'
)
const username = rawAuthor?.username || item.author_username || item.username || null
const image = item.image || item.thumb || item.thumb_url || item.thumbnail_url || IMAGE_FALLBACK
const avatar = rawAuthor?.avatar_url || rawAuthor?.avatar || item.avatar || item.author_avatar || item.avatar_url || AVATAR_FALLBACK
const likes = item.likes ?? item.favourites ?? 0
const views = item.views ?? item.views_count ?? item.view_count ?? 0
const downloads = item.downloads ?? item.downloads_count ?? item.download_count ?? 0
const contentType = normalizeContentTypeLabel(
item.content_type
|| item.content_type_name
|| item.contentType
|| item.contentTypeName
|| item.content_type_slug
|| ''
)
const category = decodeHtml(item.category || item.category_name || '')
const width = Number(item.width ?? 0)
const height = Number(item.height ?? 0)
const resolution = decodeHtml(item.resolution || ((width > 0 && height > 0) ? `${width}x${height}` : ''))
const href = item.url || item.href || (item.id ? `/art/${item.id}/${slugify(title)}` : '#')
const downloadHref = item.download_url || item.downloadHref || (item.id ? `/download/artwork/${item.id}` : href)
const cardLabel = `${title} by ${author}`
const aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]'
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ')
const authorHref = username ? `/@${username}` : null
const initialLiked = Boolean(item.viewer?.is_liked)
const [liked, setLiked] = useState(initialLiked)
const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0)
const [likeBusy, setLikeBusy] = useState(false)
const [downloadBusy, setDownloadBusy] = useState(false)
useEffect(() => {
setLiked(Boolean(item.viewer?.is_liked))
setLikeCount(Number(item.likes ?? item.favourites ?? 0) || 0)
}, [item.id, item.likes, item.favourites, item.viewer?.is_liked])
const articleData = useMemo(() => ({
'data-art-id': item.id ?? undefined,
'data-art-url': href !== '#' ? href : undefined,
'data-art-title': title,
'data-art-img': image,
}), [href, image, item.id, title])
const handleLike = async () => {
if (!item.id || likeBusy) {
onLike?.(item)
return
}
const nextState = !liked
setLikeBusy(true)
setLiked(nextState)
setLikeCount((current) => Math.max(0, current + (nextState ? 1 : -1)))
try {
const response = await fetch(`/api/artworks/${item.id}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({ state: nextState }),
})
if (!response.ok) {
throw new Error('like_request_failed')
}
onLike?.(item)
} catch {
setLiked(!nextState)
setLikeCount((current) => Math.max(0, current + (nextState ? -1 : 1)))
} finally {
setLikeBusy(false)
}
}
const handleDownload = async (event) => {
event.preventDefault()
if (!item.id || downloadBusy) return
setDownloadBusy(true)
try {
const link = document.createElement('a')
link.href = downloadHref
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch {
window.open(downloadHref, '_blank', 'noopener,noreferrer')
} finally {
setDownloadBusy(false)
}
}
if (variant === 'embed') {
return (
<article
className={cx('group overflow-hidden rounded-xl border border-white/[0.08] bg-black/30 transition-colors hover:border-sky-500/30', articleClassName, className)}
style={articleStyle}
{...articleData}
>
<a
href={href}
className="flex gap-3 p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
aria-label={`Open artwork: ${cardLabel}`}
>
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-lg bg-white/5">
<img
src={image}
alt={title}
loading={loading}
decoding={decoding}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onError={(event) => {
event.currentTarget.src = IMAGE_FALLBACK
}}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white/90">{title}</p>
{showAuthor && (
<p className="mt-0.5 truncate text-xs text-slate-400">
{authorHref ? (
<span>
by {author} <span className="text-slate-500">@{username}</span>
</span>
) : (
<span>by {author}</span>
)}
</p>
)}
<p className="mt-1 truncate text-[10px] uppercase tracking-wider text-slate-600">
{contentType || 'Artwork'}
</p>
</div>
</a>
</article>
)
}
return (
<article
className={cx('group relative', articleClassName, className)}
style={articleStyle}
{...articleData}
>
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
<a
href={href}
aria-label={`Open artwork: ${cardLabel}`}
className="absolute inset-0 z-10 rounded-[inherit] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
<span className="sr-only">{cardLabel}</span>
</a>
<div className={cx('relative overflow-hidden bg-slate-900', aspectClass, mediaClassName)} style={mediaStyle}>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.28),_transparent_52%),linear-gradient(180deg,rgba(15,23,42,0.08),rgba(15,23,42,0.42))]" />
<img
src={image}
srcSet={imageSrcSet || undefined}
sizes={imageSizes || undefined}
alt={title}
width={imageWidth || undefined}
height={imageHeight || undefined}
loading={loading}
decoding={decoding}
fetchPriority={fetchPriority || undefined}
className={cx('h-full w-full object-cover transition duration-500 group-hover:scale-110 group-focus-within:scale-110', imageClassName)}
onError={(event) => {
event.currentTarget.src = IMAGE_FALLBACK
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
{showActions && (
<div className="absolute right-3 top-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100">
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
<HeartIcon className={cx('h-4 w-4 transition-transform duration-200', liked ? 'fill-current text-rose-300' : '', likeBusy ? 'scale-90' : '')} />
</ActionButton>
<ActionLink href={downloadHref} label={downloadBusy ? 'Downloading artwork' : 'Download artwork'} onClick={handleDownload}>
<DownloadIcon className={cx('h-4 w-4', downloadBusy ? 'animate-pulse text-emerald-300' : '')} />
</ActionLink>
<ActionLink href={href} label="View artwork">
<ViewIcon className="h-4 w-4" />
</ActionLink>
</div>
)}
<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-within:opacity-100">
<h3 className={cx('truncate font-semibold text-white', titleClass)}>
{title}
</h3>
{showAuthor ? (
<div className="mt-1 flex items-start justify-between gap-3 text-xs text-white/80">
<span className="flex min-w-0 items-start gap-3">
<img
src={avatar}
alt={`Avatar of ${author}`}
loading="lazy"
decoding="async"
className="h-9 w-9 shrink-0 rounded-full object-cover"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
}}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-white/90">
{author}
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
</span>
{showStats && metadataLine && (
<span className="mt-0.5 block truncate text-[11px] text-white/70">
{metadataLine}
</span>
)}
</span>
</span>
</div>
) : showStats && metadataLine ? (
<div className="mt-1 text-[11px] text-white/70">
{metadataLine}
</div>
) : null}
</div>
</div>
</div>
</article>
)
}

View File

@@ -1,33 +0,0 @@
import React from 'react'
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
export default function ArtworkCardMini({ item }) {
if (!item?.url) return null
return (
<article className="group min-w-[14rem] shrink-0 snap-start overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 hover:-translate-y-0.5 hover:border-white/[0.1] hover:shadow-xl hover:shadow-black/30">
<a href={item.url} className="block">
<div className="relative aspect-[4/3] overflow-hidden bg-deep">
<img
src={item.thumb || FALLBACK_MD}
srcSet={item.thumbSrcSet || undefined}
sizes="256px"
alt={item.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = FALLBACK_MD
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-60" />
</div>
<div className="px-3.5 py-3">
<h3 className="truncate text-sm font-semibold text-white/90">{item.title || 'Untitled'}</h3>
<p className="mt-0.5 truncate text-xs text-white/40">by {item.author || 'Artist'}</p>
</div>
</a>
</article>
)
}

View File

@@ -0,0 +1,58 @@
import React from 'react'
import ArtworkCard from './ArtworkCard'
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
function getArtworkKey(item, index) {
if (item?.id) return item.id
if (item?.title || item?.name || item?.author) {
return `${item.title || item.name || 'artwork'}-${item.author || item.author_name || item.uname || 'artist'}-${index}`
}
return `artwork-${index}`
}
export default function ArtworkGallery({
items,
layout = 'grid',
compact = false,
showStats = true,
showAuthor = true,
className = '',
cardClassName = '',
limit,
containerProps = {},
resolveCardProps,
children,
}) {
if (!Array.isArray(items) || items.length === 0) return null
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
const baseClassName = layout === 'masonry'
? 'grid gap-6'
: 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
return (
<div className={cx(baseClassName, className)} {...containerProps}>
{visibleItems.map((item, index) => {
const cardProps = resolveCardProps?.(item, index) || {}
const { className: resolvedClassName = '', ...restCardProps } = cardProps
return (
<ArtworkCard
key={getArtworkKey(item, index)}
artwork={item}
compact={compact}
showStats={showStats}
showAuthor={showAuthor}
className={cx(cardClassName, resolvedClassName)}
{...restCardProps}
/>
)
})}
{children}
</div>
)
}

View File

@@ -0,0 +1,32 @@
import React from 'react'
import ArtworkGallery from './ArtworkGallery'
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function ArtworkGalleryGrid({
items,
compact = false,
showStats = true,
showAuthor = true,
limit,
className = '',
cardClassName = '',
}) {
if (!Array.isArray(items) || items.length === 0) return null
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
return (
<ArtworkGallery
items={visibleItems}
layout="grid"
compact={compact}
showStats={showStats}
showAuthor={showAuthor}
className={cx(className)}
cardClassName={cardClassName}
/>
)
}

View File

@@ -1,6 +1,5 @@
import React from 'react'
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
import ArtworkGalleryGrid from './ArtworkGalleryGrid'
export default function ArtworkRelated({ related }) {
if (!Array.isArray(related) || related.length === 0) return null
@@ -9,36 +8,11 @@ export default function ArtworkRelated({ related }) {
<section className="mt-12">
<h2 className="text-lg font-semibold text-white">Related Artworks</h2>
<div className="mt-5 flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 lg:grid lg:grid-cols-4 lg:gap-5 lg:overflow-visible">
{related.slice(0, 12).map((item) => (
<article
key={item.id}
className="group min-w-[75%] snap-start overflow-hidden rounded-xl border border-nova-700 bg-panel transition lg:min-w-0 lg:hover:border-nova-500"
>
<a href={item.url} className="block">
<div className="relative aspect-video bg-deep">
<img
src={item.thumb || FALLBACK_MD}
srcSet={item.thumb_srcset || undefined}
sizes="(min-width: 1024px) 25vw, 75vw"
alt={item.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 lg:group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = FALLBACK_MD
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-deep/25 to-transparent lg:opacity-0 lg:transition lg:duration-300 lg:group-hover:opacity-100" />
</div>
<div className="p-3">
<h3 className="truncate text-sm font-semibold text-white">{item.title}</h3>
<p className="truncate text-xs text-soft">by {item.author || 'Artist'}</p>
</div>
</a>
</article>
))}
</div>
<ArtworkGalleryGrid
items={related.slice(0, 8)}
compact
className="mt-5 xl:grid-cols-4"
/>
</section>
)
}

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react'
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
@@ -23,6 +24,8 @@ function toCard(item) {
export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingFollowState, setPendingFollowState] = useState(null)
const user = artwork?.user || {}
const authorName = user.name || user.username || 'Artist'
@@ -43,13 +46,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
return source.slice(0, 12).map(toCard)
}, [related, authorName, artwork?.canonical_url])
const onToggleFollow = async () => {
if (following) {
const confirmed = window.confirm(`Unfollow @${user.username || authorName}?`)
if (!confirmed) return
}
const nextState = !following
const persistFollowState = async (nextState) => {
setFollowing(nextState)
try {
const response = await fetch(`/api/users/${user.id}/follow`, {
@@ -73,99 +70,135 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
}
}
const onToggleFollow = async () => {
const nextState = !following
if (!nextState) {
setPendingFollowState(nextState)
setConfirmOpen(true)
return
}
await persistFollowState(nextState)
}
const onConfirmUnfollow = async () => {
if (pendingFollowState === null) return
setConfirmOpen(false)
await persistFollowState(pendingFollowState)
setPendingFollowState(null)
}
const onCloseConfirm = () => {
setConfirmOpen(false)
setPendingFollowState(null)
}
return (
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
{/* Avatar + info — stacked for sidebar */}
<div className="flex flex-col items-center text-center">
<a href={profileUrl} className="group">
<img
src={avatar}
alt={authorName}
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
}}
/>
</a>
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
{authorName}
</a>
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
</p>
{/* Profile + Follow buttons */}
<div className="mt-4 flex w-full gap-2">
<a
href={profileUrl}
title="View profile"
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
Profile
<>
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
{/* Avatar + info — stacked for sidebar */}
<div className="flex flex-col items-center text-center">
<a href={profileUrl} className="group">
<img
src={avatar}
alt={authorName}
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
}}
/>
</a>
<button
type="button"
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
onClick={onToggleFollow}
className={[
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
following
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
].join(' ')}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{following ? 'Following' : 'Follow'}
</button>
</div>
</div>
{/* More from creator rail */}
{creatorItems.length > 0 && (
<div className="mt-5 border-t border-white/[0.06] pt-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
{authorName}
</a>
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
</p>
{/* Profile + Follow buttons */}
<div className="mt-4 flex w-full gap-2">
<a
href={profileUrl}
title="View profile"
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
Profile
</a>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{creatorItems.slice(0, 3).map((item, idx) => (
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
<div className="aspect-square overflow-hidden bg-deep">
<img
src={item.thumb || AVATAR_FALLBACK}
alt={item.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
loading="lazy"
decoding="async"
/>
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
</svg>
<span className="text-[10px] font-bold text-white drop-shadow">
{item.likes ? formatCount(item.likes) : ''}
</span>
</div>
</a>
))}
<button
type="button"
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
onClick={onToggleFollow}
className={[
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
following
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
].join(' ')}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{following ? 'Following' : 'Follow'}
</button>
</div>
</div>
)}
</section>
{/* More from creator rail */}
{creatorItems.length > 0 && (
<div className="mt-5 border-t border-white/[0.06] pt-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{creatorItems.slice(0, 3).map((item, idx) => (
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
<div className="aspect-square overflow-hidden bg-deep">
<img
src={item.thumb || AVATAR_FALLBACK}
alt={item.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
loading="lazy"
decoding="async"
/>
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
</svg>
<span className="text-[10px] font-bold text-white drop-shadow">
{item.likes ? formatCount(item.likes) : ''}
</span>
</div>
</a>
))}
</div>
</div>
)}
</section>
<NovaConfirmDialog
open={confirmOpen}
title="Unfollow creator?"
message={`You will stop seeing updates from @${user.username || authorName} in your following feed.`}
confirmLabel="Unfollow"
cancelLabel="Keep following"
confirmTone="danger"
onConfirm={onConfirmUnfollow}
onClose={onCloseConfirm}
/>
</>
)
}