refactor: unify artwork card rendering
This commit is contained in:
409
resources/js/components/artwork/ArtworkCard.jsx
Normal file
409
resources/js/components/artwork/ArtworkCard.jsx
Normal 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(/&/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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user