refactor: AlbumForm.jsx 분리 - CustomSelect, TrackItem 컴포넌트 추출
- CustomSelect.jsx 추출 → common/ (재사용 가능한 드롭다운) - TrackItem.jsx 추출 → album/ (트랙 입력 폼) - AlbumForm.jsx: 631줄 → 443줄 (188줄 감소) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e31cb82649
commit
f436cf4367
7 changed files with 251 additions and 203 deletions
|
|
@ -48,7 +48,7 @@ components/
|
||||||
| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
|
| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
|
||||||
| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
|
| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
|
||||||
| ScheduleDict.jsx | 714 | 572 | ✅ 분리 완료 |
|
| ScheduleDict.jsx | 714 | 572 | ✅ 분리 완료 |
|
||||||
| AlbumForm.jsx | 631 | 631 | 미분리 |
|
| AlbumForm.jsx | 631 | 443 | ✅ 분리 완료 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -96,8 +96,10 @@ components/
|
||||||
│ │ ├── ScheduleItem.jsx # 일정 아이템 (리스트용)
|
│ │ ├── ScheduleItem.jsx # 일정 아이템 (리스트용)
|
||||||
│ │ ├── LocationSearchDialog.jsx # 장소 검색 모달
|
│ │ ├── LocationSearchDialog.jsx # 장소 검색 모달
|
||||||
│ │ ├── MemberSelector.jsx # 멤버 선택 UI
|
│ │ ├── MemberSelector.jsx # 멤버 선택 UI
|
||||||
│ │ └── ImageUploader.jsx # 이미지 업로드
|
│ │ ├── ImageUploader.jsx # 이미지 업로드
|
||||||
│ └── album/ # PhotoUploader 등
|
│ │ └── WordItem.jsx # 사전 단어 아이템
|
||||||
|
│ └── album/ # ✅ 추가된 컴포넌트들:
|
||||||
|
│ └── TrackItem.jsx # 트랙 입력 폼
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.3 중복 코드 제거
|
### 2.3 중복 코드 제거
|
||||||
|
|
@ -179,8 +181,10 @@ pages/pc/admin/schedules/
|
||||||
3. [x] ScheduleDict.jsx 분리 (714줄 → 572줄, 142줄 감소)
|
3. [x] ScheduleDict.jsx 분리 (714줄 → 572줄, 142줄 감소)
|
||||||
- `WordItem.jsx` 추출 (단어 테이블 행 컴포넌트)
|
- `WordItem.jsx` 추출 (단어 테이블 행 컴포넌트)
|
||||||
- `POS_TAGS` 상수 함께 분리
|
- `POS_TAGS` 상수 함께 분리
|
||||||
4. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
|
4. [x] AlbumForm.jsx 분리 (631줄 → 443줄, 188줄 감소)
|
||||||
5. [ ] AlbumForm.jsx 분리 (631줄)
|
- `CustomSelect.jsx` 추출 (공통 드롭다운 → common/)
|
||||||
|
- `TrackItem.jsx` 추출 (트랙 입력 폼 → album/)
|
||||||
|
5. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
|
||||||
|
|
||||||
### Phase 3: 추가 개선
|
### Phase 3: 추가 개선
|
||||||
1. [x] 관리자 페이지용 에러 페이지 추가 (404)
|
1. [x] 관리자 페이지용 에러 페이지 추가 (404)
|
||||||
|
|
|
||||||
153
frontend-temp/src/components/pc/admin/album/TrackItem.jsx
Normal file
153
frontend-temp/src/components/pc/admin/album/TrackItem.jsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/**
|
||||||
|
* 앨범 트랙 입력 컴포넌트
|
||||||
|
*/
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Trash2, Star, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {Object} props.track - 트랙 데이터
|
||||||
|
* @param {number} props.index - 트랙 인덱스
|
||||||
|
* @param {Function} props.onUpdate - 트랙 업데이트 핸들러 (index, field, value)
|
||||||
|
* @param {Function} props.onRemove - 트랙 삭제 핸들러 ()
|
||||||
|
*/
|
||||||
|
const TrackItem = memo(function TrackItem({ track, index, onUpdate, onRemove }) {
|
||||||
|
return (
|
||||||
|
<div 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={() => onUpdate(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={onRemove}
|
||||||
|
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) => onUpdate(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) => onUpdate(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={() => onUpdate(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) => onUpdate(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) => onUpdate(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) => onUpdate(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) => onUpdate(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) => onUpdate(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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TrackItem;
|
||||||
1
frontend-temp/src/components/pc/admin/album/index.js
Normal file
1
frontend-temp/src/components/pc/admin/album/index.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as TrackItem } from './TrackItem';
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* 커스텀 드롭다운 셀렉트 컴포넌트
|
||||||
|
*/
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.value - 선택된 값
|
||||||
|
* @param {Function} props.onChange - 값 변경 핸들러
|
||||||
|
* @param {string[]} props.options - 옵션 목록
|
||||||
|
* @param {string} props.placeholder - 플레이스홀더
|
||||||
|
*/
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomSelect;
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { default as ConfirmDialog } from './ConfirmDialog';
|
export { default as ConfirmDialog } from './ConfirmDialog';
|
||||||
|
export { default as CustomSelect } from './CustomSelect';
|
||||||
export { default as DatePicker } from './DatePicker';
|
export { default as DatePicker } from './DatePicker';
|
||||||
export { default as TimePicker } from './TimePicker';
|
export { default as TimePicker } from './TimePicker';
|
||||||
export { default as NumberPicker } from './NumberPicker';
|
export { default as NumberPicker } from './NumberPicker';
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,6 @@ export * from './common';
|
||||||
|
|
||||||
// 스케줄 관련
|
// 스케줄 관련
|
||||||
export * from './schedule';
|
export * from './schedule';
|
||||||
|
|
||||||
|
// 앨범 관련
|
||||||
|
export * from './album';
|
||||||
|
|
|
||||||
|
|
@ -4,75 +4,15 @@
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Save, Home, ChevronRight, Music, Trash2, Plus, Image, Star, ChevronDown } from 'lucide-react';
|
import { Save, Home, ChevronRight, Music, Plus, Image } from 'lucide-react';
|
||||||
import { Toast } from '@/components/common';
|
import { Toast } from '@/components/common';
|
||||||
import { AdminLayout, DatePicker } from '@/components/pc/admin';
|
import { AdminLayout, DatePicker, CustomSelect, TrackItem } from '@/components/pc/admin';
|
||||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
import { useToast } from '@/hooks/common';
|
import { useToast } from '@/hooks/common';
|
||||||
import { adminAlbumApi } from '@/api/admin';
|
import { adminAlbumApi } from '@/api/admin';
|
||||||
import { fetchFormData } from '@/api/client';
|
import { fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
// 커스텀 드롭다운 컴포넌트
|
|
||||||
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() {
|
function AdminAlbumForm() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -454,141 +394,13 @@ function AdminAlbumForm() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<div key={index} className="border border-gray-200 rounded-xl p-4">
|
<TrackItem
|
||||||
<div className="flex items-center justify-between mb-4">
|
key={index}
|
||||||
<div className="flex items-center gap-3">
|
track={track}
|
||||||
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center text-sm font-medium text-gray-600">
|
index={index}
|
||||||
{String(track.track_number).padStart(2, '0')}
|
onUpdate={updateTrack}
|
||||||
</span>
|
onRemove={() => removeTrack(index)}
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue