diff --git a/src/pages/travel/MasonryGrid.css b/src/pages/travel/MasonryGrid.css new file mode 100644 index 0000000..2cf4641 --- /dev/null +++ b/src/pages/travel/MasonryGrid.css @@ -0,0 +1,138 @@ +/* ── MasonryGrid ── */ + +.masonry-grid { + column-count: 4; + column-gap: 8px; +} + +/* item */ +.masonry-item { + break-inside: avoid; + margin-bottom: 8px; + position: relative; + border-radius: 4px; + overflow: hidden; + cursor: zoom-in; + + /* scroll-reveal initial state */ + opacity: 0; + transform: translateY(20px); + transition: opacity 0.45s ease, transform 0.45s ease; +} + +.masonry-item--revealed { + opacity: 1; + transform: translateY(0); +} + +.masonry-item__img { + display: block; + width: 100%; + height: auto; + transition: filter 0.25s ease; +} + +.masonry-item:hover .masonry-item__img { + filter: brightness(1.08); +} + +/* hover overlay */ +.masonry-item__overlay { + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; + padding: 8px 10px; + background: linear-gradient(transparent 60%, rgba(15, 12, 9, 0.7)); + opacity: 0; + transition: opacity 0.25s ease; + pointer-events: none; +} + +.masonry-item:hover .masonry-item__overlay { + opacity: 1; +} + +.masonry-item__label { + font: 11px var(--tv-mono); + color: var(--tv-text); + letter-spacing: 0.04em; +} + +/* sentinel */ +.masonry-sentinel { + height: 1px; + column-span: all; +} + +/* loading dots */ +.masonry-loading { + column-span: all; + display: flex; + justify-content: center; + gap: 6px; + padding: 24px 0; +} + +.masonry-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--tv-muted); + animation: masonry-pulse 1.2s ease-in-out infinite; +} + +.masonry-dot:nth-child(2) { + animation-delay: 0.15s; +} + +.masonry-dot:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes masonry-pulse { + 0%, 80%, 100% { opacity: 0.25; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1); } +} + +/* end message */ +.masonry-end { + column-span: all; + text-align: center; + font: 11px var(--tv-mono); + letter-spacing: 0.12em; + color: var(--tv-dim); + padding: 32px 0 16px; + margin: 0; +} + +/* responsive */ +@media (max-width: 1024px) { + .masonry-grid { + column-count: 3; + } +} + +@media (max-width: 768px) { + .masonry-grid { + column-count: 2; + } +} + +/* reduced motion */ +@media (prefers-reduced-motion: reduce) { + .masonry-item { + opacity: 1; + transform: none; + transition: none; + } + + .masonry-item__img, + .masonry-item__overlay { + transition: none; + } + + .masonry-dot { + animation: none; + } +} diff --git a/src/pages/travel/MasonryGrid.jsx b/src/pages/travel/MasonryGrid.jsx new file mode 100644 index 0000000..6b1e39a --- /dev/null +++ b/src/pages/travel/MasonryGrid.jsx @@ -0,0 +1,117 @@ +import React, { useEffect, useRef, useCallback } from 'react'; +import './MasonryGrid.css'; + +/* ───────────────────────────────────────────── + Utility +───────────────────────────────────────────── */ +function getPhotoLabel(photo) { + if (photo.label) return photo.label; + if (photo.name) { + const base = photo.name.replace(/\.[^.]+$/, ''); + return base.replace(/[_-]/g, ' '); + } + return ''; +} + +/* ───────────────────────────────────────────── + MasonryGrid — CSS columns + infinite scroll +───────────────────────────────────────────── */ +export default function MasonryGrid({ + photos, + onSelectPhoto, + onLoadMore, + hasNext, + isLoadingMore, + regionAccent, +}) { + const sentinelRef = useRef(null); + const itemRefs = useRef([]); + + /* infinite scroll sentinel */ + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !hasNext) return; + + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isLoadingMore && onLoadMore) { + onLoadMore(); + } + }, + { rootMargin: '300px' }, + ); + io.observe(sentinel); + return () => io.disconnect(); + }, [hasNext, isLoadingMore, onLoadMore]); + + /* scroll-reveal */ + useEffect(() => { + const nodes = itemRefs.current.filter(Boolean); + if (!nodes.length) return; + + const io = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('masonry-item--revealed'); + io.unobserve(entry.target); + } + }); + }, + { rootMargin: '120px', threshold: 0.05 }, + ); + + nodes.forEach((n) => io.observe(n)); + return () => io.disconnect(); + }, [photos]); + + const setItemRef = useCallback((el, idx) => { + itemRefs.current[idx] = el; + }, []); + + return ( +
— {photos.length} frames developed —
+ )} +