트랙 영상 타입 구분 기능 추가
- 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.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]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="text"
|
<input
|
||||||
value={track.music_video_url || ''}
|
type="text"
|
||||||
onChange={(e) => onUpdate(index, 'music_video_url', e.target.value)}
|
value={track.video_url || ''}
|
||||||
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"
|
onChange={(e) => onUpdate(index, 'video_url', e.target.value)}
|
||||||
placeholder="https://youtube.com/watch?v=..."
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 가사 */}
|
{/* 가사 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue