2026-01-01 18:28:01 +09:00
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
|
|
|
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
|
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
|
|
|
|
import {
|
2026-01-09 23:18:48 +09:00
|
|
|
|
Save, Home, ChevronRight, Music, Trash2, Plus, Image, Star,
|
2026-01-09 22:42:33 +09:00
|
|
|
|
ChevronDown
|
2026-01-01 18:28:01 +09:00
|
|
|
|
} from 'lucide-react';
|
2026-01-01 20:36:49 +09:00
|
|
|
|
import Toast from '../../../components/Toast';
|
2026-01-09 22:42:33 +09:00
|
|
|
|
import CustomDatePicker from '../../../components/admin/CustomDatePicker';
|
2026-01-11 12:12:46 +09:00
|
|
|
|
import AdminLayout from '../../../components/admin/AdminLayout';
|
2026-01-09 22:57:34 +09:00
|
|
|
|
import useToast from '../../../hooks/useToast';
|
2026-01-01 18:28:01 +09:00
|
|
|
|
|
|
|
|
|
|
// 커스텀 드롭다운 컴포넌트
|
|
|
|
|
|
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 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);
|
2026-01-09 22:57:34 +09:00
|
|
|
|
const { toast, setToast } = useToast();
|
2026-01-01 18:28:01 +09:00
|
|
|
|
|
|
|
|
|
|
const [formData, setFormData] = useState({
|
|
|
|
|
|
title: '',
|
|
|
|
|
|
album_type: '',
|
|
|
|
|
|
album_type_short: '',
|
|
|
|
|
|
release_date: '',
|
2026-01-04 11:34:31 +09:00
|
|
|
|
cover_original_url: '',
|
|
|
|
|
|
cover_medium_url: '',
|
|
|
|
|
|
cover_thumb_url: '',
|
2026-01-01 18:28:01 +09:00
|
|
|
|
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] : '',
|
2026-01-04 11:34:31 +09:00
|
|
|
|
cover_original_url: data.cover_original_url || '',
|
|
|
|
|
|
cover_medium_url: data.cover_medium_url || '',
|
|
|
|
|
|
cover_thumb_url: data.cover_thumb_url || '',
|
2026-01-01 18:28:01 +09:00
|
|
|
|
folder_name: data.folder_name || '',
|
|
|
|
|
|
description: data.description || '',
|
|
|
|
|
|
});
|
2026-01-04 11:34:31 +09:00
|
|
|
|
if (data.cover_medium_url || data.cover_original_url) {
|
|
|
|
|
|
setCoverPreview(data.cover_medium_url || data.cover_original_url);
|
2026-01-01 18:28:01 +09:00
|
|
|
|
}
|
|
|
|
|
|
setTracks(data.tracks || []);
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(error => {
|
|
|
|
|
|
console.error('앨범 로드 오류:', error);
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [id, isEditMode, navigate]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleInputChange = (e) => {
|
|
|
|
|
|
const { name, value } = e.target;
|
2026-01-02 23:35:36 +09:00
|
|
|
|
|
|
|
|
|
|
// 앨범명 변경 시 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 }));
|
|
|
|
|
|
}
|
2026-01-01 18:28:01 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-01-02 17:04:27 +09:00
|
|
|
|
// 작사/작곡/편곡 필드에서 '|' (전각 세로 막대)를 ', '로 자동 변환
|
|
|
|
|
|
let processedValue = value;
|
|
|
|
|
|
if (['lyricist', 'composer', 'arranger'].includes(field)) {
|
|
|
|
|
|
processedValue = value.replace(/[||]/g, ', ');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 18:28:01 +09:00
|
|
|
|
setTracks(prev => prev.map((track, i) =>
|
2026-01-02 17:04:27 +09:00
|
|
|
|
i === index ? { ...track, [field]: processedValue } : track
|
2026-01-01 18:28:01 +09:00
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
2026-01-01 20:36:49 +09:00
|
|
|
|
|
|
|
|
|
|
// 커스텀 검증
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 18:28:01 +09:00
|
|
|
|
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);
|
2026-01-01 20:36:49 +09:00
|
|
|
|
setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' });
|
2026-01-01 18:28:01 +09:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const albumTypes = ['정규', '미니', '싱글'];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-11 12:12:46 +09:00
|
|
|
|
<AdminLayout user={user}>
|
2026-01-01 20:36:49 +09:00
|
|
|
|
{/* Toast */}
|
|
|
|
|
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
2026-01-01 18:28:01 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 메인 콘텐츠 */}
|
2026-01-11 12:12:46 +09:00
|
|
|
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
2026-01-01 18:28:01 +09:00
|
|
|
|
{/* 브레드크럼 */}
|
|
|
|
|
|
<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
|
2026-01-01 20:36:49 +09:00
|
|
|
|
value={formData.album_type_short}
|
|
|
|
|
|
onChange={(val) => setFormData(prev => ({ ...prev, album_type_short: val }))}
|
2026-01-01 18:28:01 +09:00
|
|
|
|
options={albumTypes}
|
|
|
|
|
|
placeholder="타입 선택"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-01 20:36:49 +09:00
|
|
|
|
{/* 앨범 유형 (전체) */}
|
2026-01-01 18:28:01 +09:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
2026-01-01 20:36:49 +09:00
|
|
|
|
앨범 유형 *
|
2026-01-01 18:28:01 +09:00
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
2026-01-01 20:36:49 +09:00
|
|
|
|
name="album_type"
|
|
|
|
|
|
value={formData.album_type}
|
2026-01-01 18:28:01 +09:00
|
|
|
|
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"
|
2026-01-01 20:36:49 +09:00
|
|
|
|
placeholder="예: 미니 6집"
|
2026-01-01 18:28:01 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</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}
|
2026-01-02 17:04:27 +09:00
|
|
|
|
rows={8}
|
2026-01-01 18:28:01 +09:00
|
|
|
|
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"
|
2026-01-01 20:36:49 +09:00
|
|
|
|
value={track.duration || ''}
|
2026-01-01 18:28:01 +09:00
|
|
|
|
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"
|
2026-01-02 17:04:27 +09:00
|
|
|
|
placeholder="0:00"
|
2026-01-01 18:28:01 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-01 20:36:49 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 상세 정보 토글 */}
|
|
|
|
|
|
<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}
|
2026-01-02 17:04:27 +09:00
|
|
|
|
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]"
|
2026-01-01 20:36:49 +09:00
|
|
|
|
placeholder="가사를 입력하세요..."
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
2026-01-01 18:28:01 +09:00
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
2026-01-11 12:12:46 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</AdminLayout>
|
2026-01-01 18:28:01 +09:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default AdminAlbumForm;
|