- AdminSchedule, AdminScheduleForm 페이지 추가 - 커스텀 타임피커 구현 (오전/오후 지원, 드래그/휠 스크롤) - Lightbox 공통 컴포넌트 분리 (components/common/Lightbox.jsx) - 이미지 드래그 앤 드롭 정렬 기능 - 이미지 삭제 확인 다이얼로그 - 이미지 추가 버튼 첫번째 위치 고정 - 일정 이미지 순서 번호 표시 - react-ios-time-picker 라이브러리 CSS 제거
196 lines
8 KiB
JavaScript
196 lines
8 KiB
JavaScript
import { useState, useEffect, useCallback, memo } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
// 인디케이터 컴포넌트 - CSS transition 사용으로 GPU 가속
|
|
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex }) {
|
|
const translateX = -(currentIndex * 18) + 100 - 6;
|
|
|
|
return (
|
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: '200px' }}>
|
|
{/* 양옆 페이드 그라데이션 */}
|
|
<div className="absolute inset-0 pointer-events-none z-10" style={{
|
|
background: 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)'
|
|
}} />
|
|
{/* 슬라이딩 컨테이너 */}
|
|
<div
|
|
className="flex items-center gap-2 justify-center"
|
|
style={{
|
|
width: `${count * 18}px`,
|
|
transform: `translateX(${translateX}px)`,
|
|
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
|
}}
|
|
>
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<button
|
|
key={i}
|
|
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
|
i === currentIndex
|
|
? 'w-3 h-3 bg-white'
|
|
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
|
}`}
|
|
onClick={() => goToIndex(i)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// 라이트박스 공통 컴포넌트
|
|
function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
const [slideDirection, setSlideDirection] = useState(0);
|
|
|
|
// 이전/다음 네비게이션
|
|
const goToPrev = useCallback(() => {
|
|
if (images.length <= 1) return;
|
|
setImageLoaded(false);
|
|
setSlideDirection(-1);
|
|
onIndexChange((currentIndex - 1 + images.length) % images.length);
|
|
}, [images.length, currentIndex, onIndexChange]);
|
|
|
|
const goToNext = useCallback(() => {
|
|
if (images.length <= 1) return;
|
|
setImageLoaded(false);
|
|
setSlideDirection(1);
|
|
onIndexChange((currentIndex + 1) % images.length);
|
|
}, [images.length, currentIndex, onIndexChange]);
|
|
|
|
const goToIndex = useCallback((index) => {
|
|
if (index === currentIndex) return;
|
|
setImageLoaded(false);
|
|
setSlideDirection(index > currentIndex ? 1 : -1);
|
|
onIndexChange(index);
|
|
}, [currentIndex, onIndexChange]);
|
|
|
|
// 라이트박스 열릴 때 body 스크롤 숨기기
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
document.documentElement.style.overflow = 'hidden';
|
|
document.body.style.overflow = 'hidden';
|
|
} else {
|
|
document.documentElement.style.overflow = '';
|
|
document.body.style.overflow = '';
|
|
}
|
|
return () => {
|
|
document.documentElement.style.overflow = '';
|
|
document.body.style.overflow = '';
|
|
};
|
|
}, [isOpen]);
|
|
|
|
// 키보드 이벤트 핸들러
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e) => {
|
|
switch (e.key) {
|
|
case 'ArrowLeft':
|
|
goToPrev();
|
|
break;
|
|
case 'ArrowRight':
|
|
goToNext();
|
|
break;
|
|
case 'Escape':
|
|
onClose();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, goToPrev, goToNext, onClose]);
|
|
|
|
// 이미지가 바뀔 때 로딩 상태 리셋
|
|
useEffect(() => {
|
|
setImageLoaded(false);
|
|
}, [currentIndex]);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && images.length > 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
|
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
onClick={onClose}
|
|
>
|
|
{/* 내부 컨테이너 */}
|
|
<div className="min-w-[800px] min-h-[600px] w-full h-full relative flex items-center justify-center">
|
|
{/* 닫기 버튼 */}
|
|
<button
|
|
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
|
|
onClick={onClose}
|
|
>
|
|
<X size={32} />
|
|
</button>
|
|
|
|
{/* 이전 버튼 */}
|
|
{images.length > 1 && (
|
|
<button
|
|
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
goToPrev();
|
|
}}
|
|
>
|
|
<ChevronLeft size={48} />
|
|
</button>
|
|
)}
|
|
|
|
{/* 로딩 스피너 */}
|
|
{!imageLoaded && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 이미지 */}
|
|
<div className="flex flex-col items-center mx-24">
|
|
<motion.img
|
|
key={currentIndex}
|
|
src={images[currentIndex]}
|
|
alt={`이미지 ${currentIndex + 1}`}
|
|
className={`max-w-[90vw] max-h-[85vh] object-contain transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onLoad={() => setImageLoaded(true)}
|
|
initial={{ x: slideDirection * 100 }}
|
|
animate={{ x: 0 }}
|
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* 다음 버튼 */}
|
|
{images.length > 1 && (
|
|
<button
|
|
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
goToNext();
|
|
}}
|
|
>
|
|
<ChevronRight size={48} />
|
|
</button>
|
|
)}
|
|
|
|
{/* 인디케이터 */}
|
|
{images.length > 1 && (
|
|
<LightboxIndicator
|
|
count={images.length}
|
|
currentIndex={currentIndex}
|
|
goToIndex={goToIndex}
|
|
/>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
export default Lightbox;
|