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 comments = art.comments_count ?? art.comment_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-300 ease-out group-hover:scale-[1.04]', 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 (
{/* 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. */}
{title} {/* Overlay caption */}
{title}
{`Avatar {author} {username && ( @{username} )} โค {likes} ยท ๐Ÿ’ฌ {comments}
{metaParts.length > 0 && (
{metaParts.join(' โ€ข ')}
)}
{title} by {author}
{/* โ”€โ”€ Quick actions: top-right, shown on card hover via CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */}
); }