feat: add reusable gallery carousel and ranking feed infrastructure

This commit is contained in:
2026-02-28 07:56:25 +01:00
parent 67ef79766c
commit 6536d4ae78
36 changed files with 3177 additions and 373 deletions

View File

@@ -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>
);
}

View File

@@ -0,0 +1,158 @@
.nb-react-carousel {
position: relative;
display: flex;
align-items: center;
min-height: 56px;
overflow: hidden;
}
.nb-react-viewport {
flex: 1 1 auto;
overflow: hidden;
height: 100%;
display: flex;
align-items: center;
white-space: nowrap;
}
.nb-react-strip {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap !important;
white-space: nowrap;
width: max-content;
min-width: max-content;
max-width: none;
padding: 0.6rem 3rem;
will-change: transform;
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
cursor: grab;
user-select: none;
touch-action: pan-x;
}
.nb-react-strip.is-dragging {
transition: none;
cursor: grabbing;
}
.nb-react-fade {
position: absolute;
top: 0;
bottom: 0;
width: 80px;
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease;
}
.nb-react-fade--left {
left: 0;
background: linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.6) 50%, transparent 100%);
}
.nb-react-fade--right {
right: 0;
background: linear-gradient(to left, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.6) 50%, transparent 100%);
}
.nb-react-carousel:not(.at-start) .nb-react-fade--left {
opacity: 1;
}
.nb-react-carousel:not(.at-end) .nb-react-fade--right {
opacity: 1;
}
.nb-react-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9999px;
background: rgba(15,23,36,0.9);
border: 1px solid rgba(255,255,255,0.18);
color: rgba(255,255,255,0.85);
cursor: pointer;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 200ms ease, background 150ms ease, transform 150ms ease;
-webkit-tap-highlight-color: transparent;
}
.nb-react-arrow--left {
left: 8px;
}
.nb-react-arrow--right {
right: 8px;
}
.nb-react-arrow:hover {
background: rgba(30,46,68,0.98);
color: #fff;
transform: translateY(-50%) scale(1.1);
}
.nb-react-arrow:active {
transform: translateY(-50%) scale(0.93);
}
.nb-react-carousel:not(.at-start) .nb-react-arrow--left {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.nb-react-carousel:not(.at-end) .nb-react-arrow--right {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.nb-react-pill {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
line-height: 1;
border-radius: 9999px;
padding: 0.35rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap !important;
text-decoration: none;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08);
color: rgba(200,215,230,0.85);
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.nb-react-pill:hover {
background: rgba(255,255,255,0.15);
border-color: rgba(255,255,255,0.25);
color: #fff;
transform: translateY(-1px);
}
.nb-react-pill--active {
background: linear-gradient(135deg, #E07A21 0%, #c9650f 100%);
border-color: rgba(224,122,33,0.6);
color: #fff;
box-shadow: 0 2px 12px rgba(224,122,33,0.35), 0 0 0 1px rgba(224,122,33,0.2) inset;
transform: none;
}
.nb-react-pill--active:hover {
background: linear-gradient(135deg, #f08830 0%, #d9720f 100%);
transform: none;
}

View File

@@ -0,0 +1,282 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import './CategoryPillCarousel.css';
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
export default function CategoryPillCarousel({
items = [],
ariaLabel = 'Filter by category',
className = '',
}) {
const viewportRef = useRef(null);
const stripRef = useRef(null);
const animationRef = useRef(0);
const dragStateRef = useRef({
active: false,
moved: false,
pointerId: null,
startX: 0,
startOffset: 0,
});
const [offset, setOffset] = useState(0);
const [dragging, setDragging] = useState(false);
const [maxScroll, setMaxScroll] = useState(0);
const activeIndex = useMemo(() => {
const idx = items.findIndex((item) => !!item.active);
return idx >= 0 ? idx : 0;
}, [items]);
const maxOffset = useCallback(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return 0;
return Math.max(0, strip.scrollWidth - viewport.clientWidth);
}, []);
const recalcBounds = useCallback(() => {
const max = maxOffset();
setMaxScroll(max);
setOffset((prev) => clamp(prev, -max, 0));
}, [maxOffset]);
const moveTo = useCallback((nextOffset) => {
const max = maxOffset();
const clamped = clamp(nextOffset, -max, 0);
setOffset(clamped);
}, [maxOffset]);
const animateTo = useCallback((targetOffset, duration = 380) => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
const max = maxOffset();
const target = clamp(targetOffset, -max, 0);
const start = offset;
const delta = target - start;
if (Math.abs(delta) < 1) {
setOffset(target);
return;
}
const startTime = performance.now();
setDragging(false);
const easeOutCubic = (t) => 1 - ((1 - t) ** 3);
const step = (now) => {
const elapsed = now - startTime;
const progress = Math.min(1, elapsed / duration);
const eased = easeOutCubic(progress);
setOffset(start + (delta * eased));
if (progress < 1) {
animationRef.current = requestAnimationFrame(step);
} else {
animationRef.current = 0;
setOffset(target);
}
};
animationRef.current = requestAnimationFrame(step);
}, [maxOffset, offset]);
const moveToPill = useCallback((direction) => {
const strip = stripRef.current;
if (!strip) return;
const pills = Array.from(strip.querySelectorAll('.nb-react-pill'));
if (!pills.length) return;
const viewLeft = -offset;
if (direction > 0) {
const next = pills.find((pill) => pill.offsetLeft > viewLeft + 6);
if (next) animateTo(-next.offsetLeft);
else animateTo(-maxOffset());
return;
}
for (let i = pills.length - 1; i >= 0; i -= 1) {
const left = pills[i].offsetLeft;
if (left < viewLeft - 6) {
animateTo(-left);
return;
}
}
animateTo(0);
}, [animateTo, maxOffset, offset]);
useEffect(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return;
const activeEl = strip.querySelector('[data-active-pill="true"]');
if (!activeEl) {
moveTo(0);
return;
}
const centered = -(activeEl.offsetLeft - (viewport.clientWidth / 2) + (activeEl.offsetWidth / 2));
moveTo(centered);
recalcBounds();
}, [activeIndex, items, moveTo, recalcBounds]);
useEffect(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return;
const measure = () => recalcBounds();
const rafId = requestAnimationFrame(measure);
window.addEventListener('resize', measure, { passive: true });
let ro = null;
if ('ResizeObserver' in window) {
ro = new ResizeObserver(measure);
ro.observe(viewport);
ro.observe(strip);
}
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('resize', measure);
if (ro) ro.disconnect();
};
}, [items, recalcBounds]);
useEffect(() => {
const strip = stripRef.current;
if (!strip) return;
const onPointerDown = (event) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
dragStateRef.current.active = true;
dragStateRef.current.moved = false;
dragStateRef.current.pointerId = event.pointerId;
dragStateRef.current.startX = event.clientX;
dragStateRef.current.startOffset = offset;
setDragging(true);
if (strip.setPointerCapture) {
try { strip.setPointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
event.preventDefault();
};
const onPointerMove = (event) => {
const state = dragStateRef.current;
if (!state.active || state.pointerId !== event.pointerId) return;
const dx = event.clientX - state.startX;
if (Math.abs(dx) > 3) state.moved = true;
moveTo(state.startOffset + dx);
};
const onPointerUpOrCancel = (event) => {
const state = dragStateRef.current;
if (!state.active || state.pointerId !== event.pointerId) return;
state.active = false;
state.pointerId = null;
setDragging(false);
if (strip.releasePointerCapture) {
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
};
const onClickCapture = (event) => {
if (!dragStateRef.current.moved) return;
event.preventDefault();
event.stopPropagation();
dragStateRef.current.moved = false;
};
strip.addEventListener('pointerdown', onPointerDown);
strip.addEventListener('pointermove', onPointerMove);
strip.addEventListener('pointerup', onPointerUpOrCancel);
strip.addEventListener('pointercancel', onPointerUpOrCancel);
strip.addEventListener('click', onClickCapture, true);
return () => {
strip.removeEventListener('pointerdown', onPointerDown);
strip.removeEventListener('pointermove', onPointerMove);
strip.removeEventListener('pointerup', onPointerUpOrCancel);
strip.removeEventListener('pointercancel', onPointerUpOrCancel);
strip.removeEventListener('click', onClickCapture, true);
};
}, [moveTo, offset]);
useEffect(() => () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
}, []);
const max = maxScroll;
const atStart = offset >= -2;
const atEnd = offset <= -(max - 2);
return (
<div className={`nb-react-carousel ${atStart ? 'at-start' : ''} ${atEnd ? 'at-end' : ''} ${className}`.trim()}>
<div className="nb-react-fade nb-react-fade--left" aria-hidden="true" />
<button
type="button"
className="nb-react-arrow nb-react-arrow--left"
aria-label="Previous categories"
onClick={() => moveToPill(-1)}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd"/></svg>
</button>
<div className="nb-react-viewport" ref={viewportRef} role="list" aria-label={ariaLabel}>
<div
ref={stripRef}
className={`nb-react-strip ${dragging ? 'is-dragging' : ''}`}
style={{ transform: `translateX(${offset}px)` }}
>
{items.map((item) => (
<a
key={`${item.href}-${item.label}`}
href={item.href}
className={`nb-react-pill ${item.active ? 'nb-react-pill--active' : ''}`}
aria-current={item.active ? 'page' : 'false'}
data-active-pill={item.active ? 'true' : undefined}
draggable={false}
onDragStart={(event) => event.preventDefault()}
>
{item.label}
</a>
))}
</div>
</div>
<div className="nb-react-fade nb-react-fade--right" aria-hidden="true" />
<button
type="button"
className="nb-react-arrow nb-react-arrow--right"
aria-label="Next categories"
onClick={() => moveToPill(1)}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd"/></svg>
</button>
</div>
);
}

View File

@@ -20,22 +20,20 @@
}
}
/* Spec §5: 4 columns desktop, scaling up for very wide screens */
@media (min-width: 1024px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 1600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
@media (min-width: 2600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; }
@media (min-width: 2200px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
@@ -103,7 +101,7 @@
/* Image is positioned absolutely inside the container so it always fills
the capped box (max-height), cropping top/bottom via object-fit: cover. */
[data-nova-gallery] [data-gallery-grid] .nova-card-media img {
[data-nova-gallery] [data-gallery-grid] .nova-card-media > .nova-card-main-image {
position: absolute;
inset: 0;
width: 100%;
@@ -136,3 +134,66 @@
transform: translateY(0);
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
/* ── Card hover: bottom glow pulse ───────────────────────────────────────── */
.nova-card > a {
will-change: transform, box-shadow;
}
.nova-card:hover > a {
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.08),
0 0 20px rgba(224, 122, 33, 0.07);
}
/* ── Quick action buttons ─────────────────────────────────────────────────── */
/*
* .nb-card-actions absolutely positioned at top-right of .nova-card.
* Fades in + slides down slightly when the card is hovered.
* Requires .nova-card to have position:relative (set inline by ArtworkCard.jsx).
*/
.nb-card-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 30;
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transform: translateY(-4px);
transition: opacity 200ms ease-out, transform 200ms ease-out;
pointer-events: none;
}
.nova-card:hover .nb-card-actions,
.nova-card:focus-within .nb-card-actions {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.nb-card-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: rgba(10, 14, 20, 0.75);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
text-decoration: none;
transition: background 150ms ease, transform 150ms ease, color 150ms ease;
-webkit-tap-highlight-color: transparent;
}
.nb-card-action-btn:hover {
background: rgba(224, 122, 33, 0.85);
color: #fff;
transform: scale(1.1);
}

View File

@@ -84,6 +84,54 @@ function SkeletonCard() {
return <div className="nova-skeleton-card" aria-hidden="true" />;
}
// ── Ranking API helpers ───────────────────────────────────────────────────
/**
* Map a single ArtworkListResource item (from /api/rank/*) to the internal
* artwork object shape used by ArtworkCard.
*/
function mapRankApiArtwork(item) {
const w = item.dimensions?.width ?? null;
const h = item.dimensions?.height ?? null;
const thumb = item.thumbnail_url ?? null;
const webUrl = item.urls?.web ?? item.category?.url ?? null;
return {
id: item.id ?? null,
name: item.title ?? item.name ?? null,
thumb: thumb,
thumb_url: thumb,
uname: item.author?.name ?? '',
username: item.author?.username ?? item.author?.name ?? '',
avatar_url: item.author?.avatar_url ?? null,
category_name: item.category?.name ?? '',
category_slug: item.category?.slug ?? '',
slug: item.slug ?? '',
url: webUrl,
width: w,
height: h,
};
}
/**
* Fetch ranked artworks from the ranking API.
* Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure.
*/
async function fetchRankApiArtworks(endpoint, rankType) {
try {
const url = new URL(endpoint, window.location.href);
if (rankType) url.searchParams.set('type', rankType);
const res = await fetch(url.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return { artworks: [] };
const json = await res.json();
const items = Array.isArray(json.data) ? json.data : [];
return { artworks: items.map(mapRankApiArtwork) };
} catch {
return { artworks: [] };
}
}
const SKELETON_COUNT = 10;
// ── Main component ────────────────────────────────────────────────────────
@@ -97,6 +145,9 @@ const SKELETON_COUNT = 10;
* initialNextCursor string|null First cursor token
* initialNextPageUrl string|null First "next page" URL (page-based feeds)
* limit number Items per page (default 40)
* rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data
* source when no SSR artworks are available
* rankType string|null Ranking API ?type= param (trending|new_hot|best)
*/
function MasonryGallery({
artworks: initialArtworks = [],
@@ -105,6 +156,8 @@ function MasonryGallery({
initialNextCursor = null,
initialNextPageUrl = null,
limit = 40,
rankApiEndpoint = null,
rankType = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
@@ -115,6 +168,28 @@ function MasonryGallery({
const gridRef = useRef(null);
const triggerRef = useRef(null);
// ── Ranking API fallback ───────────────────────────────────────────────
// When the server-side render provides no initial artworks (e.g. cache miss
// or empty page result) and a ranking API endpoint is configured, perform a
// client-side fetch from the ranking API to hydrate the grid.
// Satisfies spec: "Fallback: Latest if ranking missing".
useEffect(() => {
if (initialArtworks.length > 0) return; // SSR artworks already present
if (!rankApiEndpoint) return; // no API endpoint configured
let cancelled = false;
setLoading(true);
fetchRankApiArtworks(rankApiEndpoint, rankType).then(({ artworks: ranked }) => {
if (cancelled) return;
if (ranked.length > 0) {
setArtworks(ranked);
setDone(true); // ranking API returns a full list; no further pagination
}
setLoading(false);
});
return () => { cancelled = true; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Masonry re-layout ──────────────────────────────────────────────────
const relayout = useCallback(() => {
const g = gridRef.current;
@@ -195,6 +270,10 @@ function MasonryGallery({
return () => io.disconnect();
}, [done, fetchNext]);
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
// Discover feeds (home/discover page) retain the same 5-col layout.
const gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
// ── Render ─────────────────────────────────────────────────────────────
return (
<section
@@ -210,7 +289,7 @@ function MasonryGallery({
<>
<div
ref={gridRef}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5"
className={gridClass}
data-gallery-grid
>
{artworks.map((art, idx) => (