This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from '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'
@@ -17,6 +18,39 @@ function formatCount(value) {
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()
@@ -137,6 +171,20 @@ function ActionButton({ label, children, onClick }) {
)
}
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>
)
}
export default function ArtworkCard({
artwork,
variant = 'default',
@@ -159,9 +207,10 @@ export default function ArtworkCard({
fetchPriority,
onLike,
showActions = true,
metricBadge = null,
}) {
const item = artwork || {}
const rawAuthor = item.author
const rawAuthor = item.author || item.creator
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
const author = decodeHtml(
(typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
@@ -170,6 +219,8 @@ export default function ArtworkCard({
|| '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
@@ -194,6 +245,11 @@ export default function ArtworkCard({
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)
@@ -294,7 +350,7 @@ export default function ArtworkCard({
<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">
<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>
@@ -302,7 +358,8 @@ export default function ArtworkCard({
) : (
<span>by {author}</span>
)}
</p>
{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'}
@@ -349,8 +406,29 @@ export default function ArtworkCard({
<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="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">
<div className={cx(
'absolute right-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',
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>
@@ -384,9 +462,12 @@ export default function ArtworkCard({
}}
/>
<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 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">