From 2a952f39ab3cea1e53888dff6456230af858fe8b Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 4 Jan 2026 20:50:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=ED=94=BC=EC=BB=A4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminSchedule, AdminScheduleForm 페이지 추가 - 커스텀 타임피커 구현 (오전/오후 지원, 드래그/휠 스크롤) - Lightbox 공통 컴포넌트 분리 (components/common/Lightbox.jsx) - 이미지 드래그 앤 드롭 정렬 기능 - 이미지 삭제 확인 다이얼로그 - 이미지 추가 버튼 첫번째 위치 고정 - 일정 이미지 순서 번호 표시 - react-ios-time-picker 라이브러리 CSS 제거 --- frontend/package-lock.json | 45 +- frontend/package.json | 1 + frontend/src/App.jsx | 5 + frontend/src/components/common/Lightbox.jsx | 196 +++ frontend/src/index.css | 9 + frontend/src/pages/pc/Schedule.jsx | 314 +++-- frontend/src/pages/pc/admin/AdminSchedule.jsx | 587 ++++++++ .../src/pages/pc/admin/AdminScheduleForm.jsx | 1191 +++++++++++++++++ 8 files changed, 2246 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/common/Lightbox.jsx create mode 100644 frontend/src/pages/pc/admin/AdminSchedule.jsx create mode 100644 frontend/src/pages/pc/admin/AdminScheduleForm.jsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dfd9f53..a59484e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "react": "^18.2.0", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-ios-time-picker": "^0.2.2", "react-photo-album": "^3.4.0", "react-router-dom": "^6.22.3", "react-window": "^2.2.3" @@ -1963,7 +1964,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2189,6 +2189,17 @@ "dev": true, "license": "MIT" }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2248,6 +2259,25 @@ "react": "^18.3.1" } }, + "node_modules/react-ios-time-picker": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/react-ios-time-picker/-/react-ios-time-picker-0.2.2.tgz", + "integrity": "sha512-bi+K23lK6Pf2xDXmhAlz+RJuy9/onWYi7Ye+ODVhIkis9AVFECOza2ckkZl/4vUypj2+TdTsHn+VZrTNdGIwDQ==", + "license": "MIT", + "dependencies": { + "react-portal": "^4.2.2" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-photo-album": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz", @@ -2269,6 +2299,19 @@ } } }, + "node_modules/react-portal": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/react-portal/-/react-portal-4.3.0.tgz", + "integrity": "sha512-qs/2uKq1ifB3J1+K8ExfgUvCDZqlqCkfOEhqTELEDTfosloKiuzOzc7hl7IQ/7nohiFZD41BUYU0boAsIsGYHw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.8" + }, + "peerDependencies": { + "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0", + "react-dom": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d430244..f7e6bbe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "react": "^18.2.0", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-ios-time-picker": "^0.2.2", "react-photo-album": "^3.4.0", "react-router-dom": "^6.22.3", "react-window": "^2.2.3" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 24c09c2..00aa258 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,8 @@ import AdminMemberEdit from './pages/pc/admin/AdminMemberEdit'; import AdminAlbums from './pages/pc/admin/AdminAlbums'; import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm'; import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos'; +import AdminSchedule from './pages/pc/admin/AdminSchedule'; +import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm'; // PC 레이아웃 import PCLayout from './components/pc/Layout'; @@ -35,6 +37,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> {/* 일반 페이지 (레이아웃 포함) */} + {/* 양옆 페이드 그라데이션 */} +
+ {/* 슬라이딩 컨테이너 */} +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+ ); +}); + +// 라이트박스 공통 컴포넌트 +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 ( + + {isOpen && images.length > 0 && ( + + {/* 내부 컨테이너 */} +
+ {/* 닫기 버튼 */} + + + {/* 이전 버튼 */} + {images.length > 1 && ( + + )} + + {/* 로딩 스피너 */} + {!imageLoaded && ( +
+
+
+ )} + + {/* 이미지 */} +
+ e.stopPropagation()} + onLoad={() => setImageLoaded(true)} + initial={{ x: slideDirection * 100 }} + animate={{ x: 0 }} + transition={{ duration: 0.25, ease: 'easeOut' }} + /> +
+ + {/* 다음 버튼 */} + {images.length > 1 && ( + + )} + + {/* 인디케이터 */} + {images.length > 1 && ( + + )} +
+
+ )} +
+ ); +} + +export default Lightbox; diff --git a/frontend/src/index.css b/frontend/src/index.css index 644efe5..8c7cc9d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -78,3 +78,12 @@ body { .lightbox-no-scrollbar::-webkit-scrollbar { display: none; } + +/* 스크롤바 숨기기 유틸리티 */ +.scrollbar-hide::-webkit-scrollbar { + display: none; +} +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/frontend/src/pages/pc/Schedule.jsx b/frontend/src/pages/pc/Schedule.jsx index a352cf8..ade4915 100644 --- a/frontend/src/pages/pc/Schedule.jsx +++ b/frontend/src/pages/pc/Schedule.jsx @@ -7,6 +7,8 @@ function Schedule() { const [currentDate, setCurrentDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(null); const [showYearMonthPicker, setShowYearMonthPicker] = useState(false); + const [viewMode, setViewMode] = useState('yearMonth'); // 'yearMonth' | 'months' + const [slideDirection, setSlideDirection] = useState(0); // -1: prev, 1: next const pickerRef = useRef(null); // 외부 클릭시 팝업 닫기 @@ -14,6 +16,7 @@ function Schedule() { const handleClickOutside = (event) => { if (pickerRef.current && !pickerRef.current.contains(event.target)) { setShowYearMonthPicker(false); + setViewMode('yearMonth'); } }; @@ -46,23 +49,31 @@ function Schedule() { }; const prevMonth = () => { + setSlideDirection(-1); setCurrentDate(new Date(year, month - 1, 1)); }; const nextMonth = () => { + setSlideDirection(1); setCurrentDate(new Date(year, month + 1, 1)); }; const selectDate = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - if (hasSchedule(day)) { - setSelectedDate(selectedDate === dateStr ? null : dateStr); - } + setSelectedDate(selectedDate === dateStr ? null : dateStr); }; - const selectYearMonth = (newYear, newMonth) => { - setCurrentDate(new Date(newYear, newMonth, 1)); + // 년도 선택 시 월 선택 모드로 전환 + const selectYear = (newYear) => { + setCurrentDate(new Date(newYear, month, 1)); + setViewMode('months'); + }; + + // 월 선택 시 적용 후 닫기 + const selectMonth = (newMonth) => { + setCurrentDate(new Date(year, newMonth, 1)); setShowYearMonthPicker(false); + setViewMode('yearMonth'); }; // 필터링된 스케줄 @@ -80,9 +91,23 @@ function Schedule() { }; }; - // 년도 범위 (현재 년도 기준 ±5년) - const currentYear = new Date().getFullYear(); - const yearRange = Array.from({ length: 11 }, (_, i) => currentYear - 5 + i); + // 년도 범위 (현재 년도 기준 10년 단위) + const startYear = Math.floor(year / 10) * 10 - 1; + const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i); + + // 현재 년도/월 확인 함수 + const isCurrentYear = (y) => new Date().getFullYear() === y; + const isCurrentMonth = (m) => { + const today = new Date(); + return today.getFullYear() === year && today.getMonth() === m; + }; + + // 월 배열 + const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; + + // 년도 범위 이동 + const prevYearRange = () => setCurrentDate(new Date(year - 10, month, 1)); + const nextYearRange = () => setCurrentDate(new Date(year + 10, month, 1)); return (
@@ -113,7 +138,7 @@ function Schedule() { animate={{ opacity: 1, x: 0 }} className="w-[400px] flex-shrink-0" > -
+
{/* 달력 헤더 */}
-
- - - {/* 년/월 선택 팝업 */} - - {showYearMonthPicker && ( - - {/* 년도 선택 */} -
-

년도

-
- {yearRange.map((y) => ( - - ))} -
-
- {/* 월 선택 */} -
-

-
- {Array.from({ length: 12 }, (_, i) => i).map((m) => ( - - ))} -
-
-
- )} -
-
+
- {/* 요일 헤더 */} -
- {days.map((day, i) => ( -
+ {showYearMonthPicker && ( + - {day} + {/* 헤더 - 년도 범위 이동 */} +
+ + + {viewMode === 'yearMonth' ? `${yearRange[0]} - ${yearRange[yearRange.length - 1]}` : `${year}년`} + + +
+ + + {viewMode === 'yearMonth' && ( + + {/* 년도 선택 */} +
년도
+
+ {yearRange.map((y) => ( + + ))} +
+ + {/* 월 선택 */} +
+
+ {monthNames.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === 'months' && ( + + {/* 월 선택 */} +
월 선택
+
+ {monthNames.map((m, i) => ( + + ))} +
+
+ )} +
+
+ )} + + + {/* 요일 헤더 + 날짜 그리드 (함께 슬라이드) */} + + + {/* 요일 헤더 */} +
+ {days.map((day, i) => ( +
+ {day} +
+ ))}
- ))} -
- {/* 날짜 그리드 */} -
- {/* 빈 칸 */} - {Array.from({ length: firstDay }).map((_, i) => ( -
- ))} + {/* 날짜 그리드 */} +
+ {/* 전달 날짜 */} + {Array.from({ length: firstDay }).map((_, i) => { + const prevMonthDays = getDaysInMonth(year, month - 1); + const day = prevMonthDays - firstDay + i + 1; + return ( +
+ {day} +
+ ); + })} - {/* 날짜 */} + {/* 현재 달 날짜 */} {Array.from({ length: daysInMonth }).map((_, i) => { const day = i + 1; const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; @@ -224,34 +325,45 @@ function Schedule() { ); })} -
+ + {/* 다음달 날짜 (마지막 주만 채우기) */} + {(() => { + const totalCells = firstDay + daysInMonth; + const remainder = totalCells % 7; + const nextDays = remainder === 0 ? 0 : 7 - remainder; + return Array.from({ length: nextDays }).map((_, i) => ( +
+ {i + 1} +
+ )); + })()} +
+ + {/* 범례 및 전체보기 */}
-
- - 일정 있음 +
+ + 일정 있음
+
+
+ + + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} +
+ + + + + 일정 관리 +
+ + {/* 타이틀 + 추가 버튼 */} +
+
+

일정 관리

+

fromis_9의 일정을 관리합니다

+
+ +
+ +
+ {/* 왼쪽: 달력 + 카테고리 필터 */} +
+ {/* 달력 (Schedule.jsx와 동일한 패턴) */} +
+ {/* 달력 헤더 */} +
+ + + +
+ + {/* 년/월 선택 팝업 (Schedule.jsx와 동일한 스타일) */} + + {showYearMonthPicker && ( + + {/* 헤더 - 년도 범위 이동 */} +
+ + + {viewMode === 'yearMonth' ? `${yearRange[0]} - ${yearRange[yearRange.length - 1]}` : `${year}년`} + + +
+ + + {viewMode === 'yearMonth' && ( + + {/* 년도 선택 */} +
년도
+
+ {yearRange.map((y) => ( + + ))} +
+ + {/* 월 선택 */} +
+
+ {monthNames.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === 'months' && ( + + {/* 월 선택 */} +
월 선택
+
+ {monthNames.map((m, i) => ( + + ))} +
+
+ )} +
+
+ )} +
+ + {/* 요일 헤더 + 날짜 그리드 */} + + + {/* 요일 헤더 */} +
+ {days.map((day, i) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {/* 전달 날짜 */} + {Array.from({ length: firstDay }).map((_, i) => { + const prevMonthDays = getDaysInMonth(year, month - 1); + const day = prevMonthDays - firstDay + i + 1; + return ( +
+ {day} +
+ ); + })} + + {/* 현재 달 날짜 */} + {Array.from({ length: daysInMonth }).map((_, i) => { + const day = i + 1; + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const isSelected = selectedDate === dateStr; + const hasEvent = hasSchedule(day); + const dayOfWeek = (firstDay + i) % 7; + const isToday = new Date().toDateString() === new Date(year, month, day).toDateString(); + + return ( + + ); + })} + + {/* 다음달 날짜 */} + {(() => { + const totalCells = firstDay + daysInMonth; + const remainder = totalCells % 7; + const nextDays = remainder === 0 ? 0 : 7 - remainder; + return Array.from({ length: nextDays }).map((_, i) => ( +
+ {i + 1} +
+ )); + })()} +
+
+
+ + {/* 범례 */} +
+ + 일정 있음 +
+
+ + {/* 카테고리 필터 */} +
+

카테고리

+
+ {categories.map(category => ( + + ))} +
+
+
+ + {/* 오른쪽: 일정 목록 */} +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-12 pr-4 py-3 bg-gray-50 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all" + /> +
+
+ + {/* 일정 목록 */} +
+
+
+

+ {selectedCategory === 'all' ? '전체 일정' : categories.find(c => c.id === selectedCategory)?.name} +

+ {filteredSchedules.length}개의 일정 +
+
+ + {loading ? ( +
+
+
+ ) : filteredSchedules.length === 0 ? ( +
+ +

등록된 일정이 없습니다

+
+ ) : ( +
+ {filteredSchedules.map((schedule, index) => ( + +
+ {/* 날짜 */} +
+
+ {new Date(schedule.date).getDate()} +
+
+ {new Date(schedule.date).toLocaleDateString('ko-KR', { weekday: 'short' })} +
+
+ + {/* 내용 */} +
+
+ + {categories.find(c => c.id === schedule.category)?.name} + + {schedule.time} +
+

{schedule.title}

+

{schedule.description}

+
+ + {/* 액션 버튼 */} +
+ + +
+
+
+ ))} +
+ )} +
+
+
+
+
+ ); +} + +export default AdminSchedule; diff --git a/frontend/src/pages/pc/admin/AdminScheduleForm.jsx b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx new file mode 100644 index 0000000..e8c9e08 --- /dev/null +++ b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx @@ -0,0 +1,1191 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate, Link, useParams } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + LogOut, Home, ChevronRight, Calendar, Save, X, Upload, Link as LinkIcon, + ChevronLeft, ChevronDown, Clock, Image, Users, Check, Plus +} from 'lucide-react'; +import Toast from '../../../components/Toast'; +import Lightbox from '../../../components/common/Lightbox'; +// 커스텀 데이트픽커 컴포넌트 (AdminMemberEdit.jsx에서 가져옴) +function CustomDatePicker({ value, onChange, placeholder = '날짜 선택' }) { + const [isOpen, setIsOpen] = useState(false); + const [viewMode, setViewMode] = useState('days'); + const [viewDate, setViewDate] = useState(() => { + if (value) return new Date(value); + return new Date(); + }); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setIsOpen(false); + setViewMode('days'); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const days = []; + for (let i = 0; i < firstDay; i++) { + days.push(null); + } + for (let i = 1; i <= daysInMonth; i++) { + days.push(i); + } + + const startYear = Math.floor(year / 10) * 10 - 1; + const years = Array.from({ length: 12 }, (_, i) => startYear + i); + + const prevMonth = () => setViewDate(new Date(year, month - 1, 1)); + const nextMonth = () => setViewDate(new Date(year, month + 1, 1)); + const prevYearRange = () => setViewDate(new Date(year - 10, month, 1)); + const nextYearRange = () => setViewDate(new Date(year + 10, month, 1)); + + const selectDate = (day) => { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + onChange(dateStr); + setIsOpen(false); + setViewMode('days'); + }; + + const selectYear = (y) => { + setViewDate(new Date(y, month, 1)); + setViewMode('months'); + }; + + const selectMonth = (m) => { + setViewDate(new Date(year, m, 1)); + setViewMode('days'); + }; + + const formatDisplayDate = (dateStr) => { + if (!dateStr) return ''; + const [y, m, d] = dateStr.split('-'); + return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`; + }; + + const isSelected = (day) => { + if (!value || !day) return false; + const [y, m, d] = value.split('-'); + return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day; + }; + + const isToday = (day) => { + if (!day) return false; + const today = new Date(); + return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day; + }; + + const isCurrentYear = (y) => new Date().getFullYear() === y; + const isCurrentMonth = (m) => { + const today = new Date(); + return today.getFullYear() === year && today.getMonth() === m; + }; + + const months = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; + + return ( +
+ + + + {isOpen && ( + +
+ + + +
+ + + {viewMode === 'years' && ( + +
년도
+
+ {years.map((y) => ( + + ))} +
+
+
+ {months.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === 'months' && ( + +
월 선택
+
+ {months.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === 'days' && ( + +
+ {['일', '월', '화', '수', '목', '금', '토'].map((d, i) => ( +
+ {d} +
+ ))} +
+
+ {days.map((day, i) => ( + + ))} +
+
+ )} +
+
+ )} +
+
+ ); +} + +// 숫자 피커 컬럼 컴포넌트 (Vue 컴포넌트를 React로 변환) +function NumberPicker({ items, value, onChange }) { + const ITEM_HEIGHT = 40; + const containerRef = useRef(null); + const [offset, setOffset] = useState(0); + const offsetRef = useRef(0); // 드래그용 ref + const touchStartY = useRef(0); + const startOffset = useRef(0); + const isScrolling = useRef(false); + + // offset 변경시 ref도 업데이트 + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + + // 초기 위치 설정 + useEffect(() => { + if (value !== null && value !== undefined) { + const index = items.indexOf(value); + if (index !== -1) { + const newOffset = -index * ITEM_HEIGHT; + setOffset(newOffset); + offsetRef.current = newOffset; + } + } + }, []); + + // 값 변경시 위치 업데이트 + useEffect(() => { + const index = items.indexOf(value); + if (index !== -1) { + const targetOffset = -index * ITEM_HEIGHT; + if (Math.abs(offset - targetOffset) > 1) { + setOffset(targetOffset); + offsetRef.current = targetOffset; + } + } + }, [value, items]); + + const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋 + + // 아이템이 중앙에 있는지 확인 + const isItemInCenter = (item) => { + const itemIndex = items.indexOf(item); + const itemPosition = -itemIndex * ITEM_HEIGHT; + const tolerance = ITEM_HEIGHT / 2; + return Math.abs(offset - itemPosition) < tolerance; + }; + + // 오프셋 업데이트 (경계 제한) + const updateOffset = (newOffset) => { + const maxOffset = 0; + const minOffset = -(items.length - 1) * ITEM_HEIGHT; + return Math.min(maxOffset, Math.max(minOffset, newOffset)); + }; + + // 중앙 아이템 업데이트 + const updateCenterItem = (currentOffset) => { + const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT); + if (centerIndex >= 0 && centerIndex < items.length) { + const centerItem = items[centerIndex]; + if (value !== centerItem) { + onChange(centerItem); + } + } + }; + + // 가장 가까운 아이템에 스냅 + const snapToClosestItem = (currentOffset) => { + const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT; + setOffset(targetOffset); + offsetRef.current = targetOffset; + updateCenterItem(targetOffset); + }; + + // 터치 시작 + const handleTouchStart = (e) => { + e.stopPropagation(); + touchStartY.current = e.touches[0].clientY; + startOffset.current = offsetRef.current; + }; + + // 터치 이동 + const handleTouchMove = (e) => { + e.stopPropagation(); + const touchY = e.touches[0].clientY; + const deltaY = touchY - touchStartY.current; + const newOffset = updateOffset(startOffset.current + deltaY); + setOffset(newOffset); + offsetRef.current = newOffset; + }; + + // 터치 종료 + const handleTouchEnd = (e) => { + e.stopPropagation(); + snapToClosestItem(offsetRef.current); + }; + + // 마우스 휠 - 바깥 스크롤 방지 + const handleWheel = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isScrolling.current) return; + isScrolling.current = true; + + const newOffset = updateOffset(offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT); + setOffset(newOffset); + offsetRef.current = newOffset; + snapToClosestItem(newOffset); + + setTimeout(() => { + isScrolling.current = false; + }, 50); + }; + + // 마우스 드래그 + const handleMouseDown = (e) => { + e.preventDefault(); + e.stopPropagation(); + touchStartY.current = e.clientY; + startOffset.current = offsetRef.current; + + const handleMouseMove = (moveEvent) => { + moveEvent.preventDefault(); + const deltaY = moveEvent.clientY - touchStartY.current; + const newOffset = updateOffset(startOffset.current + deltaY); + setOffset(newOffset); + offsetRef.current = newOffset; + }; + + const handleMouseUp = () => { + snapToClosestItem(offsetRef.current); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + // wheel 이벤트 passive false로 등록 + useEffect(() => { + const container = containerRef.current; + if (container) { + container.addEventListener('wheel', handleWheel, { passive: false }); + return () => container.removeEventListener('wheel', handleWheel); + } + }, []); + + return ( +
+ {/* 중앙 선택 영역 */} +
+ + {/* 피커 내부 */} +
+ {items.map((item) => ( +
+ {item} +
+ ))} +
+
+ ); +} + +// 커스텀 시간 피커 (Vue 컴포넌트 기반) +function CustomTimePicker({ value, onChange, placeholder = '시간 선택' }) { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + // 현재 값 파싱 + const parseValue = () => { + if (!value) return { hour: '12', minute: '00', period: '오후' }; + const [h, m] = value.split(':'); + const hour = parseInt(h); + const isPM = hour >= 12; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + return { + hour: String(hour12).padStart(2, '0'), + minute: m, + period: isPM ? '오후' : '오전' + }; + }; + + const parsed = parseValue(); + const [selectedHour, setSelectedHour] = useState(parsed.hour); + const [selectedMinute, setSelectedMinute] = useState(parsed.minute); + const [selectedPeriod, setSelectedPeriod] = useState(parsed.period); + + // 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // 피커 열릴 때 현재 값으로 초기화 + useEffect(() => { + if (isOpen) { + const parsed = parseValue(); + setSelectedHour(parsed.hour); + setSelectedMinute(parsed.minute); + setSelectedPeriod(parsed.period); + } + }, [isOpen, value]); + + // 시간 확정 + const handleSave = () => { + let hour = parseInt(selectedHour); + if (selectedPeriod === '오후' && hour !== 12) hour += 12; + if (selectedPeriod === '오전' && hour === 12) hour = 0; + const timeStr = `${String(hour).padStart(2, '0')}:${selectedMinute}`; + onChange(timeStr); + setIsOpen(false); + }; + + // 취소 + const handleCancel = () => { + setIsOpen(false); + }; + + // 초기화 + const handleClear = () => { + onChange(''); + setIsOpen(false); + }; + + // 표시용 포맷 + const displayValue = () => { + if (!value) return placeholder; + const [h, m] = value.split(':'); + const hour = parseInt(h); + const isPM = hour >= 12; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + return `${isPM ? '오후' : '오전'} ${hour12}:${m}`; + }; + + // 피커 아이템 데이터 + const periods = ['오전', '오후']; + const hours = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']; + const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')); + + return ( +
+ + + + {isOpen && ( + + {/* 피커 영역 */} +
+ {/* 오전/오후 (맨 앞) */} + + + {/* 시간 */} + + + : + + {/* 분 */} + +
+ + {/* 푸터 버튼 */} +
+ +
+ + +
+
+
+ )} +
+
+ ); +} + + +function AdminScheduleForm() { + const navigate = useNavigate(); + const { id } = useParams(); + const isEditMode = !!id; + + const [user, setUser] = useState(null); + const [toast, setToast] = useState(null); + const [loading, setLoading] = useState(false); + const [members, setMembers] = useState([]); + + // 폼 데이터 (날짜/시간 범위 지원) + const [formData, setFormData] = useState({ + title: '', + startDate: '', + endDate: '', + startTime: '', + endTime: '', + isRange: false, // 범위 설정 여부 + category: 'broadcast', + description: '', + url: '', + members: [], + images: [] + }); + + // 이미지 미리보기 + const [imagePreviews, setImagePreviews] = useState([]); + + // 라이트박스 상태 + const [lightboxOpen, setLightboxOpen] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(0); + + // 삭제 다이얼로그 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTargetIndex, setDeleteTargetIndex] = useState(null); + + // 카테고리 목록 + const categories = [ + { id: 'broadcast', name: '방송', color: 'blue' }, + { id: 'event', name: '이벤트', color: 'green' }, + { id: 'release', name: '발매', color: 'purple' }, + { id: 'concert', name: '콘서트', color: 'red' }, + { id: 'fansign', name: '팬사인회', color: 'pink' }, + ]; + + + // 카테고리 색상 + const getCategoryColor = (categoryId) => { + const colors = { + broadcast: 'bg-blue-500', + event: 'bg-green-500', + release: 'bg-purple-500', + concert: 'bg-red-500', + fansign: 'bg-pink-500', + }; + return colors[categoryId] || 'bg-gray-500'; + }; + + // Toast 자동 숨김 + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + useEffect(() => { + const token = localStorage.getItem('adminToken'); + const userData = localStorage.getItem('adminUser'); + + if (!token || !userData) { + navigate('/admin'); + return; + } + + setUser(JSON.parse(userData)); + fetchMembers(); + }, [navigate]); + + const fetchMembers = async () => { + try { + const res = await fetch('/api/members'); + const data = await res.json(); + setMembers(data.filter(m => !m.is_former)); + } catch (error) { + console.error('멤버 로드 오류:', error); + } + }; + + const handleLogout = () => { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + navigate('/admin'); + }; + + // 멤버 토글 + const toggleMember = (memberId) => { + const newMembers = formData.members.includes(memberId) + ? formData.members.filter(id => id !== memberId) + : [...formData.members, memberId]; + setFormData({ ...formData, members: newMembers }); + }; + + // 전체 선택/해제 + const toggleAllMembers = () => { + if (formData.members.length === members.length) { + setFormData({ ...formData, members: [] }); + } else { + setFormData({ ...formData, members: members.map(m => m.id) }); + } + }; + + // 다중 이미지 업로드 + const handleImagesUpload = (e) => { + const files = Array.from(e.target.files); + const newImages = [...formData.images, ...files]; + setFormData({ ...formData, images: newImages }); + + files.forEach(file => { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreviews(prev => [...prev, reader.result]); + }; + reader.readAsDataURL(file); + }); + }; + + // 이미지 삭제 다이얼로그 열기 + const openDeleteDialog = (index) => { + setDeleteTargetIndex(index); + setDeleteDialogOpen(true); + }; + + // 이미지 삭제 확인 + const confirmDeleteImage = () => { + if (deleteTargetIndex !== null) { + const newImages = formData.images.filter((_, i) => i !== deleteTargetIndex); + const newPreviews = imagePreviews.filter((_, i) => i !== deleteTargetIndex); + setFormData({ ...formData, images: newImages }); + setImagePreviews(newPreviews); + } + setDeleteDialogOpen(false); + setDeleteTargetIndex(null); + }; + + // 라이트박스 열기 + const openLightbox = (index) => { + setLightboxIndex(index); + setLightboxOpen(true); + }; + + // 드래그 앤 드롭 상태 + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + // 드래그 시작 + const handleDragStart = (e, index) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + // 드래그 이미지 설정 + e.dataTransfer.setData('text/plain', index); + }; + + // 드래그 오버 + const handleDragOver = (e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragOverIndex !== index) { + setDragOverIndex(index); + } + }; + + // 드래그 종료 + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + + // 드롭 - 이미지 순서 변경 + const handleDrop = (e, dropIndex) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === dropIndex) { + handleDragEnd(); + return; + } + + // 새 배열 생성 + const newPreviews = [...imagePreviews]; + const newImages = [...formData.images]; + + // 드래그된 아이템 제거 후 새 위치에 삽입 + const [movedPreview] = newPreviews.splice(draggedIndex, 1); + const [movedImage] = newImages.splice(draggedIndex, 1); + + newPreviews.splice(dropIndex, 0, movedPreview); + newImages.splice(dropIndex, 0, movedImage); + + setImagePreviews(newPreviews); + setFormData({ ...formData, images: newImages }); + handleDragEnd(); + }; + + + // 폼 제출 (UI만) + const handleSubmit = (e) => { + e.preventDefault(); + setToast({ type: 'success', message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.' }); + setTimeout(() => navigate('/admin/schedule'), 1000); + }; + + return ( +
+ setToast(null)} /> + + {/* 삭제 확인 다이얼로그 */} + + {deleteDialogOpen && ( + setDeleteDialogOpen(false)} + > + e.stopPropagation()} + > +

이미지 삭제

+

이 이미지를 삭제하시겠습니까?

+
+ + +
+
+
+ )} +
+ + {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */} + setLightboxOpen(false)} + onIndexChange={setLightboxIndex} + /> + + {/* 헤더 */} +
+
+
+ + fromis_9 + + + Admin + +
+
+ + 안녕하세요, {user?.username}님 + + +
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} +
+ + + + + 일정 관리 + + {isEditMode ? '일정 수정' : '일정 추가'} +
+ + {/* 타이틀 */} +
+

{isEditMode ? '일정 수정' : '일정 추가'}

+

새로운 일정을 등록합니다

+
+ + {/* 폼 */} +
+ {/* 기본 정보 카드 */} +
+

기본 정보

+ +
+ {/* 제목 */} +
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="일정 제목을 입력하세요" + className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + required + /> +
+ + {/* 범위 설정 토글 */} +
+ + 기간 설정 (시작~종료) +
+ + {/* 날짜 + 시간 */} + {formData.isRange ? ( + // 범위 설정 모드 +
+ {/* 시작 */} +
+
+ + setFormData({ ...formData, startDate: date })} + placeholder="시작 날짜 선택" + /> +
+
+ + setFormData({ ...formData, startTime: time })} + placeholder="시작 시간 선택" + /> +
+
+ {/* 종료 */} +
+
+ + setFormData({ ...formData, endDate: date })} + placeholder="종료 날짜 선택" + /> +
+
+ + setFormData({ ...formData, endTime: time })} + placeholder="종료 시간 선택" + /> +
+
+
+ ) : ( + // 단일 날짜 모드 +
+
+ + setFormData({ ...formData, startDate: date })} + /> +
+
+ + setFormData({ ...formData, startTime: time })} + /> +
+
+ )} + + {/* 카테고리 */} +
+ +
+ {categories.map(category => ( + + ))} +
+
+ + {/* 설명 */} +
+ +