feat: add reusable gallery carousel and ranking feed infrastructure
This commit is contained in:
@@ -1,11 +1,5 @@
|
||||
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, '');
|
||||
}
|
||||
@@ -15,17 +9,8 @@ function slugify(str) {
|
||||
* 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 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();
|
||||
@@ -40,11 +25,35 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
|
||||
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);
|
||||
// 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}/avatars/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'];
|
||||
@@ -63,6 +72,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
// 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' : '',
|
||||
@@ -76,7 +86,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`nova-card gallery-item artwork${isWideEligible ? ' nova-card--wide' : ''}`}
|
||||
className={`nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`}
|
||||
style={articleStyle}
|
||||
data-art-id={art.id}
|
||||
data-art-url={cardUrl}
|
||||
@@ -85,17 +95,18 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
>
|
||||
<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"
|
||||
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' }}
|
||||
>
|
||||
{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
|
||||
ref={mediaRef}
|
||||
className="nova-card-media relative w-full overflow-hidden bg-neutral-900"
|
||||
style={aspectStyle}
|
||||
>
|
||||
@@ -116,18 +127,6 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
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>
|
||||
@@ -136,7 +135,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={`Avatar of ${author}`}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
className="w-6 h-6 shrink-0 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="truncate">
|
||||
@@ -158,6 +157,41 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user