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