트랙 영상 타입 구분 기능 추가

- 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:
caadiq 2026-01-24 10:56:12 +09:00
parent 0c6d250a9d
commit 5415893f9d
5 changed files with 64 additions and 30 deletions

View file

@ -181,12 +181,13 @@ async function insertTracks(connection, albumId, tracks) {
track.composer || null, track.composer || null,
track.arranger || null, track.arranger || null,
track.lyrics || null, track.lyrics || null,
track.music_video_url || null, track.video_url || null,
track.video_type || null,
]); ]);
await connection.query( await connection.query(
`INSERT INTO album_tracks `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 ?`,
[values] [values]
); );

View file

@ -4,6 +4,13 @@
import { memo } from 'react'; import { memo } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Trash2, Star, ChevronDown } from 'lucide-react'; 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 * @param {Object} props
@ -120,16 +127,26 @@ const TrackItem = memo(function TrackItem({ track, index, onUpdate, onRemove })
</div> </div>
</div> </div>
{/* MV URL */} {/* 비디오 URL */}
<div className="mt-3"> <div className="mt-3">
<label className="block text-xs text-gray-500 mb-1">뮤직비디오 URL</label> <label className="block text-xs text-gray-500 mb-1">영상 URL</label>
<div className="flex gap-2">
<input <input
type="text" type="text"
value={track.music_video_url || ''} value={track.video_url || ''}
onChange={(e) => onUpdate(index, 'music_video_url', e.target.value)} onChange={(e) => onUpdate(index, '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" 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=..." 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> </div>
{/* 가사 */} {/* 가사 */}

View file

@ -9,10 +9,12 @@ import { ChevronDown } from 'lucide-react';
* @param {Object} props * @param {Object} props
* @param {string} props.value - 선택된 * @param {string} props.value - 선택된
* @param {Function} props.onChange - 변경 핸들러 * @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.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 [isOpen, setIsOpen] = useState(false);
const ref = useRef(null); const ref = useRef(null);
@ -26,17 +28,29 @@ function CustomSelect({ value, onChange, options, placeholder }) {
return () => document.removeEventListener('mousedown', handleClickOutside); 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 ( return (
<div ref={ref} className="relative"> <div ref={ref} className={`relative ${className}`}>
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} 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 <ChevronDown
size={18} size={size === 'sm' ? 16 : 18}
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} className={`text-gray-400 transition-transform flex-shrink-0 ml-2 ${isOpen ? 'rotate-180' : ''}`}
/> />
</button> </button>
@ -49,19 +63,19 @@ function CustomSelect({ value, onChange, options, placeholder }) {
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden" 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 <button
key={option} key={option.value}
type="button" type="button"
onClick={() => { onClick={() => {
onChange(option); onChange(option.value);
setIsOpen(false); setIsOpen(false);
}} }}
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${ className={`w-full ${sizeClasses} text-left hover:bg-gray-50 transition-colors ${
value === option ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700' value === option.value ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
}`} }`}
> >
{option} {option.label}
</button> </button>
))} ))}
</motion.div> </motion.div>

View file

@ -37,7 +37,8 @@ function MobileTrackDetail() {
enabled: !!albumName && !!trackTitle, 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); const [showFullLyrics, setShowFullLyrics] = useState(false);
@ -140,12 +141,12 @@ function MobileTrackDetail() {
> >
<h2 className="text-base font-bold mb-3 flex items-center gap-2"> <h2 className="text-base font-bold mb-3 flex items-center gap-2">
<div className="w-1 h-4 bg-red-500 rounded-full" /> <div className="w-1 h-4 bg-red-500 rounded-full" />
뮤직비디오 {videoLabel}
</h2> </h2>
<div className="relative w-full aspect-video rounded-xl overflow-hidden shadow-md bg-black"> <div className="relative w-full aspect-video rounded-xl overflow-hidden shadow-md bg-black">
<iframe <iframe
src={`https://www.youtube.com/embed/${youtubeVideoId}`} src={`https://www.youtube.com/embed/${youtubeVideoId}`}
title={`${track.title} 뮤직비디오`} title={`${track.title} ${videoLabel}`}
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
allowFullScreen allowFullScreen

View file

@ -37,7 +37,8 @@ function PCTrackDetail() {
enabled: !!albumName && !!trackTitle, 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) { if (loading) {
return ( return (
@ -134,12 +135,12 @@ function PCTrackDetail() {
> >
<h2 className="text-lg font-bold mb-4 flex items-center gap-2"> <h2 className="text-lg font-bold mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-red-500 rounded-full" /> <div className="w-1 h-5 bg-red-500 rounded-full" />
뮤직비디오 {videoLabel}
</h2> </h2>
<div className="relative w-full aspect-video rounded-2xl overflow-hidden shadow-lg bg-black"> <div className="relative w-full aspect-video rounded-2xl overflow-hidden shadow-lg bg-black">
<iframe <iframe
src={`https://www.youtube.com/embed/${youtubeVideoId}`} src={`https://www.youtube.com/embed/${youtubeVideoId}`}
title={`${track.title} 뮤직비디오`} title={`${track.title} ${videoLabel}`}
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen