fromis_9/frontend/src/pages/pc/admin/AdminAlbumForm.jsx
caadiq b0f7169226 refactor: API 라우트 구조 통합 및 파일 분리
- /api/admin/* + /api/* 분리 구조를 /api/*로 통합
- GET 요청은 공개, POST/PUT/DELETE는 인증 필요로 변경
- albums 라우트를 기능별 파일로 분리 (index, photos, teasers)
- 프론트엔드 API 호출 경로 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:01:35 +09:00

666 lines
36 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, useRef, useEffect } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import {
Save, Home, ChevronRight, Music, Trash2, Plus, Image, Star,
ChevronDown
} from 'lucide-react';
import Toast from '../../../components/Toast';
import CustomDatePicker from '../../../components/admin/CustomDatePicker';
import AdminLayout from '../../../components/admin/AdminLayout';
import useAdminAuth from '../../../hooks/useAdminAuth';
import useToast from '../../../hooks/useToast';
import * as albumsApi from '../../../api/admin/albums';
// 커스텀 드롭다운 컴포넌트
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 queryClient = useQueryClient();
const { id } = useParams();
const isEditMode = !!id;
const coverInputRef = useRef(null);
const { user, isAuthenticated } = useAdminAuth();
const [saving, setSaving] = useState(false);
const [coverPreview, setCoverPreview] = useState(null);
const [coverFile, setCoverFile] = useState(null);
const { toast, setToast } = useToast();
const [formData, setFormData] = useState({
title: '',
album_type: '',
album_type_short: '',
release_date: '',
cover_original_url: '',
cover_medium_url: '',
cover_thumb_url: '',
folder_name: '',
description: '',
});
const [tracks, setTracks] = useState([]);
// 수정 모드일 때 앨범 데이터 로드 (useQuery 사용)
const { data: albumData, isLoading: loading, error: albumError } = useQuery({
queryKey: ['admin', 'album', id],
queryFn: () => albumsApi.getAlbum(id),
enabled: isAuthenticated && isEditMode && !!id,
staleTime: 0,
});
// 앨범 데이터 로드 시 폼에 반영
useEffect(() => {
if (albumData) {
setFormData({
title: albumData.title || '',
album_type: albumData.album_type || '',
album_type_short: albumData.album_type_short || '',
release_date: albumData.release_date ? albumData.release_date.split('T')[0] : '',
cover_original_url: albumData.cover_original_url || '',
cover_medium_url: albumData.cover_medium_url || '',
cover_thumb_url: albumData.cover_thumb_url || '',
folder_name: albumData.folder_name || '',
description: albumData.description || '',
});
if (albumData.cover_medium_url || albumData.cover_original_url) {
setCoverPreview(albumData.cover_medium_url || albumData.cover_original_url);
}
setTracks(albumData.tracks || []);
}
}, [albumData]);
// 에러 처리
useEffect(() => {
if (albumError) {
console.error('앨범 로드 오류:', albumError);
setToast({ message: '앨범 로드 중 오류가 발생했습니다.', type: 'error' });
}
}, [albumError, setToast]);
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/albums/${id}` : '/api/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('저장 실패');
}
// 앨범 목록 캐시 무효화
queryClient.invalidateQueries({ queryKey: ['admin', 'albums'] });
navigate('/admin/albums');
} catch (error) {
console.error('저장 오류:', error);
setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' });
} finally {
setSaving(false);
}
};
const albumTypes = ['정규', '미니', '싱글'];
return (
<AdminLayout user={user}>
{/* Toast */}
<Toast toast={toast} onClose={() => setToast(null)} />
{/* 메인 콘텐츠 */}
<div 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>
)}
</div>
</AdminLayout>
);
}
export default AdminAlbumForm;