Save workspace changes
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
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 suppressClickRef = useRef(false);
|
||||
const dragStateRef = useRef({
|
||||
active: false,
|
||||
started: false,
|
||||
captured: false,
|
||||
pointerId: null,
|
||||
pointerType: 'mouse',
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startOffset: 0,
|
||||
startedOnLink: false,
|
||||
});
|
||||
|
||||
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.started = false;
|
||||
dragStateRef.current.captured = false;
|
||||
dragStateRef.current.pointerId = event.pointerId;
|
||||
dragStateRef.current.pointerType = event.pointerType || 'mouse';
|
||||
dragStateRef.current.startX = event.clientX;
|
||||
dragStateRef.current.startY = event.clientY;
|
||||
dragStateRef.current.startOffset = offset;
|
||||
dragStateRef.current.startedOnLink = !!event.target.closest('.nb-react-pill');
|
||||
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state.active || state.pointerId !== event.pointerId) return;
|
||||
|
||||
const dx = event.clientX - state.startX;
|
||||
const dy = event.clientY - state.startY;
|
||||
const threshold = state.pointerType === 'touch'
|
||||
? 12
|
||||
: (state.startedOnLink ? 24 : 12);
|
||||
if (!state.started) {
|
||||
if (Math.abs(dx) <= threshold || Math.abs(dx) <= Math.abs(dy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.started = true;
|
||||
if (!state.captured && strip.setPointerCapture) {
|
||||
try {
|
||||
strip.setPointerCapture(event.pointerId);
|
||||
state.captured = true;
|
||||
} catch (_) {
|
||||
state.captured = false;
|
||||
}
|
||||
}
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
if (state.started) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
moveTo(state.startOffset + dx);
|
||||
};
|
||||
|
||||
const onPointerUpOrCancel = (event) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state.active || state.pointerId !== event.pointerId) return;
|
||||
|
||||
suppressClickRef.current = state.started;
|
||||
state.active = false;
|
||||
state.started = false;
|
||||
state.startedOnLink = false;
|
||||
state.pointerId = null;
|
||||
setDragging(false);
|
||||
|
||||
if (state.captured && strip.releasePointerCapture) {
|
||||
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
|
||||
}
|
||||
state.captured = false;
|
||||
};
|
||||
|
||||
const onClickCapture = (event) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressClickRef.current = 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* MasonryGallery – scoped CSS
|
||||
*
|
||||
* Grid column definitions (activated when React adds .is-enhanced to the root).
|
||||
* Mirrors the blade @push('styles') blocks so the same rules apply whether the
|
||||
* page is rendered server-side or by the React component.
|
||||
*/
|
||||
|
||||
/* ── Masonry grid ─────────────────────────────────────────────────────────── */
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
grid-auto-rows: 8px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* 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(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(5, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@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; }
|
||||
|
||||
/* ── Fallback aspect-ratio for cards without stored dimensions ───────────── */
|
||||
/*
|
||||
* When ArtworkCard has no width/height data it renders the img as h-auto,
|
||||
* meaning the container height is 0 until the image loads. Setting a
|
||||
* default aspect-ratio here reserves approximate space immediately and
|
||||
* prevents applyMasonry from calculating span=1 → then jumping on load.
|
||||
* Cards with an inline aspect-ratio style (from real dimensions) override this.
|
||||
*/
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||
aspect-ratio: 3 / 2;
|
||||
width: 100%; /* prevent aspect-ratio + max-height from shrinking the column width */
|
||||
}
|
||||
|
||||
/* Override: when an inline aspect-ratio is set by ArtworkCard those values */
|
||||
/* take precedence naturally (inline style > class). No extra selector needed. */
|
||||
|
||||
/* ── Card max-height cap ──────────────────────────────────────────────────── */
|
||||
/*
|
||||
* Limits any single card to the height of 2 stacked 16:9 images in its column.
|
||||
* Formula: 2 × (col_width × 9/16) = col_width × 9/8
|
||||
*
|
||||
* 5-col (lg+): col_width = (100vw - 80px_padding - 4×24px_gaps) / 5
|
||||
* = (100vw - 176px) / 5
|
||||
* max-height = (100vw - 176px) / 5 × 9/8
|
||||
* = (100vw - 176px) × 0.225
|
||||
*
|
||||
* 2-col (md): col_width = (100vw - 80px - 1×24px) / 2
|
||||
* = (100vw - 104px) / 2
|
||||
* max-height = (100vw - 104px) / 2 × 9/8
|
||||
* = (100vw - 104px) × 0.5625
|
||||
*
|
||||
* 1-col mobile: uncapped – portrait images are fine filling the full width.
|
||||
*/
|
||||
|
||||
/* Global selector covers both the React-rendered gallery and the blade fallback */
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||
overflow: hidden; /* ensure img is clipped at max-height */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||
/* 5-column layout: 2 × (col_width × 9/16) = col_width × 9/8 */
|
||||
max-height: calc((100vw - 176px) * 9 / 40);
|
||||
}
|
||||
/* Wide (2-col spanning) cards get double the column width */
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
|
||||
max-height: calc((100vw - 176px) * 9 / 20);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||
/* 2-column layout */
|
||||
max-height: calc((100vw - 104px) * 9 / 16);
|
||||
}
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
|
||||
/* 2-col span fills full width on md breakpoint */
|
||||
max-height: calc((100vw - 104px) * 9 / 8);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 > .nova-card-main-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||
.nova-skeleton-card {
|
||||
border-radius: 1rem;
|
||||
min-height: 180px;
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
rgba(255, 255, 255, 0.06) 8%,
|
||||
rgba(255, 255, 255, 0.12) 18%,
|
||||
rgba(255, 255, 255, 0.06) 33%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: novaShimmer 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes novaShimmer {
|
||||
to { background-position-x: -200%; }
|
||||
}
|
||||
|
||||
/* ── Card enter animation (appended by infinite scroll) ───────────────────── */
|
||||
.nova-card-enter { opacity: 0; transform: translateY(8px); }
|
||||
.nova-card-enter-active {
|
||||
opacity: 1;
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import React, {
|
||||
useState, useEffect, useRef, useCallback, memo,
|
||||
} from 'react';
|
||||
import ArtworkGallery from '../artwork/ArtworkGallery';
|
||||
import './MasonryGallery.css';
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
}
|
||||
|
||||
async function sendDiscoveryEvent(endpoint, payload) {
|
||||
if (!endpoint) return;
|
||||
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
// Discovery telemetry should never block the gallery UX.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Masonry helpers ────────────────────────────────────────────────────────
|
||||
const ROW_SIZE = 8;
|
||||
const ROW_GAP = 16;
|
||||
|
||||
function applyMasonry(grid) {
|
||||
if (!grid) return;
|
||||
Array.from(grid.querySelectorAll('.nova-card')).forEach((card) => {
|
||||
const media = card.querySelector('.nova-card-media') || card;
|
||||
let height = media.getBoundingClientRect().height || 200;
|
||||
|
||||
// Clamp to the computed max-height so the span never over-reserves rows
|
||||
// when CSS max-height kicks in (e.g. portrait images capped to 2×16:9).
|
||||
const cssMaxH = parseFloat(getComputedStyle(media).maxHeight);
|
||||
if (!isNaN(cssMaxH) && cssMaxH > 0 && cssMaxH < height) {
|
||||
height = cssMaxH;
|
||||
}
|
||||
|
||||
const span = Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_SIZE + ROW_GAP)));
|
||||
card.style.gridRowEnd = `span ${span}`;
|
||||
});
|
||||
}
|
||||
|
||||
function waitForImages(el) {
|
||||
return Promise.all(
|
||||
Array.from(el.querySelectorAll('img')).map((img) =>
|
||||
img.decode ? img.decode().catch(() => null) : Promise.resolve(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page fetch helpers ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* Fetch the next page of data.
|
||||
*
|
||||
* The response is either:
|
||||
* - JSON { artworks: [...], next_cursor: '...' } when X-Requested-With is
|
||||
* sent and the controller returns JSON (future enhancement)
|
||||
* - HTML page – we parse [data-react-masonry-gallery] from it and read its
|
||||
* data-artworks / data-next-cursor / data-next-page-url attributes.
|
||||
*/
|
||||
async function fetchPageData(url) {
|
||||
const res = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
|
||||
// JSON fast-path (if controller ever returns JSON)
|
||||
if (ct.includes('application/json')) {
|
||||
const json = await res.json();
|
||||
|
||||
// Support multiple API payload shapes across endpoints.
|
||||
const artworks = Array.isArray(json.artworks)
|
||||
? json.artworks
|
||||
: Array.isArray(json.data)
|
||||
? json.data
|
||||
: Array.isArray(json.items)
|
||||
? json.items
|
||||
: Array.isArray(json.results)
|
||||
? json.results
|
||||
: [];
|
||||
|
||||
const nextCursor = json.next_cursor
|
||||
?? json.nextCursor
|
||||
?? json.meta?.next_cursor
|
||||
?? null;
|
||||
|
||||
const nextPageUrl = json.next_page_url
|
||||
?? json.nextPageUrl
|
||||
?? json.meta?.next_page_url
|
||||
?? null;
|
||||
|
||||
const hasMore = typeof json.has_more === 'boolean'
|
||||
? json.has_more
|
||||
: typeof json.hasMore === 'boolean'
|
||||
? json.hasMore
|
||||
: null;
|
||||
|
||||
return {
|
||||
artworks,
|
||||
nextCursor,
|
||||
nextPageUrl,
|
||||
hasMore,
|
||||
meta: json.meta ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// HTML: parse and extract mount-container data attributes
|
||||
const html = await res.text();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const el = doc.querySelector('[data-react-masonry-gallery]');
|
||||
if (!el) return { artworks: [], nextCursor: null, nextPageUrl: null };
|
||||
|
||||
let artworks = [];
|
||||
try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ }
|
||||
|
||||
return {
|
||||
artworks,
|
||||
nextCursor: el.dataset.nextCursor || null,
|
||||
nextPageUrl: el.dataset.nextPageUrl || null,
|
||||
hasMore: null,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Skeleton row ──────────────────────────────────────────────────────────
|
||||
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;
|
||||
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null;
|
||||
return {
|
||||
id: item.id ?? null,
|
||||
name: item.title ?? item.name ?? null,
|
||||
thumb: thumb,
|
||||
thumb_url: thumb,
|
||||
uname: item.author?.name ?? '',
|
||||
username: publisher?.type === 'group' ? '' : (item.author?.username ?? ''),
|
||||
avatar_url: item.author?.avatar_url ?? null,
|
||||
profile_url: publisher?.profile_url ?? item.author?.profile_url ?? null,
|
||||
published_as_type: publisher?.type ?? null,
|
||||
publisher: publisher,
|
||||
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
|
||||
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
|
||||
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;
|
||||
|
||||
function getMasonryCardProps(art, idx) {
|
||||
const title = (art.name || art.title || 'Untitled artwork').trim();
|
||||
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
|
||||
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
|
||||
const categorySlug = (art.category_slug || '').toLowerCase();
|
||||
const categoryName = (art.category_name || art.category || '').toLowerCase();
|
||||
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper'];
|
||||
const wideCategoryNames = ['photography', 'wallpapers'];
|
||||
const isWideEligible =
|
||||
aspectRatio !== null &&
|
||||
aspectRatio > 2.0 &&
|
||||
(wideCategories.includes(categorySlug) || wideCategoryNames.includes(categoryName));
|
||||
|
||||
return {
|
||||
articleClassName: `nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`,
|
||||
articleStyle: isWideEligible ? { gridColumn: 'span 2' } : undefined,
|
||||
frameClassName: 'rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 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)]',
|
||||
mediaClassName: 'nova-card-media relative w-full overflow-hidden bg-neutral-900',
|
||||
mediaStyle: hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : undefined,
|
||||
imageSrcSet: art.thumb_srcset || undefined,
|
||||
imageSizes: '(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw',
|
||||
imageWidth: hasDimensions ? art.width : undefined,
|
||||
imageHeight: hasDimensions ? art.height : undefined,
|
||||
loading: idx < 8 ? 'eager' : 'lazy',
|
||||
decoding: idx < 8 ? 'sync' : 'async',
|
||||
fetchPriority: idx === 0 ? 'high' : undefined,
|
||||
imageClassName: 'nova-card-main-image absolute inset-0 h-full w-full object-cover group-hover:scale-[1.03]',
|
||||
metricBadge: art.recommendation_reason
|
||||
? {
|
||||
label: art.recommendation_reason,
|
||||
className: 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────
|
||||
/**
|
||||
* MasonryGallery
|
||||
*
|
||||
* Props (all optional – set via data attributes in entry-masonry-gallery.jsx):
|
||||
* artworks [] Initial artwork objects
|
||||
* galleryType string Maps to data-gallery-type (e.g. 'trending')
|
||||
* cursorEndpoint string|null Route for cursor-based feeds (e.g. For You)
|
||||
* 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)
|
||||
* gridClassName string|null Optional CSS class override for grid columns/gaps
|
||||
*/
|
||||
function MasonryGallery({
|
||||
artworks: initialArtworks = [],
|
||||
galleryType = 'discover',
|
||||
cursorEndpoint = null,
|
||||
initialNextCursor = null,
|
||||
initialNextPageUrl = null,
|
||||
limit = 40,
|
||||
rankApiEndpoint = null,
|
||||
rankType = null,
|
||||
gridClassName = null,
|
||||
discoveryEndpoint = null,
|
||||
algoVersion: initialAlgoVersion = null,
|
||||
}) {
|
||||
const [artworks, setArtworks] = useState(initialArtworks);
|
||||
const [nextCursor, setNextCursor] = useState(initialNextCursor);
|
||||
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [done, setDone] = useState(!initialNextCursor && !initialNextPageUrl);
|
||||
const [algoVersion, setAlgoVersion] = useState(initialAlgoVersion);
|
||||
|
||||
const gridRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
const viewedArtworkIdsRef = useRef(new Set());
|
||||
const clickedArtworkIdsRef = useRef(new Set());
|
||||
|
||||
// ── 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;
|
||||
if (!g) return;
|
||||
applyMasonry(g);
|
||||
waitForImages(g).then(() => applyMasonry(g));
|
||||
}, []);
|
||||
|
||||
// Re-layout whenever artworks list changes.
|
||||
// Defer by one requestAnimationFrame so the browser has resolved
|
||||
// aspect-ratio heights before we measure with getBoundingClientRect().
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => relayout());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [artworks, relayout]);
|
||||
|
||||
// Re-layout on container resize (column width changes)
|
||||
useEffect(() => {
|
||||
const g = gridRef.current;
|
||||
if (!g || !('ResizeObserver' in window)) return;
|
||||
const ro = new ResizeObserver(relayout);
|
||||
ro.observe(g);
|
||||
return () => ro.disconnect();
|
||||
}, [relayout]);
|
||||
|
||||
// ── Load more ──────────────────────────────────────────────────────────
|
||||
const fetchNext = useCallback(async () => {
|
||||
if (loading || done) return;
|
||||
|
||||
// Build the URL to fetch
|
||||
let fetchUrl = null;
|
||||
if (cursorEndpoint && nextCursor) {
|
||||
const u = new URL(cursorEndpoint, window.location.href);
|
||||
u.searchParams.set('cursor', nextCursor);
|
||||
u.searchParams.set('limit', String(limit));
|
||||
fetchUrl = u.toString();
|
||||
} else if (nextPageUrl) {
|
||||
fetchUrl = nextPageUrl;
|
||||
}
|
||||
|
||||
if (!fetchUrl) { setDone(true); return; }
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore, meta } =
|
||||
await fetchPageData(fetchUrl);
|
||||
|
||||
if (meta?.algo_version) {
|
||||
setAlgoVersion(meta.algo_version);
|
||||
}
|
||||
|
||||
if (!newItems.length) {
|
||||
setDone(true);
|
||||
} else {
|
||||
setArtworks((prev) => [...prev, ...newItems]);
|
||||
if (cursorEndpoint) {
|
||||
setNextCursor(nc);
|
||||
if (hasMore === false || !nc) setDone(true);
|
||||
} else {
|
||||
setNextPageUrl(np);
|
||||
if (!np) setDone(true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setDone(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading, done, cursorEndpoint, nextCursor, nextPageUrl, limit]);
|
||||
|
||||
// ── Intersection observer for infinite scroll ──────────────────────────
|
||||
useEffect(() => {
|
||||
if (done) return;
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger || !('IntersectionObserver' in window)) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => { if (entries[0].isIntersecting) fetchNext(); },
|
||||
{ rootMargin: '900px', threshold: 0 },
|
||||
);
|
||||
io.observe(trigger);
|
||||
return () => io.disconnect();
|
||||
}, [done, fetchNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
|
||||
|
||||
const grid = gridRef.current;
|
||||
if (!grid || !(window.IntersectionObserver)) return undefined;
|
||||
|
||||
const artworkIndex = new Map(artworks.map((art, index) => [String(art.id), { art, index }]));
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.65) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = entry.target.closest('[data-art-id]');
|
||||
const artworkId = card?.getAttribute('data-art-id');
|
||||
if (!artworkId || viewedArtworkIdsRef.current.has(artworkId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = artworkIndex.get(artworkId);
|
||||
if (!candidate?.art?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewedArtworkIdsRef.current.add(artworkId);
|
||||
observer.unobserve(entry.target);
|
||||
sendDiscoveryEvent(discoveryEndpoint, {
|
||||
event_type: 'view',
|
||||
artwork_id: Number(candidate.art.id),
|
||||
algo_version: candidate.art.recommendation_algo_version || algoVersion || undefined,
|
||||
meta: {
|
||||
gallery_type: galleryType,
|
||||
position: candidate.index + 1,
|
||||
source: candidate.art.recommendation_source || null,
|
||||
reason: candidate.art.recommendation_reason || null,
|
||||
score: candidate.art.recommendation_score ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
{ threshold: [0.65] },
|
||||
);
|
||||
|
||||
const cards = grid.querySelectorAll('[data-art-id]');
|
||||
cards.forEach((card) => observer.observe(card));
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
|
||||
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return undefined;
|
||||
|
||||
const handleClick = (event) => {
|
||||
const card = event.target.closest('[data-art-id]');
|
||||
if (!card) return;
|
||||
|
||||
const artworkId = card.getAttribute('data-art-id');
|
||||
if (!artworkId || clickedArtworkIdsRef.current.has(artworkId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artwork = artworks.find((item) => String(item.id) === artworkId);
|
||||
if (!artwork?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clickedArtworkIdsRef.current.add(artworkId);
|
||||
sendDiscoveryEvent(discoveryEndpoint, {
|
||||
event_type: 'click',
|
||||
artwork_id: Number(artwork.id),
|
||||
algo_version: artwork.recommendation_algo_version || algoVersion || undefined,
|
||||
meta: {
|
||||
gallery_type: galleryType,
|
||||
source: artwork.recommendation_source || null,
|
||||
reason: artwork.recommendation_reason || null,
|
||||
score: artwork.recommendation_score ?? null,
|
||||
target_url: artwork.url || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
grid.addEventListener('click', handleClick, true);
|
||||
return () => grid.removeEventListener('click', handleClick, true);
|
||||
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
|
||||
|
||||
// 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 = gridClassName || 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<section
|
||||
className="pb-10 pt-2 is-enhanced"
|
||||
data-nova-gallery
|
||||
data-gallery-type={galleryType}
|
||||
data-react-masonry-gallery
|
||||
data-artworks={JSON.stringify(artworks)}
|
||||
data-next-cursor={nextCursor ?? undefined}
|
||||
data-next-page-url={nextPageUrl ?? undefined}
|
||||
>
|
||||
{artworks.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
ref={gridRef}
|
||||
>
|
||||
<ArtworkGallery
|
||||
items={artworks}
|
||||
layout="masonry"
|
||||
className={gridClass}
|
||||
containerProps={{ 'data-gallery-grid': true }}
|
||||
resolveCardProps={getMasonryCardProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Infinite scroll sentinel – placed after the grid */}
|
||||
{!done && (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="h-px w-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div className="flex justify-center items-center gap-2 mt-8 py-4 text-white/30 text-sm">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12" cy="12" r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v8H4z"
|
||||
/>
|
||||
</svg>
|
||||
Loading more…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && artworks.length > 0 && (
|
||||
<p className="text-center text-xs text-white/20 mt-8 py-2">
|
||||
All caught up
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Empty state – gallery-type-specific messaging handled by caller */
|
||||
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p className="text-white/40 text-sm">No artworks found for this section yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MasonryGallery);
|
||||
Reference in New Issue
Block a user