- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so object-cover fills the max-height capped box instead of collapsing the width - MasonryGallery.css: add width:100% to media container, position img absolutely so top/bottom is cropped rather than leaving dark gaps - Add React MasonryGallery + ArtworkCard components and entry point - Add recommendation system: UserRecoProfile model/DTO/migration, SuggestedCreatorsController, SuggestedTagsController, Recommendation services, config/recommendations.php - SimilarArtworksController, DiscoverController, HomepageService updates - Update routes (api + web) and discover/for-you views - Refresh favicon assets, update vite.config.js
164 lines
7.3 KiB
JavaScript
164 lines
7.3 KiB
JavaScript
import React, { useEffect, useRef } from 'react';
|
||
|
||
function buildAvatarUrl(userId, avatarHash, size = 40) {
|
||
if (!userId) return '/images/avatar-placeholder.jpg';
|
||
if (!avatarHash) return `/avatar/default/${userId}?s=${size}`;
|
||
return `/avatar/${userId}/${avatarHash}?s=${size}`;
|
||
}
|
||
|
||
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);
|
||
|
||
// Activate blur-preview class once image has decoded (mirrors nova.js behaviour)
|
||
useEffect(() => {
|
||
const img = imgRef.current;
|
||
if (!img) return;
|
||
const markLoaded = () => img.classList.add('is-loaded');
|
||
if (img.complete && img.naturalWidth > 0) { markLoaded(); return; }
|
||
img.addEventListener('load', markLoaded, { once: true });
|
||
img.addEventListener('error', markLoaded, { once: true });
|
||
}, []);
|
||
|
||
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;
|
||
const avatarSrc = buildAvatarUrl(art.user_id, art.avatar_hash, 40);
|
||
|
||
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
|
||
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
|
||
|
||
// 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 = [
|
||
'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${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-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||
>
|
||
{category && (
|
||
<div className="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">
|
||
{category}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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
|
||
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}
|
||
/>
|
||
|
||
{/* Hover badge row */}
|
||
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
|
||
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">
|
||
View
|
||
</span>
|
||
{authorUrl && (
|
||
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">
|
||
Profile
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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 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>
|
||
</article>
|
||
);
|
||
}
|