import React, { useEffect, useMemo, useRef, useState } from 'react'
import { usePage } from '@inertiajs/react'
import LevelBadge from '../xp/LevelBadge'
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 formatRelativeTime(value) {
if (!value) return ''
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
const now = new Date()
const diffMs = date.getTime() - now.getTime()
const diffSeconds = Math.round(diffMs / 1000)
const absSeconds = Math.abs(diffSeconds)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
const diffMinutes = Math.round(diffSeconds / 60)
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
const diffHours = Math.round(diffSeconds / 3600)
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
const diffDays = Math.round(diffSeconds / 86400)
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
const diffWeeks = Math.round(diffSeconds / 604800)
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
const diffMonths = Math.round(diffSeconds / 2629800)
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
const diffYears = Math.round(diffSeconds / 31557600)
return rtf.format(diffYears, 'year')
}
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 sendDiscoveryEvent(endpoint, payload) {
if (!endpoint) return
void fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
}).catch(() => {})
}
async function sendFeedbackSignal(endpoint, payload) {
if (!endpoint) {
throw new Error('missing_feedback_endpoint')
}
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('feedback_request_failed')
}
return response.json().catch(() => null)
}
async function requestJson(endpoint, { method = 'GET', body } = {}) {
const response = await fetch(endpoint, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const error = new Error(payload?.message || 'Request failed.')
error.payload = payload
throw error
}
return payload
}
function trackRecommendationFeedback(item, eventType, extraMeta = {}) {
const endpoint = item?.discovery_endpoint
const artworkId = Number(item?.id ?? 0)
if (!endpoint || artworkId <= 0) return
sendDiscoveryEvent(endpoint, {
event_type: eventType,
artwork_id: artworkId,
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
meta: {
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
source: item?.recommendation_source || null,
reason: item?.recommendation_reason || null,
score: item?.recommendation_score ?? null,
...extraMeta,
},
})
}
function HeartIcon(props) {
return (
)
}
function DownloadIcon(props) {
return (
)
}
function ViewIcon(props) {
return (
)
}
function HideIcon(props) {
return (
)
}
function TagIcon(props) {
return (
)
}
function CollectionIcon(props) {
return (
)
}
function ActionLink({ href, label, children, onClick }) {
return (
{children}
)
}
function ActionButton({ label, children, onClick }) {
return (
)
}
function BadgePill({ className = '', iconClass = '', children }) {
return (
{iconClass ? : null}
{children}
)
}
function CollectionPickerModal({
open,
artworkTitle,
collections,
loading,
error,
notice,
createUrl,
attachingCollectionId,
onAttach,
onClose,
}) {
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) {
return null
}
return (
Collections
Add to collection
Choose a showcase for {artworkTitle}.
{notice ?
{notice}
: null}
{error ?
{error}
: null}
{loading ? (
Loading collections...
) : collections.length ? (
{collections.map((collection) => (
{collection.title}
{collection.artworks_count} artworks • {collection.visibility}
{collection.already_attached ?
Added : null}
Manage
))}
) : (
Create your first collection
Start a curated showcase, then add this artwork into the sequence.
Create Collection
)}
)
}
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,
onDismissed,
showActions = true,
metricBadge = null,
}) {
let inertiaProps = {}
try {
inertiaProps = usePage()?.props || {}
} catch {
inertiaProps = {}
}
const item = artwork || {}
const rawAuthor = item.author || item.creator
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 authorLevel = Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
const authorRank = rawAuthor?.rank || item.author_rank || item.creator?.rank || ''
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 resolvedMetricBadge = metricBadge || item.metric_badge || null
const relativePublishedAt = useMemo(
() => formatRelativeTime(item.published_at || item.publishedAt || null),
[item.published_at, item.publishedAt]
)
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)
const [hideBusy, setHideBusy] = useState(false)
const [dislikeBusy, setDislikeBusy] = useState(false)
const [dismissed, setDismissed] = useState(false)
const [collectionPickerOpen, setCollectionPickerOpen] = useState(false)
const [collectionOptionsLoading, setCollectionOptionsLoading] = useState(false)
const [collectionOptionsLoaded, setCollectionOptionsLoaded] = useState(false)
const [collectionOptions, setCollectionOptions] = useState([])
const [collectionCreateUrl, setCollectionCreateUrl] = useState('/settings/collections/create')
const [collectionPickerError, setCollectionPickerError] = useState('')
const [collectionPickerNotice, setCollectionPickerNotice] = useState('')
const [attachingCollectionId, setAttachingCollectionId] = useState(null)
const openTrackedRef = useRef(false)
const primaryTag = useMemo(() => {
if (item?.primary_tag && typeof item.primary_tag === 'object') {
return item.primary_tag
}
if (Array.isArray(item?.tags) && item.tags.length > 0) {
return item.tags[0]
}
return null
}, [item.primary_tag, item.tags])
const hideArtworkEndpoint = item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint || null
const dislikeTagEndpoint = item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint || null
const canHideRecommendation = Boolean(item?.id && hideArtworkEndpoint && item?.recommendation_algo_version)
const canDislikePrimaryTag = Boolean(dislikeTagEndpoint && item?.recommendation_algo_version && (primaryTag?.id || primaryTag?.slug))
const authUserId = Number(inertiaProps?.auth?.user?.id ?? 0)
const itemOwnerId = Number(item.author_id ?? rawAuthor?.id ?? item.user_id ?? item.creator?.id ?? 0)
const canAddToCollection = Boolean(authUserId > 0 && Number(item.id ?? 0) > 0 && itemOwnerId > 0 && itemOwnerId === authUserId)
const collectionOptionsEndpoint = canAddToCollection
? (item.collection_options_endpoint || `/settings/collections/artworks/${item.id}/options`)
: null
useEffect(() => {
setLiked(Boolean(item.viewer?.is_liked))
setLikeCount(Number(item.likes ?? item.favourites ?? 0) || 0)
setDismissed(false)
setCollectionPickerOpen(false)
setCollectionOptionsLoading(false)
setCollectionOptionsLoaded(false)
setCollectionOptions([])
setCollectionPickerError('')
setCollectionPickerNotice('')
setAttachingCollectionId(null)
}, [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 handleOpen = () => {
if (openTrackedRef.current) return
openTrackedRef.current = true
trackRecommendationFeedback(item, 'click', {
interaction_origin: 'artwork-card-open',
target_url: href,
})
}
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')
}
if (nextState) {
trackRecommendationFeedback(item, 'favorite', {
interaction_origin: 'artwork-card-like',
})
}
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 {
trackRecommendationFeedback(item, 'download', {
interaction_origin: 'artwork-card-download',
target_url: downloadHref,
})
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)
}
}
const dismissArtwork = (kind) => {
setDismissed(true)
onDismissed?.(item, kind)
}
const handleHideArtwork = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!canHideRecommendation || hideBusy) return
setHideBusy(true)
try {
await sendFeedbackSignal(hideArtworkEndpoint, {
artwork_id: Number(item.id),
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
source: item.recommendation_source || 'recommendation-card',
meta: {
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
reason: item.recommendation_reason || null,
primary_tag_slug: primaryTag?.slug || null,
interaction_origin: 'artwork-card-hide',
},
})
dismissArtwork('hide-artwork')
} catch {
// Keep the card visible if the feedback request fails.
} finally {
setHideBusy(false)
}
}
const handleDislikePrimaryTag = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!canDislikePrimaryTag || dislikeBusy) return
setDislikeBusy(true)
try {
await sendFeedbackSignal(dislikeTagEndpoint, {
tag_id: primaryTag?.id ? Number(primaryTag.id) : undefined,
tag_slug: primaryTag?.slug || undefined,
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
source: item.recommendation_source || 'recommendation-card',
meta: {
artwork_id: Number(item.id),
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
reason: item.recommendation_reason || null,
interaction_origin: 'artwork-card-dislike-tag',
},
})
dismissArtwork('dislike-tag')
} catch {
// Keep the card visible if the feedback request fails.
} finally {
setDislikeBusy(false)
}
}
const handleOpenCollectionPicker = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!collectionOptionsEndpoint || collectionOptionsLoading) return
setCollectionPickerOpen(true)
setCollectionPickerError('')
setCollectionPickerNotice('')
if (collectionOptionsLoaded) {
return
}
setCollectionOptionsLoading(true)
try {
const payload = await requestJson(collectionOptionsEndpoint)
setCollectionOptions(Array.isArray(payload?.data) ? payload.data : [])
setCollectionCreateUrl(payload?.meta?.create_url || '/settings/collections/create')
setCollectionOptionsLoaded(true)
} catch (error) {
setCollectionPickerError(error.message || 'Unable to load collections.')
} finally {
setCollectionOptionsLoading(false)
}
}
const handleAttachToCollection = async (collection) => {
if (!collection?.attach_url || attachingCollectionId === collection.id) return
setAttachingCollectionId(collection.id)
setCollectionPickerError('')
setCollectionPickerNotice('')
try {
await requestJson(collection.attach_url, {
method: 'POST',
body: { artwork_ids: [Number(item.id)] },
})
setCollectionOptions((current) => current.map((entry) => (
entry.id === collection.id
? { ...entry, already_attached: true, artworks_count: Number(entry.artworks_count || 0) + 1 }
: entry
)))
setCollectionPickerNotice(`Added to ${collection.title}.`)
} catch (error) {
const firstError = error?.payload?.errors
? Object.values(error.payload.errors).flat().find(Boolean)
: null
setCollectionPickerError(firstError || error.message || 'Unable to add artwork to collection.')
} finally {
setAttachingCollectionId(null)
}
}
if (dismissed) {
return null
}
if (variant === 'embed') {
return (

{
event.currentTarget.src = IMAGE_FALLBACK
}}
/>
{title}
{showAuthor && (
{authorHref ? (
by {author} @{username}
) : (
by {author}
)}
{authorLevel > 0 ? : null}
)}
{contentType || 'Artwork'}
)
}
return (
<>
{cardLabel}

{
event.currentTarget.src = IMAGE_FALLBACK
}}
/>
{(resolvedMetricBadge?.label || relativePublishedAt) ? (
{resolvedMetricBadge?.label ? (
{resolvedMetricBadge.label}
) : null}
{relativePublishedAt ? (
{relativePublishedAt}
) : null}
) : null}
{showActions && (
{canAddToCollection ? (
) : null}
{canHideRecommendation ? (
) : null}
)}
{title}
{showAuthor ? (
{
event.currentTarget.src = AVATAR_FALLBACK
}}
/>
{author}
{username && @{username}}
{authorLevel > 0 ? : null}
{showStats && metadataLine && (
{metadataLine}
)}
) : showStats && metadataLine ? (
{metadataLine}
) : null}
{canDislikePrimaryTag ? (
) : null}
setCollectionPickerOpen(false)}
/>
>
)
}