X 일정 상세 페이지에 이미지 라이트박스 추가

- PC/모바일 XSection에 라이트박스 기능 추가
- 이미지 클릭 시 전체 화면으로 보기
- 좌우 화살표로 이미지 탐색
- 인디케이터로 현재 이미지 위치 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 11:59:23 +09:00
parent f797736f8e
commit d8055c00e5
2 changed files with 129 additions and 5 deletions

View file

@ -2,7 +2,7 @@ import { useParams, Link } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Clock, ChevronLeft, Check, Link2, MapPin, Navigation, ExternalLink } from 'lucide-react';
import { Calendar, Clock, ChevronLeft, Check, Link2, MapPin, Navigation, ExternalLink, X, ChevronRight } from 'lucide-react';
import Linkify from 'react-linkify';
import { getSchedule } from '../../../api/public/schedules';
import '../../../mobile.css';
@ -308,6 +308,39 @@ function XSection({ schedule }) {
const displayName = profile?.displayName || username;
const avatarUrl = profile?.avatarUrl;
//
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const openLightbox = (index) => {
setLightboxIndex(index);
setLightboxOpen(true);
};
const goToPrev = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex - 1 + schedule.imageUrls.length) % schedule.imageUrls.length);
}
};
const goToNext = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex + 1) % schedule.imageUrls.length);
}
};
// body
useEffect(() => {
if (lightboxOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [lightboxOpen]);
// ( )
const linkDecorator = (href, text, key) => (
<a
@ -337,6 +370,7 @@ function XSection({ schedule }) {
};
return (
<>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
@ -390,7 +424,8 @@ function XSection({ schedule }) {
<img
src={schedule.imageUrls[0]}
alt=""
className="w-full rounded-xl border border-gray-100"
className="w-full rounded-xl border border-gray-100 cursor-pointer active:opacity-80 transition-opacity"
onClick={() => openLightbox(0)}
/>
) : (
<div className={`grid gap-1 rounded-xl overflow-hidden border border-gray-100 ${
@ -401,9 +436,10 @@ function XSection({ schedule }) {
key={i}
src={url}
alt=""
className={`w-full object-cover ${
className={`w-full object-cover cursor-pointer active:opacity-80 transition-opacity ${
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
}`}
onClick={() => openLightbox(i)}
/>
))}
</div>
@ -433,6 +469,72 @@ function XSection({ schedule }) {
</a>
</div>
</motion.div>
{/* 모바일 라이트박스 */}
<AnimatePresence>
{lightboxOpen && schedule.imageUrls?.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-50 flex items-center justify-center"
onClick={() => setLightboxOpen(false)}
>
{/* 닫기 버튼 */}
<button
className="absolute top-4 right-4 p-2 text-white/70 z-10"
onClick={() => setLightboxOpen(false)}
>
<X size={28} />
</button>
{/* 이미지 */}
<motion.img
key={lightboxIndex}
src={schedule.imageUrls[lightboxIndex]}
alt=""
className="max-w-full max-h-full object-contain"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
onClick={(e) => e.stopPropagation()}
/>
{/* 이전/다음 버튼 */}
{schedule.imageUrls.length > 1 && (
<>
<button
className="absolute left-2 top-1/2 -translate-y-1/2 p-2 text-white/70"
onClick={(e) => { e.stopPropagation(); goToPrev(); }}
>
<ChevronLeft size={32} />
</button>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-white/70"
onClick={(e) => { e.stopPropagation(); goToNext(); }}
>
<ChevronRight size={32} />
</button>
</>
)}
{/* 인디케이터 */}
{schedule.imageUrls.length > 1 && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-2">
{schedule.imageUrls.map((_, i) => (
<button
key={i}
className={`w-2 h-2 rounded-full transition-colors ${
i === lightboxIndex ? 'bg-white' : 'bg-white/40'
}`}
onClick={(e) => { e.stopPropagation(); setLightboxIndex(i); }}
/>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</>
);
}

View file

@ -1,6 +1,8 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import Linkify from 'react-linkify';
import { decodeHtmlEntities } from './utils';
import Lightbox from '../../../../components/common/Lightbox';
// datetime (2026-01-18 19:00 7:00 · 2026 1 18)
const formatXDateTime = (datetime) => {
@ -27,6 +29,15 @@ function XSection({ schedule }) {
const displayName = profile?.displayName || username;
const avatarUrl = profile?.avatarUrl;
//
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const openLightbox = (index) => {
setLightboxIndex(index);
setLightboxOpen(true);
};
// ( )
const linkDecorator = (href, text, key) => (
<a
@ -96,7 +107,8 @@ function XSection({ schedule }) {
<img
src={schedule.imageUrls[0]}
alt=""
className="w-full rounded-2xl border border-gray-100"
className="w-full rounded-2xl border border-gray-100 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => openLightbox(0)}
/>
) : (
<div className={`grid gap-1 rounded-2xl overflow-hidden border border-gray-100 ${
@ -109,9 +121,10 @@ function XSection({ schedule }) {
key={i}
src={url}
alt=""
className={`w-full object-cover ${
className={`w-full object-cover cursor-pointer hover:opacity-90 transition-opacity ${
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
}`}
onClick={() => openLightbox(i)}
/>
))}
</div>
@ -141,6 +154,15 @@ function XSection({ schedule }) {
</a>
</div>
</motion.div>
{/* 라이트박스 */}
<Lightbox
images={schedule.imageUrls || []}
currentIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
onIndexChange={setLightboxIndex}
/>
</div>
);
}