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 { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 Linkify from 'react-linkify';
|
||||||
import { getSchedule } from '../../../api/public/schedules';
|
import { getSchedule } from '../../../api/public/schedules';
|
||||||
import '../../../mobile.css';
|
import '../../../mobile.css';
|
||||||
|
|
@ -308,6 +308,39 @@ function XSection({ schedule }) {
|
||||||
const displayName = profile?.displayName || username;
|
const displayName = profile?.displayName || username;
|
||||||
const avatarUrl = profile?.avatarUrl;
|
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) => (
|
const linkDecorator = (href, text, key) => (
|
||||||
<a
|
<a
|
||||||
|
|
@ -337,6 +370,7 @@ function XSection({ schedule }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -390,7 +424,8 @@ function XSection({ schedule }) {
|
||||||
<img
|
<img
|
||||||
src={schedule.imageUrls[0]}
|
src={schedule.imageUrls[0]}
|
||||||
alt=""
|
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 ${
|
<div className={`grid gap-1 rounded-xl overflow-hidden border border-gray-100 ${
|
||||||
|
|
@ -401,9 +436,10 @@ function XSection({ schedule }) {
|
||||||
key={i}
|
key={i}
|
||||||
src={url}
|
src={url}
|
||||||
alt=""
|
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'
|
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
|
||||||
}`}
|
}`}
|
||||||
|
onClick={() => openLightbox(i)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -433,6 +469,72 @@ function XSection({ schedule }) {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</motion.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 { motion } from 'framer-motion';
|
||||||
import Linkify from 'react-linkify';
|
import Linkify from 'react-linkify';
|
||||||
import { decodeHtmlEntities } from './utils';
|
import { decodeHtmlEntities } from './utils';
|
||||||
|
import Lightbox from '../../../../components/common/Lightbox';
|
||||||
|
|
||||||
// datetime 포맷팅 (2026-01-18 19:00 → 오후 7:00 · 2026년 1월 18일)
|
// datetime 포맷팅 (2026-01-18 19:00 → 오후 7:00 · 2026년 1월 18일)
|
||||||
const formatXDateTime = (datetime) => {
|
const formatXDateTime = (datetime) => {
|
||||||
|
|
@ -27,6 +29,15 @@ function XSection({ schedule }) {
|
||||||
const displayName = profile?.displayName || username;
|
const displayName = profile?.displayName || username;
|
||||||
const avatarUrl = profile?.avatarUrl;
|
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) => (
|
const linkDecorator = (href, text, key) => (
|
||||||
<a
|
<a
|
||||||
|
|
@ -96,7 +107,8 @@ function XSection({ schedule }) {
|
||||||
<img
|
<img
|
||||||
src={schedule.imageUrls[0]}
|
src={schedule.imageUrls[0]}
|
||||||
alt=""
|
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 ${
|
<div className={`grid gap-1 rounded-2xl overflow-hidden border border-gray-100 ${
|
||||||
|
|
@ -109,9 +121,10 @@ function XSection({ schedule }) {
|
||||||
key={i}
|
key={i}
|
||||||
src={url}
|
src={url}
|
||||||
alt=""
|
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'
|
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
|
||||||
}`}
|
}`}
|
||||||
|
onClick={() => openLightbox(i)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -141,6 +154,15 @@ function XSection({ schedule }) {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 라이트박스 */}
|
||||||
|
<Lightbox
|
||||||
|
images={schedule.imageUrls || []}
|
||||||
|
currentIndex={lightboxIndex}
|
||||||
|
isOpen={lightboxOpen}
|
||||||
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue