트랙 영상 타입 구분 기능 추가
- DB: music_video_url을 video_url로 변경, video_type 컬럼 추가
- 백엔드: insertTracks에서 video_url, video_type 처리
- 관리자: 영상 타입 선택 드롭다운 추가 (뮤직비디오/스페셜 영상)
- CustomSelect: {value, label} 객체 옵션 및 size prop 지원
- 트랙 상세: video_type에 따른 라벨 동적 표시
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0c6d250a9d
commit
5415893f9d
5 changed files with 64 additions and 30 deletions
|
|
@ -181,12 +181,13 @@ async function insertTracks(connection, albumId, tracks) {
|
|||
track.composer || null,
|
||||
track.arranger || null,
|
||||
track.lyrics || null,
|
||||
track.music_video_url || null,
|
||||
track.video_url || null,
|
||||
track.video_type || null,
|
||||
]);
|
||||
|
||||
await connection.query(
|
||||
`INSERT INTO album_tracks
|
||||
(album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
|
||||
(album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, video_url, video_type)
|
||||
VALUES ?`,
|
||||
[values]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
import { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, Star, ChevronDown } from 'lucide-react';
|
||||
import { CustomSelect } from '../common';
|
||||
|
||||
const VIDEO_TYPE_OPTIONS = [
|
||||
{ value: '', label: '선택' },
|
||||
{ value: 'music_video', label: '뮤직비디오' },
|
||||
{ value: 'special', label: '스페셜 영상' },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
|
|
@ -120,16 +127,26 @@ const TrackItem = memo(function TrackItem({ track, index, onUpdate, onRemove })
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* MV URL */}
|
||||
{/* 비디오 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=..."
|
||||
/>
|
||||
<label className="block text-xs text-gray-500 mb-1">영상 URL</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={track.video_url || ''}
|
||||
onChange={(e) => onUpdate(index, 'video_url', e.target.value)}
|
||||
className="flex-1 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=..."
|
||||
/>
|
||||
<CustomSelect
|
||||
value={track.video_type || ''}
|
||||
onChange={(value) => onUpdate(index, 'video_type', value)}
|
||||
options={VIDEO_TYPE_OPTIONS}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가사 */}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { ChevronDown } from 'lucide-react';
|
|||
* @param {Object} props
|
||||
* @param {string} props.value - 선택된 값
|
||||
* @param {Function} props.onChange - 값 변경 핸들러
|
||||
* @param {string[]} props.options - 옵션 목록
|
||||
* @param {Array<string|{value: string, label: string}>} props.options - 옵션 목록 (문자열 또는 {value, label} 객체)
|
||||
* @param {string} props.placeholder - 플레이스홀더
|
||||
* @param {string} props.className - 추가 클래스명
|
||||
* @param {string} props.size - 크기 ('sm' | 'md')
|
||||
*/
|
||||
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||
function CustomSelect({ value, onChange, options, placeholder, className = '', size = 'md' }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
|
|
@ -26,17 +28,29 @@ function CustomSelect({ value, onChange, options, placeholder }) {
|
|||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 옵션을 {value, label} 형태로 정규화
|
||||
const normalizedOptions = options.map((opt) =>
|
||||
typeof opt === 'string' ? { value: opt, label: opt } : opt
|
||||
);
|
||||
|
||||
// 현재 선택된 옵션의 라벨 찾기
|
||||
const selectedLabel = normalizedOptions.find((opt) => opt.value === value)?.label;
|
||||
|
||||
const sizeClasses = size === 'sm' ? 'px-3 py-2 text-sm' : 'px-4 py-2.5';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div ref={ref} className={`relative ${className}`}>
|
||||
<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"
|
||||
className={`w-full ${sizeClasses} 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>
|
||||
<span className={selectedLabel ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
size={size === 'sm' ? 16 : 18}
|
||||
className={`text-gray-400 transition-transform flex-shrink-0 ml-2 ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
|
@ -49,19 +63,19 @@ function CustomSelect({ value, onChange, options, placeholder }) {
|
|||
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) => (
|
||||
{normalizedOptions.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
onChange(option.value);
|
||||
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'
|
||||
className={`w-full ${sizeClasses} text-left hover:bg-gray-50 transition-colors ${
|
||||
value === option.value ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ function MobileTrackDetail() {
|
|||
enabled: !!albumName && !!trackTitle,
|
||||
});
|
||||
|
||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
|
||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.video_url), [track?.video_url]);
|
||||
const videoLabel = track?.video_type === 'special' ? '스페셜 영상' : '뮤직비디오';
|
||||
|
||||
// 가사 펼침 상태
|
||||
const [showFullLyrics, setShowFullLyrics] = useState(false);
|
||||
|
|
@ -140,12 +141,12 @@ function MobileTrackDetail() {
|
|||
>
|
||||
<h2 className="text-base font-bold mb-3 flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-red-500 rounded-full" />
|
||||
뮤직비디오
|
||||
{videoLabel}
|
||||
</h2>
|
||||
<div className="relative w-full aspect-video rounded-xl overflow-hidden shadow-md bg-black">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
|
||||
title={`${track.title} 뮤직비디오`}
|
||||
title={`${track.title} ${videoLabel}`}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
|
||||
allowFullScreen
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ function PCTrackDetail() {
|
|||
enabled: !!albumName && !!trackTitle,
|
||||
});
|
||||
|
||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
|
||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.video_url), [track?.video_url]);
|
||||
const videoLabel = track?.video_type === 'special' ? '스페셜 영상' : '뮤직비디오';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -134,12 +135,12 @@ function PCTrackDetail() {
|
|||
>
|
||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<div className="w-1 h-5 bg-red-500 rounded-full" />
|
||||
뮤직비디오
|
||||
{videoLabel}
|
||||
</h2>
|
||||
<div className="relative w-full aspect-video rounded-2xl overflow-hidden shadow-lg bg-black">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
|
||||
title={`${track.title} 뮤직비디오`}
|
||||
title={`${track.title} ${videoLabel}`}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue