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

- 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.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]
);

View file

@ -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>
{/* 가사 */}

View file

@ -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>

View file

@ -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

View file

@ -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