feat(travel): HeroLightbox — shared element transition + 스와이프 탐색
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
279
src/pages/travel/HeroLightbox.css
Normal file
279
src/pages/travel/HeroLightbox.css
Normal file
@@ -0,0 +1,279 @@
|
||||
/* ═══════════════════════════════════════════
|
||||
HeroLightbox — fullscreen photo viewer
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* ── Root overlay ── */
|
||||
.hero-lb {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
.hero-lb--enter { opacity: 0; }
|
||||
.hero-lb--open { opacity: 1; }
|
||||
.hero-lb--exit { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* ── Backdrop ── */
|
||||
.hero-lb__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
transition: background 0.35s ease;
|
||||
}
|
||||
.hero-lb--enter .hero-lb__backdrop { background: rgba(0, 0, 0, 0); }
|
||||
|
||||
/* ── Inner container ── */
|
||||
.hero-lb__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Top bar ── */
|
||||
.hero-lb__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 4px 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-lb__counter {
|
||||
font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace);
|
||||
font-size: 0.85rem;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.hero-lb__counter-cur {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-lb__close {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-lb__close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* ── Stage (photo + arrows) ── */
|
||||
.hero-lb__stage {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Photo ── */
|
||||
.hero-lb__photo {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 200px);
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
/* ── Slide animations ── */
|
||||
.hero-lb__slide--next {
|
||||
animation: hero-slide-right 280ms cubic-bezier(0.22, 0.68, 0.36, 1) both;
|
||||
}
|
||||
.hero-lb__slide--prev {
|
||||
animation: hero-slide-left 280ms cubic-bezier(0.22, 0.68, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes hero-slide-right {
|
||||
from { opacity: 0; transform: translateX(24px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes hero-slide-left {
|
||||
from { opacity: 0; transform: translateX(-24px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ── Arrow buttons ── */
|
||||
.hero-lb__arrow {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
}
|
||||
.hero-lb__arrow:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.hero-lb__arrow:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.hero-lb__arrow--loading {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.hero-lb__arrow--loading:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Spinner ── */
|
||||
.hero-lb__spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: var(--tv-text, #e8ddd0);
|
||||
border-radius: 50%;
|
||||
animation: hero-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes hero-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Meta ── */
|
||||
.hero-lb__meta {
|
||||
padding: 8px 0 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.hero-lb__meta-album {
|
||||
font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif);
|
||||
font-style: italic;
|
||||
}
|
||||
.hero-lb__meta-file {
|
||||
font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* ── Thumbnail strip ── */
|
||||
.hero-lb__strip {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
padding: 8px 0 4px;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.hero-lb__strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-lb__thumb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, opacity 0.2s;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.hero-lb__thumb:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.hero-lb__thumb--active {
|
||||
border-color: #f5e6c8;
|
||||
opacity: 1;
|
||||
}
|
||||
.hero-lb__thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Mobile (<=768px)
|
||||
═══════════════════════════════════════════ */
|
||||
@media (max-width: 768px) {
|
||||
.hero-lb__inner {
|
||||
max-width: 100vw;
|
||||
padding: 12px 12px;
|
||||
}
|
||||
|
||||
.hero-lb__arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-lb__thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.hero-lb__photo {
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.hero-lb__meta {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Reduced motion
|
||||
═══════════════════════════════════════════ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-lb,
|
||||
.hero-lb__backdrop,
|
||||
.hero-lb__close,
|
||||
.hero-lb__arrow,
|
||||
.hero-lb__thumb {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hero-lb__slide--next,
|
||||
.hero-lb__slide--prev {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.hero-lb__spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
268
src/pages/travel/HeroLightbox.jsx
Normal file
268
src/pages/travel/HeroLightbox.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { getRegionAccent } from './MiniMap';
|
||||
import './HeroLightbox.css';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Helpers
|
||||
───────────────────────────────────────────── */
|
||||
const STRIP_LIMIT = 36;
|
||||
const THUMB_SIZE = 52;
|
||||
const THUMB_SIZE_MOBILE = 44;
|
||||
const ANIM_MS = 350;
|
||||
|
||||
function getStripRange(total, active) {
|
||||
if (total <= STRIP_LIMIT) return [0, total];
|
||||
const half = Math.floor(STRIP_LIMIT / 2);
|
||||
let start = active - half;
|
||||
if (start < 0) start = 0;
|
||||
let end = start + STRIP_LIMIT;
|
||||
if (end > total) {
|
||||
end = total;
|
||||
start = Math.max(0, end - STRIP_LIMIT);
|
||||
}
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
const prefersReduced = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
HeroLightbox
|
||||
───────────────────────────────────────────── */
|
||||
export default function HeroLightbox({
|
||||
photos,
|
||||
selectedIndex,
|
||||
albumName,
|
||||
regionId,
|
||||
sourceRect,
|
||||
hasNext,
|
||||
loadingMore,
|
||||
onClose,
|
||||
onNavigate,
|
||||
onLoadMore,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [phase, setPhase] = useState('enter');
|
||||
const [slideDir, setSlideDir] = useState(null);
|
||||
const [slideToken, setSlideToken] = useState(0);
|
||||
const pendingAdvanceRef = useRef(false);
|
||||
const stripRef = useRef(null);
|
||||
const prevOverflowRef = useRef('');
|
||||
const accent = useMemo(() => getRegionAccent(regionId), [regionId]);
|
||||
const reduced = useMemo(() => prefersReduced(), []);
|
||||
const animMs = reduced ? 0 : ANIM_MS;
|
||||
|
||||
/* — Phase transitions — */
|
||||
useEffect(() => {
|
||||
// enter → open via double rAF
|
||||
let raf1, raf2;
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setPhase('open'));
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* — Body scroll lock — */
|
||||
useEffect(() => {
|
||||
prevOverflowRef.current = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflowRef.current;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* — Pending advance after load more — */
|
||||
useEffect(() => {
|
||||
if (pendingAdvanceRef.current && !loadingMore) {
|
||||
pendingAdvanceRef.current = false;
|
||||
goNext();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadingMore, photos.length]);
|
||||
|
||||
/* — Auto-center active thumb — */
|
||||
useEffect(() => {
|
||||
if (!stripRef.current) return;
|
||||
const thumbSize = isMobile ? THUMB_SIZE_MOBILE : THUMB_SIZE;
|
||||
const gap = 4;
|
||||
const stripW = stripRef.current.offsetWidth;
|
||||
const scrollTarget =
|
||||
selectedIndex * (thumbSize + gap) - stripW / 2 + thumbSize / 2;
|
||||
stripRef.current.scrollTo({ left: scrollTarget, behavior: reduced ? 'auto' : 'smooth' });
|
||||
}, [selectedIndex, isMobile, reduced]);
|
||||
|
||||
/* — Close handler — */
|
||||
const handleClose = useCallback(() => {
|
||||
setPhase('exit');
|
||||
setTimeout(onClose, animMs);
|
||||
}, [onClose, animMs]);
|
||||
|
||||
/* — Navigation — */
|
||||
const goPrev = useCallback(() => {
|
||||
if (selectedIndex <= 0) return;
|
||||
setSlideDir('prev');
|
||||
setSlideToken((t) => t + 1);
|
||||
onNavigate(selectedIndex - 1);
|
||||
}, [selectedIndex, onNavigate]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (selectedIndex >= photos.length - 1) {
|
||||
if (hasNext) {
|
||||
pendingAdvanceRef.current = true;
|
||||
onLoadMore?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSlideDir('next');
|
||||
setSlideToken((t) => t + 1);
|
||||
onNavigate(selectedIndex + 1);
|
||||
}, [selectedIndex, photos.length, hasNext, onNavigate, onLoadMore]);
|
||||
|
||||
/* — Keyboard — */
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
else if (e.key === 'ArrowLeft') goPrev();
|
||||
else if (e.key === 'ArrowRight') goNext();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [handleClose, goPrev, goNext]);
|
||||
|
||||
/* — Swipe — */
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: goNext,
|
||||
onSwipedRight: goPrev,
|
||||
onSwipedDown: (e) => {
|
||||
if (e.absY > 100) handleClose();
|
||||
},
|
||||
trackMouse: false,
|
||||
delta: 30,
|
||||
});
|
||||
|
||||
/* — Current photo — */
|
||||
const photo = photos[selectedIndex];
|
||||
if (!photo) return null;
|
||||
|
||||
const thumbSz = isMobile ? THUMB_SIZE_MOBILE : THUMB_SIZE;
|
||||
const [stripStart, stripEnd] = getStripRange(photos.length, selectedIndex);
|
||||
const stripPhotos = photos.slice(stripStart, stripEnd);
|
||||
|
||||
const slideClass =
|
||||
slideDir === 'next'
|
||||
? 'hero-lb__slide--next'
|
||||
: slideDir === 'prev'
|
||||
? 'hero-lb__slide--prev'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`hero-lb hero-lb--${phase}`}
|
||||
{...swipeHandlers}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo viewer"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="hero-lb__backdrop" onClick={handleClose} />
|
||||
|
||||
{/* Inner */}
|
||||
<div className="hero-lb__inner">
|
||||
{/* Top bar */}
|
||||
<div className="hero-lb__topbar">
|
||||
<span className="hero-lb__counter">
|
||||
<span className="hero-lb__counter-cur" style={{ color: accent }}>
|
||||
{selectedIndex + 1}
|
||||
</span>
|
||||
{' / '}
|
||||
{photos.length}
|
||||
</span>
|
||||
<button
|
||||
className="hero-lb__close"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main photo area */}
|
||||
<div className="hero-lb__stage">
|
||||
{/* Left arrow */}
|
||||
{!isMobile && selectedIndex > 0 && (
|
||||
<button className="hero-lb__arrow hero-lb__arrow--left" onClick={goPrev} aria-label="Previous">
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Photo */}
|
||||
<img
|
||||
key={slideToken}
|
||||
className={`hero-lb__photo ${slideClass}`}
|
||||
src={photo.url || photo.src}
|
||||
alt={photo.filename || photo.name || ''}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Right arrow */}
|
||||
{!isMobile && selectedIndex < photos.length - 1 && (
|
||||
<button className="hero-lb__arrow hero-lb__arrow--right" onClick={goNext} aria-label="Next">
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Loading spinner for load-more */}
|
||||
{!isMobile && loadingMore && selectedIndex >= photos.length - 1 && (
|
||||
<button className="hero-lb__arrow hero-lb__arrow--right hero-lb__arrow--loading" disabled aria-label="Loading">
|
||||
<span className="hero-lb__spinner" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="hero-lb__meta">
|
||||
<span className="hero-lb__meta-album">{albumName}</span>
|
||||
{' · '}
|
||||
<span className="hero-lb__meta-file">{photo.filename || photo.name || ''}</span>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
<div className="hero-lb__strip" ref={stripRef}>
|
||||
{stripPhotos.map((p, i) => {
|
||||
const realIdx = stripStart + i;
|
||||
const isActive = realIdx === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={p.id || realIdx}
|
||||
className={`hero-lb__thumb${isActive ? ' hero-lb__thumb--active' : ''}`}
|
||||
style={{
|
||||
width: thumbSz,
|
||||
height: thumbSz,
|
||||
borderColor: isActive ? '#f5e6c8' : 'transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
setSlideDir(realIdx > selectedIndex ? 'next' : 'prev');
|
||||
setSlideToken((t) => t + 1);
|
||||
onNavigate(realIdx);
|
||||
}}
|
||||
aria-label={`Photo ${realIdx + 1}`}
|
||||
>
|
||||
<img
|
||||
src={p.thumbUrl || p.thumb || p.url || p.src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user