Files
SkinbaseNova/resources/js/components/gallery/ArtworkCard.jsx
Gregor Klevze eee7df1f8c feat: artwork page carousels, recommendations, avatars & fixes
- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
2026-02-28 14:05:39 +01:00

198 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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-300 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" />
<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} · 💬 {comments}</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>
);
}