X 일정 상세 페이지에 이미지 라이트박스 추가
- PC/모바일 XSection에 라이트박스 기능 추가 - 이미지 클릭 시 전체 화면으로 보기 - 좌우 화살표로 이미지 탐색 - 인디케이터로 현재 이미지 위치 표시 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f797736f8e
commit
d8055c00e5
2 changed files with 129 additions and 5 deletions
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue