fromis_9/frontend/src/pages/pc/admin/AdminAlbumForm.jsx

984 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import Toast from '../../../components/Toast';
// 커스텀 드롭다운 컴포넌트
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 [toast, setToast] = useState(null);
// Toast 자동 숨김
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
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;
// 앨범명 변경 시 RustFS 폴더명 자동 생성
if (name === 'title') {
const folderName = value
.toLowerCase()
.replace(/[\s.]+/g, '-') // 띄어쓰기, 점을 하이픈으로
.replace(/[^a-z0-9가-힣-]/g, '') // 특수문자 제거 (영문, 숫자, 한글, 하이픈만 유지)
.replace(/-+/g, '-') // 연속 하이픈 하나로
.replace(/^-|-$/g, ''); // 앞뒤 하이픈 제거
setFormData(prev => ({ ...prev, title: value, folder_name: folderName }));
} else {
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) => {
// 작사/작곡/편곡 필드에서 '' (전각 세로 막대)를 ', '로 자동 변환
let processedValue = value;
if (['lyricist', 'composer', 'arranger'].includes(field)) {
processedValue = value.replace(/[|]/g, ', ');
}
setTracks(prev => prev.map((track, i) =>
i === index ? { ...track, [field]: processedValue } : track
));
};
const handleSubmit = async (e) => {
e.preventDefault();
// 커스텀 검증
if (!formData.title.trim()) {
setToast({ message: '앨범명을 입력해주세요.', type: 'warning' });
return;
}
if (!formData.folder_name.trim()) {
setToast({ message: 'RustFS 폴더명을 입력해주세요.', type: 'warning' });
return;
}
if (!formData.album_type_short) {
setToast({ message: '앨범 타입을 선택해주세요.', type: 'warning' });
return;
}
if (!formData.release_date) {
setToast({ message: '발매일을 선택해주세요.', type: 'warning' });
return;
}
if (!formData.album_type.trim()) {
setToast({ message: '앨범 유형을 입력해주세요.', type: 'warning' });
return;
}
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);
setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' });
} 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 }}
>
{/* Toast */}
<Toast toast={toast} onClose={() => setToast(null)} />
{/* 헤더 */}
<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="예: 하얀 그리움"
/>
</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"
/>
</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_short}
onChange={(val) => setFormData(prev => ({ ...prev, album_type_short: 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"
value={formData.album_type}
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="예: 미니 6집"
/>
</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={8}
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="0:00"
/>
</div>
</div>
{/* 상세 정보 토글 */}
<div className="mt-3">
<button
type="button"
onClick={() => updateTrack(index, 'showDetails', !track.showDetails)}
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1 transition-colors"
>
<ChevronDown
size={14}
className={`transition-transform ${track.showDetails ? 'rotate-180' : ''}`}
/>
상세 정보 {track.showDetails ? '접기' : '펼치기'}
</button>
</div>
<AnimatePresence>
{track.showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* 작사/작곡/편곡 */}
<div className="space-y-3 mt-3">
<div>
<label className="block text-xs text-gray-500 mb-1">작사</label>
<input
type="text"
value={track.lyricist || ''}
onChange={(e) => updateTrack(index, 'lyricist', 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>
<label className="block text-xs text-gray-500 mb-1">작곡</label>
<input
type="text"
value={track.composer || ''}
onChange={(e) => updateTrack(index, 'composer', 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>
<label className="block text-xs text-gray-500 mb-1">편곡</label>
<input
type="text"
value={track.arranger || ''}
onChange={(e) => updateTrack(index, 'arranger', 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>
{/* MV URL */}
<div className="mt-3">
<label className="block text-xs text-gray-500 mb-1">뮤직비디오 URL</label>
<input
type="text"
value={track.music_video_url || ''}
onChange={(e) => updateTrack(index, 'music_video_url', 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="https://youtube.com/watch?v=..."
/>
</div>
{/* 가사 */}
<div className="mt-3">
<label className="block text-xs text-gray-500 mb-1">가사</label>
<textarea
value={track.lyrics || ''}
onChange={(e) => updateTrack(index, 'lyrics', e.target.value)}
rows={12}
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 resize-none min-h-[200px]"
placeholder="가사를 입력하세요..."
/>
</div>
</motion.div>
)}
</AnimatePresence>
</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;