feat(travel): HeroLightbox — shared element transition + 스와이프 탐색

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 01:22:49 +09:00
parent f9495f0c30
commit 085481e104
2 changed files with 547 additions and 0 deletions

View 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;
}
}

View 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>
);
}