diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f50f5bb..fb87a6c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import PCSchedule from './pages/pc/Schedule'; import AdminLogin from './pages/pc/admin/AdminLogin'; import AdminDashboard from './pages/pc/admin/AdminDashboard'; import AdminAlbums from './pages/pc/admin/AdminAlbums'; +import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm'; // PC 레이아웃 import PCLayout from './components/pc/Layout'; @@ -26,6 +27,8 @@ function App() { } /> } /> } /> + } /> + } /> {/* 일반 페이지 (레이아웃 포함) */} { + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + + + {isOpen && ( + + {options.map((option) => ( + + ))} + + )} + +
+ ); +} + +// 커스텀 데이트픽커 컴포넌트 +function CustomDatePicker({ value, onChange }) { + const [isOpen, setIsOpen] = useState(false); + const [viewMode, setViewMode] = useState('days'); // 'days' | 'months' | 'years' + 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); + } + + // 년도 범위 (현재 년도 기준 -10 ~ +10) + 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) => { + return 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) => ( + + ))} +
+
+ )} +
+
+ )} +
+
+ ); +} + +function AdminAlbumForm() { + const navigate = useNavigate(); + const { id } = useParams(); + const isEditMode = !!id; + const coverInputRef = useRef(null); + + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [coverPreview, setCoverPreview] = useState(null); + const [coverFile, setCoverFile] = useState(null); + + const [formData, setFormData] = useState({ + title: '', + album_type: '', + album_type_short: '', + release_date: '', + cover_url: '', + folder_name: '', + description: '', + }); + + const [tracks, setTracks] = useState([]); + + useEffect(() => { + const token = localStorage.getItem('adminToken'); + const userData = localStorage.getItem('adminUser'); + + if (!token || !userData) { + navigate('/admin'); + return; + } + + setUser(JSON.parse(userData)); + + if (isEditMode) { + setLoading(true); + fetch(`/api/albums/${id}`) + .then(res => res.json()) + .then(data => { + setFormData({ + title: data.title || '', + album_type: data.album_type || '', + album_type_short: data.album_type_short || '', + release_date: data.release_date ? data.release_date.split('T')[0] : '', + cover_url: data.cover_url || '', + folder_name: data.folder_name || '', + description: data.description || '', + }); + if (data.cover_url) { + setCoverPreview(data.cover_url); + } + setTracks(data.tracks || []); + setLoading(false); + }) + .catch(error => { + console.error('앨범 로드 오류:', error); + setLoading(false); + }); + } + }, [id, isEditMode, navigate]); + + const handleLogout = () => { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + navigate('/admin'); + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleCoverChange = (e) => { + const file = e.target.files[0]; + if (file) { + setCoverFile(file); + const reader = new FileReader(); + reader.onloadend = () => { + setCoverPreview(reader.result); + }; + reader.readAsDataURL(file); + } + }; + + const addTrack = () => { + setTracks(prev => [...prev, { + track_number: prev.length + 1, + title: '', + is_title_track: false, + duration: '', + }]); + }; + + const removeTrack = (index) => { + setTracks(prev => prev.filter((_, i) => i !== index).map((track, i) => ({ + ...track, + track_number: i + 1 + }))); + }; + + const updateTrack = (index, field, value) => { + setTracks(prev => prev.map((track, i) => + i === index ? { ...track, [field]: value } : track + )); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + + try { + const token = localStorage.getItem('adminToken'); + const url = isEditMode ? `/api/admin/albums/${id}` : '/api/admin/albums'; + const method = isEditMode ? 'PUT' : 'POST'; + + const submitData = new FormData(); + submitData.append('data', JSON.stringify({ ...formData, tracks })); + if (coverFile) { + submitData.append('cover', coverFile); + } + + const response = await fetch(url, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: submitData, + }); + + if (!response.ok) { + throw new Error('저장 실패'); + } + + navigate('/admin/albums'); + } catch (error) { + console.error('저장 오류:', error); + alert('저장 중 오류가 발생했습니다.'); + } finally { + setSaving(false); + } + }; + + const albumTypes = ['정규', '미니', '싱글']; + + const pageVariants = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 } + }; + + return ( + + {/* 헤더 */} +
+
+
+ + fromis_9 + + + Admin + +
+
+ + 안녕하세요, {user?.username}님 + + +
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} + + + + + + + 앨범 관리 + + + {isEditMode ? '앨범 수정' : '새 앨범 추가'} + + + {/* 타이틀 */} + +

+ {isEditMode ? '앨범 수정' : '새 앨범 추가'} +

+

앨범 정보와 트랙을 입력하세요

+
+ + {loading ? ( +
+
+
+ ) : ( +
+ {/* 앨범 기본 정보 */} + +

앨범 정보

+ +
+ {/* 커버 이미지 */} +
+ +
+
coverInputRef.current?.click()} + className="w-40 h-40 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors overflow-hidden" + > + {coverPreview ? ( + 커버 미리보기 + ) : ( +
+ +

클릭하여 업로드

+
+ )} +
+ +
+

권장 크기: 1000x1000px

+

지원 형식: JPG, PNG, WebP

+ {coverPreview && ( + + )} +
+
+
+ + {/* 앨범명 */} +
+ + +
+ + {/* 폴더명 */} +
+ +
+ fromis-9/album/ + +
+

영문 소문자, 숫자, 하이픈만 사용

+
+ + {/* 앨범 타입 - 커스텀 드롭다운 */} +
+ + setFormData(prev => ({ ...prev, album_type: val }))} + options={albumTypes} + placeholder="타입 선택" + /> +
+ + {/* 앨범 타입 약어 */} +
+ + +
+ + {/* 발매일 - 커스텀 데이트픽커 */} +
+ + setFormData(prev => ({ ...prev, release_date: val }))} + /> +
+ + {/* 설명 */} +
+ +