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,204 +0,0 @@
import React, { useEffect, useRef } from 'react';
function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
/**
* React version of resources/views/components/artwork-card.blade.php
* Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies.
*/
export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = null }) {
const imgRef = useRef(null);
const mediaRef = useRef(null);
const title = (art.name || art.title || 'Untitled artwork').trim();
const author = (art.uname || art.author_name || art.author || 'Skinbase').trim();
const username = (art.username || art.uname || '').trim();
const category = (art.category_name || art.category || '').trim();
const likes = art.likes ?? art.favourites ?? 0;
const views = art.views ?? art.views_count ?? art.view_count ?? 0;
const downloads = art.downloads ?? art.downloads_count ?? art.download_count ?? 0;
const imgSrc = art.thumb || art.thumb_url || art.thumbnail_url || '/images/placeholder.jpg';
const imgSrcset = art.thumb_srcset || imgSrc;
const cardUrl = art.url || (art.id ? `/art/${art.id}/${slugify(title)}` : '#');
const authorUrl = username ? `/@${username.toLowerCase()}` : null;
// Use pre-computed CDN URL from the server; JS fallback mirrors AvatarUrl::default()
const cdnBase = 'https://files.skinbase.org';
const avatarSrc = art.avatar_url || `${cdnBase}/default/avatar_default.webp`;
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
// Activate blur-preview class once image has decoded (mirrors nova.js behaviour).
// If the server didn't supply dimensions (old artworks with width=0/height=0),
// read naturalWidth/naturalHeight from the loaded image and imperatively set
// the container's aspect-ratio so the masonry ResizeObserver picks up real proportions.
useEffect(() => {
const img = imgRef.current;
const media = mediaRef.current;
if (!img) return;
const markLoaded = () => {
img.classList.add('is-loaded');
// If no server-side dimensions, apply real ratio from the decoded image
if (media && !hasDimensions && img.naturalWidth > 0 && img.naturalHeight > 0) {
media.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
}
};
if (img.complete && img.naturalWidth > 0) { markLoaded(); return; }
img.addEventListener('load', markLoaded, { once: true });
img.addEventListener('error', markLoaded, { once: true });
}, []);
// Span 2 columns for panoramic images (AR > 2.0) in Photography or Wallpapers categories.
// These slugs match the root categories; name-matching is kept as fallback.
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper'];
const wideCategoryNames = ['photography', 'wallpapers'];
const catSlug = (art.category_slug || '').toLowerCase();
const catName = (art.category_name || '').toLowerCase();
const isWideEligible =
aspectRatio !== null &&
aspectRatio > 2.0 &&
(wideCategories.includes(catSlug) || wideCategoryNames.includes(catName));
const articleStyle = isWideEligible ? { gridColumn: 'span 2' } : {};
const aspectStyle = hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : {};
// Image always fills the container absolutely the container's height is
// driven by aspect-ratio (capped by CSS max-height). Using absolute
// positioning means width/height are always 100% of the capped box, so
// object-cover crops top/bottom instead of leaving dark gaps.
const imgClass = [
'nova-card-main-image',
'absolute inset-0 h-full w-full object-cover',
'transition-[transform,filter] duration-150 ease-out group-hover:scale-[1.03]',
loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '',
].join(' ');
const metaParts = [];
if (art.resolution) metaParts.push(art.resolution);
else if (hasDimensions) metaParts.push(`${art.width}×${art.height}`);
if (category) metaParts.push(category);
if (art.license) metaParts.push(art.license);
return (
<article
className={`nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`}
style={articleStyle}
data-art-id={art.id}
data-art-url={cardUrl}
data-art-title={title}
data-art-img={imgSrc}
>
<a
href={cardUrl}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20
shadow-lg shadow-black/40
transition-all duration-150 ease-out
hover:scale-[1.02] hover:-translate-y-px hover:ring-white/15
hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
style={{ willChange: 'transform' }}
>
{/* nova-card-media: height driven by aspect-ratio, capped by MasonryGallery.css max-height.
w-full prevents browsers shrinking the width when max-height overrides aspect-ratio. */}
<div
ref={mediaRef}
className="nova-card-media relative w-full overflow-hidden bg-neutral-900"
style={aspectStyle}
>
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none" />
<div className="pointer-events-none absolute right-2 top-2 z-20 flex items-center gap-1.5 rounded-full border border-white/10 bg-black/45 px-2 py-1 text-[10px] text-white/85 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-heart text-[9px] text-rose-300" />{likes}</span>
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-eye text-[9px] text-sky-300" />{views}</span>
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-download text-[9px] text-emerald-300" />{downloads}</span>
</div>
<img
ref={imgRef}
src={imgSrc}
srcSet={imgSrcset}
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
loading={loading}
decoding={loading === 'eager' ? 'sync' : 'async'}
fetchPriority={fetchPriority || undefined}
alt={title}
width={hasDimensions ? art.width : undefined}
height={hasDimensions ? art.height : undefined}
className={imgClass}
data-blur-preview={loading !== 'eager' ? '' : undefined}
/>
{/* Overlay caption */}
<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-visible:opacity-100">
<div className="truncate text-sm font-semibold text-white">{title}</div>
<div className="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">
<span className="truncate flex items-center gap-2">
<img
src={avatarSrc}
alt={`Avatar of ${author}`}
className="w-6 h-6 shrink-0 rounded-full object-cover"
loading="lazy"
/>
<span className="truncate">
<span>{author}</span>
{username && (
<span className="text-white/60"> @{username}</span>
)}
</span>
</span>
<span className="shrink-0"> {likes} · 👁 {views} · {downloads}</span>
</div>
{metaParts.length > 0 && (
<div className="mt-1 text-[11px] text-white/70">
{metaParts.join(' • ')}
</div>
)}
</div>
</div>
<span className="sr-only">{title} by {author}</span>
</a>
{/* ── Quick actions: top-right, shown on card hover via CSS ─────── */}
<div className="nb-card-actions" aria-hidden="true">
<button
type="button"
className="nb-card-action-btn"
title="Favourite"
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Favourite action wired to API in future iteration
}}
>
</button>
<a
href={`${cardUrl}?download=1`}
className="nb-card-action-btn"
title="Download"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
</a>
<a
href={cardUrl}
className="nb-card-action-btn"
title="Quick view"
tabIndex={-1}
>
👁
</a>
</div>
</article>
);
}