updated gallery

This commit is contained in:
2026-03-17 18:34:26 +01:00
parent 7b37259a2c
commit 7da0fd39f7
52 changed files with 1216 additions and 870 deletions

View File

@@ -157,23 +157,3 @@
transform: none;
}
.nb-react-drag-zone {
position: absolute;
left: 48px;
right: 48px;
bottom: 0;
height: 12px;
z-index: 1;
cursor: grab;
-webkit-tap-highlight-color: transparent;
}
.nb-react-drag-zone:active {
cursor: grabbing;
}
@media (hover: none) and (pointer: coarse) {
.nb-react-drag-zone {
display: none;
}
}

View File

@@ -12,16 +12,18 @@ export default function CategoryPillCarousel({
}) {
const viewportRef = useRef(null);
const stripRef = useRef(null);
const dragZoneRef = 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);
@@ -157,13 +159,10 @@ export default function CategoryPillCarousel({
useEffect(() => {
const strip = stripRef.current;
const dragZone = dragZoneRef.current;
if (!strip || !dragZone) return;
if (!strip) return;
const onPointerDown = (event) => {
const isMouse = event.pointerType === 'mouse';
const fromDragZone = event.currentTarget === dragZone;
if (isMouse && !fromDragZone) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
@@ -172,16 +171,15 @@ export default function CategoryPillCarousel({
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);
if (strip.setPointerCapture) {
try { strip.setPointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
};
const onPointerMove = (event) => {
@@ -189,13 +187,24 @@ export default function CategoryPillCarousel({
if (!state.active || state.pointerId !== event.pointerId) return;
const dx = event.clientX - state.startX;
const threshold = state.pointerType === 'touch' ? 12 : 8;
const dy = event.clientY - state.startY;
const threshold = state.pointerType === 'touch'
? 12
: (state.startedOnLink ? 24 : 12);
if (!state.started) {
if (Math.abs(dx) <= threshold) {
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);
}
@@ -213,12 +222,14 @@ export default function CategoryPillCarousel({
suppressClickRef.current = state.started;
state.active = false;
state.started = false;
state.startedOnLink = false;
state.pointerId = null;
setDragging(false);
if (strip.releasePointerCapture) {
if (state.captured && strip.releasePointerCapture) {
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
state.captured = false;
};
const onClickCapture = (event) => {
@@ -234,22 +245,12 @@ export default function CategoryPillCarousel({
strip.addEventListener('pointercancel', onPointerUpOrCancel);
strip.addEventListener('click', onClickCapture, true);
dragZone.addEventListener('pointerdown', onPointerDown);
dragZone.addEventListener('pointermove', onPointerMove);
dragZone.addEventListener('pointerup', onPointerUpOrCancel);
dragZone.addEventListener('pointercancel', onPointerUpOrCancel);
return () => {
strip.removeEventListener('pointerdown', onPointerDown);
strip.removeEventListener('pointermove', onPointerMove);
strip.removeEventListener('pointerup', onPointerUpOrCancel);
strip.removeEventListener('pointercancel', onPointerUpOrCancel);
strip.removeEventListener('click', onClickCapture, true);
dragZone.removeEventListener('pointerdown', onPointerDown);
dragZone.removeEventListener('pointermove', onPointerMove);
dragZone.removeEventListener('pointerup', onPointerUpOrCancel);
dragZone.removeEventListener('pointercancel', onPointerUpOrCancel);
};
}, [moveTo, offset]);
@@ -307,12 +308,6 @@ export default function CategoryPillCarousel({
>
<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
ref={dragZoneRef}
className="nb-react-drag-zone"
aria-hidden="true"
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React, {
useState, useEffect, useRef, useCallback, memo,
} from 'react';
import ArtworkCard from './ArtworkCard';
import ArtworkGallery from '../artwork/ArtworkGallery';
import './MasonryGallery.css';
// ── Masonry helpers ────────────────────────────────────────────────────────
@@ -132,6 +132,8 @@ function mapRankApiArtwork(item) {
uname: item.author?.name ?? '',
username: item.author?.username ?? item.author?.name ?? '',
avatar_url: item.author?.avatar_url ?? null,
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 ?? '',
@@ -164,6 +166,36 @@ async function fetchRankApiArtworks(endpoint, rankType) {
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]',
};
}
// ── Main component ────────────────────────────────────────────────────────
/**
* MasonryGallery
@@ -309,7 +341,7 @@ function MasonryGallery({
// ── Render ─────────────────────────────────────────────────────────────
return (
<section
className="px-6 pb-10 pt-2 md:px-10 is-enhanced"
className="pb-10 pt-2 is-enhanced"
data-nova-gallery
data-gallery-type={galleryType}
data-react-masonry-gallery
@@ -321,17 +353,14 @@ function MasonryGallery({
<>
<div
ref={gridRef}
className={gridClass}
data-gallery-grid
>
{artworks.map((art, idx) => (
<ArtworkCard
key={`${art.id}-${idx}`}
art={art}
loading={idx < 8 ? 'eager' : 'lazy'}
fetchPriority={idx === 0 ? 'high' : undefined}
/>
))}
<ArtworkGallery
items={artworks}
layout="masonry"
className={gridClass}
containerProps={{ 'data-gallery-grid': true }}
resolveCardProps={getMasonryCardProps}
/>
</div>
{/* Infinite scroll sentinel placed after the grid */}