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