feat(travel): MasonryGrid 컴포넌트 — CSS columns Masonry + 무한스크롤
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
138
src/pages/travel/MasonryGrid.css
Normal file
138
src/pages/travel/MasonryGrid.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
117
src/pages/travel/MasonryGrid.jsx
Normal file
117
src/pages/travel/MasonryGrid.jsx
Normal file
@@ -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 (
|
||||
<div className="masonry-grid" style={{ '--region-accent': regionAccent }}>
|
||||
{photos.map((photo, idx) => {
|
||||
const label = getPhotoLabel(photo);
|
||||
return (
|
||||
<div
|
||||
key={photo.id || photo.src || idx}
|
||||
className="masonry-item"
|
||||
ref={(el) => setItemRef(el, idx)}
|
||||
onClick={() => onSelectPhoto && onSelectPhoto(photo, idx)}
|
||||
>
|
||||
<img
|
||||
className="masonry-item__img"
|
||||
src={photo.thumb || photo.src}
|
||||
alt={label}
|
||||
loading={idx < 8 ? 'eager' : 'lazy'}
|
||||
draggable={false}
|
||||
/>
|
||||
{label && (
|
||||
<div className="masonry-item__overlay">
|
||||
<span className="masonry-item__label">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* sentinel for infinite scroll */}
|
||||
{hasNext && <div ref={sentinelRef} className="masonry-sentinel" />}
|
||||
|
||||
{/* loading indicator */}
|
||||
{isLoadingMore && (
|
||||
<div className="masonry-loading">
|
||||
<span className="masonry-dot" />
|
||||
<span className="masonry-dot" />
|
||||
<span className="masonry-dot" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* end message */}
|
||||
{!hasNext && photos.length > 0 && (
|
||||
<p className="masonry-end">— {photos.length} frames developed —</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user