940 lines
36 KiB
JavaScript
940 lines
36 KiB
JavaScript
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 (
|
|
<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 HideIcon(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="M6 6l12 12" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6 6 18" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function TagIcon(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="M20.25 13.5 10.5 3.75H4.5v6l9.75 9.75a2.12 2.12 0 0 0 3 0l3-3a2.12 2.12 0 0 0 0-3Z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7.875 7.875h.008v.008h-.008z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function CollectionIcon(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="M4.5 6.75A2.25 2.25 0 0 1 6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v10.5a2.25 2.25 0 0 1-2.25 2.25H6.75a2.25 2.25 0 0 1-2.25-2.25V6.75Z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8.25v7.5" />
|
|
</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>
|
|
)
|
|
}
|
|
|
|
function BadgePill({ className = '', iconClass = '', children }) {
|
|
return (
|
|
<span
|
|
className={cx(
|
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] backdrop-blur-sm ring-1 shadow-[0_8px_24px_rgba(2,6,23,0.28)]',
|
|
className
|
|
)}
|
|
>
|
|
{iconClass ? <i className={iconClass} aria-hidden="true" /> : null}
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="fixed inset-0 z-[140] flex items-center justify-center p-4">
|
|
<button
|
|
type="button"
|
|
aria-label="Close add to collection dialog"
|
|
onClick={onClose}
|
|
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
|
/>
|
|
|
|
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,15,27,0.98),rgba(6,11,20,0.98))] shadow-[0_40px_120px_rgba(2,6,23,0.55)]">
|
|
<div className="border-b border-white/10 px-6 py-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/80">Collections</p>
|
|
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Add to collection</h3>
|
|
<p className="mt-2 text-sm text-slate-300">Choose a showcase for <span className="font-semibold text-white">{artworkTitle}</span>.</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-300 transition hover:bg-white/[0.08] hover:text-white"
|
|
>
|
|
<i className="fa-solid fa-xmark" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 px-6 py-6">
|
|
{notice ? <div className="rounded-2xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{notice}</div> : null}
|
|
{error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
|
|
|
{loading ? (
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center text-sm text-slate-300">Loading collections...</div>
|
|
) : collections.length ? (
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{collections.map((collection) => (
|
|
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold text-white">{collection.title}</div>
|
|
<div className="mt-1 text-xs text-slate-400">{collection.artworks_count} artworks • {collection.visibility}</div>
|
|
</div>
|
|
{collection.already_attached ? <span className="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100">Added</span> : null}
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => onAttach(collection)}
|
|
disabled={collection.already_attached || attachingCollectionId === collection.id}
|
|
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${collection.already_attached || attachingCollectionId === collection.id ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 transition hover:bg-sky-400/15'}`}
|
|
>
|
|
<CollectionIcon className="h-3.5 w-3.5" />
|
|
{attachingCollectionId === collection.id ? 'Adding...' : collection.already_attached ? 'Already added' : 'Add'}
|
|
</button>
|
|
<a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Manage</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center">
|
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[20px] border border-white/10 bg-white/[0.05] text-slate-400">
|
|
<CollectionIcon className="h-7 w-7" />
|
|
</div>
|
|
<h4 className="mt-4 text-lg font-semibold text-white">Create your first collection</h4>
|
|
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-300">Start a curated showcase, then add this artwork into the sequence.</p>
|
|
<a href={createUrl} className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
|
<i className="fa-solid fa-plus" />
|
|
Create Collection
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<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}`}
|
|
onClick={handleOpen}
|
|
>
|
|
<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 && (
|
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
|
{authorHref ? (
|
|
<span>
|
|
by {author} <span className="text-slate-500">@{username}</span>
|
|
</span>
|
|
) : (
|
|
<span>by {author}</span>
|
|
)}
|
|
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact /> : null}
|
|
</div>
|
|
)}
|
|
<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}`}
|
|
onClick={handleOpen}
|
|
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" />
|
|
|
|
{(resolvedMetricBadge?.label || relativePublishedAt) ? (
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 p-3">
|
|
<div>
|
|
{resolvedMetricBadge?.label ? (
|
|
<BadgePill className={resolvedMetricBadge.className || 'bg-emerald-500/14 text-emerald-200 ring-emerald-400/30'} iconClass={resolvedMetricBadge.iconClass}>
|
|
{resolvedMetricBadge.label}
|
|
</BadgePill>
|
|
) : null}
|
|
</div>
|
|
|
|
{relativePublishedAt ? (
|
|
<BadgePill className="bg-black/45 text-white/75 ring-white/12" iconClass="fa-regular fa-clock text-[10px]">
|
|
{relativePublishedAt}
|
|
</BadgePill>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{showActions && (
|
|
<div className={cx(
|
|
'absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end 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',
|
|
relativePublishedAt ? 'top-12' : 'top-3'
|
|
)}>
|
|
<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>
|
|
|
|
{canAddToCollection ? (
|
|
<ActionButton label="Add artwork to collection" onClick={handleOpenCollectionPicker}>
|
|
<CollectionIcon className="h-4 w-4" />
|
|
</ActionButton>
|
|
) : null}
|
|
|
|
{canHideRecommendation ? (
|
|
<ActionButton label={hideBusy ? 'Hiding artwork' : 'Hide artwork'} onClick={handleHideArtwork}>
|
|
<HideIcon className={cx('h-4 w-4', hideBusy ? 'animate-pulse text-amber-200' : 'text-white/90')} />
|
|
</ActionButton>
|
|
) : null}
|
|
</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="flex items-center gap-2">
|
|
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
|
|
{author}
|
|
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
|
|
</span>
|
|
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
|
|
</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}
|
|
|
|
{canDislikePrimaryTag ? (
|
|
<div className="pointer-events-auto mt-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleDislikePrimaryTag}
|
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-amber-200/40 hover:bg-black/55 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
|
>
|
|
<TagIcon className={cx('h-3.5 w-3.5', dislikeBusy ? 'animate-pulse text-amber-200' : '')} />
|
|
{dislikeBusy ? 'Updating' : `Less of #${primaryTag?.slug || primaryTag?.name}`}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<CollectionPickerModal
|
|
open={collectionPickerOpen}
|
|
artworkTitle={title}
|
|
collections={collectionOptions}
|
|
loading={collectionOptionsLoading}
|
|
error={collectionPickerError}
|
|
notice={collectionPickerNotice}
|
|
createUrl={collectionCreateUrl}
|
|
attachingCollectionId={attachingCollectionId}
|
|
onAttach={handleAttachToCollection}
|
|
onClose={() => setCollectionPickerOpen(false)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|