feat: 앨범 추가/수정 폼 UI 개선
- 커스텀 드롭다운 (앨범 타입: 정규/미니/싱글) - 커스텀 데이트픽커 (년도/월 선택 그리드) - 커버 이미지 직접 업로드 - RustFS 폴더명 입력 필드 - 타이틀곡 토글 버튼 - 페이지 전환 애니메이션
This commit is contained in:
parent
09a78ac044
commit
bd787d57c3
2 changed files with 851 additions and 0 deletions
|
|
@ -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() {
|
|||
<Route path="/admin" element={<AdminLogin />} />
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/albums" element={<AdminAlbums />} />
|
||||
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||
|
||||
{/* 일반 페이지 (레이아웃 포함) */}
|
||||
<Route path="/*" element={
|
||||
|
|
|
|||
848
frontend/src/pages/pc/admin/AdminAlbumForm.jsx
Normal file
848
frontend/src/pages/pc/admin/AdminAlbumForm.jsx
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Save, Home, ChevronRight, LogOut, Music, Trash2, Plus, Image, Star,
|
||||
ChevronDown, ChevronLeft, Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
// 커스텀 드롭다운 컴포넌트
|
||||
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||
value === option
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 커스텀 데이트픽커 컴포넌트
|
||||
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 (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{value ? formatDisplayDate(value) : '날짜 선택'}
|
||||
</span>
|
||||
<Calendar size={18} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 mt-2 bg-white border border-gray-200 rounded-xl shadow-lg p-4 w-80"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={viewMode === 'years' ? prevYearRange : prevMonth}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode(viewMode === 'days' ? 'years' : 'days')}
|
||||
className="font-medium text-gray-900 hover:text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
{viewMode === 'years' ? `${years[0]} - ${years[years.length - 1]}` : `${year}년 ${month + 1}월`}
|
||||
<ChevronDown size={16} className={`transition-transform ${viewMode !== 'days' ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={viewMode === 'years' ? nextYearRange : nextMonth}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'years' && (
|
||||
<motion.div
|
||||
key="years"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* 년도 라벨 */}
|
||||
<div className="text-center text-sm text-gray-500 mb-3">년도</div>
|
||||
|
||||
{/* 년도 그리드 */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={() => selectYear(y)}
|
||||
className={`
|
||||
py-2 rounded-lg text-sm transition-colors
|
||||
${year === y ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'}
|
||||
${isCurrentYear(y) && year !== y ? 'border border-primary text-primary' : ''}
|
||||
`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 월 라벨 */}
|
||||
<div className="text-center text-sm text-gray-500 mb-3">월</div>
|
||||
|
||||
{/* 월 그리드 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{months.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => selectMonth(i)}
|
||||
className={`
|
||||
py-2 rounded-lg text-sm transition-colors
|
||||
${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'}
|
||||
${isCurrentMonth(i) && month !== i ? 'border border-primary text-primary' : ''}
|
||||
`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === 'months' && (
|
||||
<motion.div
|
||||
key="months"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* 월 라벨 */}
|
||||
<div className="text-center text-sm text-gray-500 mb-3">월 선택</div>
|
||||
|
||||
{/* 월 그리드 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{months.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => selectMonth(i)}
|
||||
className={`
|
||||
py-2.5 rounded-lg text-sm transition-colors
|
||||
${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'}
|
||||
${isCurrentMonth(i) && month !== i ? 'border border-primary text-primary' : ''}
|
||||
`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === 'days' && (
|
||||
<motion.div
|
||||
key="days"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* 요일 */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`text-center text-xs font-medium py-1 ${
|
||||
i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={() => day && selectDate(day)}
|
||||
className={`
|
||||
aspect-square rounded-lg text-sm flex items-center justify-center transition-colors
|
||||
${!day ? '' : 'hover:bg-gray-100'}
|
||||
${isSelected(day) ? 'bg-primary text-white hover:bg-primary-dark' : ''}
|
||||
${isToday(day) && !isSelected(day) ? 'border border-primary text-primary' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) ? 'text-gray-700' : ''}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
className="min-h-screen bg-gray-50"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={pageVariants}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
fromis_9
|
||||
</Link>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 text-sm">
|
||||
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>님
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* 브레드크럼 */}
|
||||
<motion.div
|
||||
className="flex items-center gap-2 text-sm text-gray-400 mb-8"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||
<Home size={16} />
|
||||
</Link>
|
||||
<ChevronRight size={14} />
|
||||
<Link to="/admin/albums" className="hover:text-primary transition-colors">
|
||||
앨범 관리
|
||||
</Link>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-gray-700">{isEditMode ? '앨범 수정' : '새 앨범 추가'}</span>
|
||||
</motion.div>
|
||||
|
||||
{/* 타이틀 */}
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{isEditMode ? '앨범 수정' : '새 앨범 추가'}
|
||||
</h1>
|
||||
<p className="text-gray-500">앨범 정보와 트랙을 입력하세요</p>
|
||||
</motion.div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* 앨범 기본 정보 */}
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">앨범 정보</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 커버 이미지 */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
커버 이미지
|
||||
</label>
|
||||
<div className="flex items-start gap-6">
|
||||
<div
|
||||
onClick={() => 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 ? (
|
||||
<img
|
||||
src={coverPreview}
|
||||
alt="커버 미리보기"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-gray-400">
|
||||
<Image size={32} className="mx-auto mb-2" />
|
||||
<p className="text-xs">클릭하여 업로드</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={coverInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleCoverChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 mb-2">권장 크기: 1000x1000px</p>
|
||||
<p className="text-sm text-gray-500">지원 형식: JPG, PNG, WebP</p>
|
||||
{coverPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCoverPreview(null);
|
||||
setCoverFile(null);
|
||||
}}
|
||||
className="mt-3 text-sm text-red-500 hover:text-red-600"
|
||||
>
|
||||
이미지 제거
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 앨범명 */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
앨범명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="예: 하얀 그리움"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 폴더명 */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
RustFS 폴더명 *
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 text-sm">fromis-9/album/</span>
|
||||
<input
|
||||
type="text"
|
||||
name="folder_name"
|
||||
value={formData.folder_name}
|
||||
onChange={handleInputChange}
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="예: white-memories"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">영문 소문자, 숫자, 하이픈만 사용</p>
|
||||
</div>
|
||||
|
||||
{/* 앨범 타입 - 커스텀 드롭다운 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
앨범 타입 *
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={formData.album_type}
|
||||
onChange={(val) => setFormData(prev => ({ ...prev, album_type: val }))}
|
||||
options={albumTypes}
|
||||
placeholder="타입 선택"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 앨범 타입 약어 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
타입 약어
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="album_type_short"
|
||||
value={formData.album_type_short}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="예: 스페셜 디지털 싱글"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 발매일 - 커스텀 데이트픽커 */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
발매일 *
|
||||
</label>
|
||||
<CustomDatePicker
|
||||
value={formData.release_date}
|
||||
onChange={(val) => setFormData(prev => ({ ...prev, release_date: val }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
앨범 설명
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleInputChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
|
||||
placeholder="앨범에 대한 설명을 입력하세요..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 트랙 목록 */}
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-900">트랙 목록</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTrack}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
트랙 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tracks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Music size={40} className="mx-auto mb-3 text-gray-300" />
|
||||
<p>트랙을 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tracks.map((track, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="border border-gray-200 rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center text-sm font-medium text-gray-600">
|
||||
{String(track.track_number).padStart(2, '0')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateTrack(index, 'is_title_track', !track.is_title_track)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
track.is_title_track
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Star size={12} fill={track.is_title_track ? 'currentColor' : 'none'} />
|
||||
타이틀
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTrack(index)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-3">
|
||||
<input
|
||||
type="text"
|
||||
value={track.title}
|
||||
onChange={(e) => updateTrack(index, 'title', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="곡 제목"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={track.duration}
|
||||
onChange={(e) => updateTrack(index, 'duration', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-center"
|
||||
placeholder="3:30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<motion.div
|
||||
className="flex items-center justify-end gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/albums')}
|
||||
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminAlbumForm;
|
||||
Loading…
Add table
Reference in a new issue