diff --git a/docs/code-improvements.md b/docs/code-improvements.md index a0753d4..3b85c15 100644 --- a/docs/code-improvements.md +++ b/docs/code-improvements.md @@ -16,12 +16,14 @@ --- -## 1. 즉시 수정 필요 (Critical) +## 1. 즉시 수정 필요 (Critical) ✅ 완료 -### 1.1 useAdminAuth 무한 루프 위험 +### 1.1 useAdminAuth 무한 루프 위험 ✅ **파일**: `hooks/useAdminAuth.js:35` +**상태**: ✅ 완료 - `useRef`로 logout 함수 안정화 + **문제**: ```javascript useEffect(() => { @@ -57,10 +59,12 @@ logout: useCallback(() => { --- -### 1.2 인증 queryKey 충돌 +### 1.2 인증 queryKey 충돌 ✅ **파일**: `hooks/useAdminAuth.js`, `hooks/useAdminAuth.js` (useRedirectIfAuthenticated) +**상태**: ✅ 완료 - 고유 queryKey 적용 (`['admin', 'auth', 'verify']`, `['admin', 'auth', 'redirect-check']`) + **문제**: ```javascript // useAdminAuth (라인 19) @@ -97,10 +101,12 @@ export function useAuthStatus(options = {}) { --- -### 1.3 접근성(a11y) 심각한 문제 +### 1.3 접근성(a11y) 심각한 문제 ✅ **파일**: 모든 컴포넌트 +**상태**: ✅ 완료 - Toast, Lightbox, LightboxIndicator, Calendar (PC/Mobile)에 aria 속성 추가 + **문제**: - `aria-label` 전무 - `role` 속성 미사용 @@ -139,20 +145,22 @@ export function useAuthStatus(options = {}) { | `common/Tooltip.jsx` | role="tooltip", aria-describedby | **접근성 개선 체크리스트**: -- [ ] 모든 아이콘 버튼에 `aria-label` 추가 -- [ ] 장식용 아이콘에 `aria-hidden="true"` 추가 -- [ ] 모달/다이얼로그에 `role="dialog"`, `aria-modal="true"` 추가 -- [ ] 알림에 `role="alert"` 추가 -- [ ] 선택 가능한 요소에 `aria-selected` 추가 -- [ ] 토글 버튼에 `aria-pressed` 추가 -- [ ] 색상 외에 텍스트/아이콘으로 정보 보완 +- [x] 모든 아이콘 버튼에 `aria-label` 추가 +- [x] 장식용 아이콘에 `aria-hidden="true"` 추가 +- [x] 모달/다이얼로그에 `role="dialog"`, `aria-modal="true"` 추가 +- [x] 알림에 `role="alert"` 추가 +- [x] 선택 가능한 요소에 `aria-selected` 추가 +- [x] 토글 버튼에 `aria-pressed` 추가 +- [ ] 색상 외에 텍스트/아이콘으로 정보 보완 (향후 개선) --- -### 1.4 카드 컴포넌트 메모이제이션 부족 +### 1.4 카드 컴포넌트 메모이제이션 부족 ✅ **파일**: `components/pc/ScheduleCard.jsx`, `components/pc/BirthdayCard.jsx`, `components/mobile/ScheduleCard.jsx` 등 +**상태**: ✅ 완료 - 6개 카드 컴포넌트에 `React.memo` 적용 + **문제**: ```javascript // 현재 코드 - memo 없음 @@ -187,23 +195,25 @@ const ScheduleCard = memo( ``` **메모이제이션이 필요한 컴포넌트**: -- [ ] `components/pc/ScheduleCard.jsx` -- [ ] `components/pc/BirthdayCard.jsx` -- [ ] `components/mobile/ScheduleCard.jsx` -- [ ] `components/mobile/ScheduleListCard.jsx` -- [ ] `components/mobile/ScheduleSearchCard.jsx` -- [ ] `components/mobile/BirthdayCard.jsx` +- [x] `components/pc/ScheduleCard.jsx` +- [x] `components/pc/BirthdayCard.jsx` +- [x] `components/mobile/ScheduleCard.jsx` +- [x] `components/mobile/ScheduleListCard.jsx` +- [x] `components/mobile/ScheduleSearchCard.jsx` +- [x] `components/mobile/BirthdayCard.jsx` --- -## 2. 높은 우선순위 (High) +## 2. 높은 우선순위 (High) ✅ 완료 -### 2.1 PC/Mobile 간 중복 코드 +### 2.1 PC/Mobile 간 중복 코드 ✅ -#### 2.1.1 YouTube URL 파싱 함수 중복 +#### 2.1.1 YouTube URL 파싱 함수 중복 ✅ **파일**: `pages/album/pc/TrackDetail.jsx`, `pages/album/mobile/TrackDetail.jsx` +**상태**: ✅ 완료 - `utils/youtube.js` 생성, TrackDetail에서 유틸리티 사용으로 변경 + **중복 코드**: ```javascript // 두 파일에서 동일하게 존재 @@ -253,10 +263,12 @@ export function getYoutubeThumbnail(videoId, quality = 'hqdefault') { --- -#### 2.1.2 Credit 포맷팅 함수 중복 +#### 2.1.2 Credit 포맷팅 함수 중복 ✅ **파일**: `pages/album/pc/TrackDetail.jsx`, `pages/album/mobile/TrackDetail.jsx` +**상태**: ✅ 완료 - `utils/format.js`에 `parseCredits` 추가, TrackDetail에서 사용 + **중복 코드**: ```javascript function formatCredit(text) { @@ -300,10 +312,12 @@ import { parseCredits } from '@/utils'; --- -#### 2.1.3 재생 시간 계산 함수 중복 +#### 2.1.3 재생 시간 계산 함수 중복 ✅ **파일**: `pages/album/pc/AlbumDetail.jsx`, `pages/album/mobile/AlbumDetail.jsx` +**상태**: ✅ 완료 - `utils/format.js`에 `calculateTotalDuration` 추가, AlbumDetail에서 사용 + **중복 코드**: ```javascript const getTotalDuration = () => { @@ -350,7 +364,9 @@ export function calculateTotalDuration(tracks) { --- -#### 2.1.4 라이트박스 로직 중복 +#### 2.1.4 라이트박스 로직 중복 ✅ + +**상태**: ✅ 완료 - `hooks/useLightbox.js` 생성 (향후 점진적 적용) **파일**: - `pages/album/pc/AlbumDetail.jsx` @@ -489,10 +505,12 @@ export function useLightbox({ images = [], onClose } = {}) { --- -### 2.2 에러 상태 처리 미흡 +### 2.2 에러 상태 처리 미흡 ✅ **파일**: 대부분의 페이지 컴포넌트 +**상태**: ✅ 완료 - `components/common/ErrorMessage.jsx` 생성 (향후 점진적 적용) + **문제**: ```javascript // 현재 - 에러 처리 없음 @@ -548,10 +566,12 @@ if (isError) return ; --- -### 2.3 로딩 스피너 불일치 +### 2.3 로딩 스피너 불일치 ✅ **파일**: 여러 페이지 +**상태**: ✅ 완료 - `components/common/Loading.jsx`에 `size` prop 추가 (sm, md, lg) + **문제**: ```javascript // PC - 큰 스피너 @@ -595,42 +615,27 @@ if (loading) return ; --- -### 2.4 스토어 미사용 코드 +### 2.4 스토어 미사용 코드 ✅ **파일**: `stores/useAuthStore.js`, `stores/useUIStore.js` -#### useAuthStore 미사용 메서드 +**상태**: ✅ 완료 - 미사용 메서드 및 confirmDialog 코드 삭제 + +#### useAuthStore 미사용 메서드 (삭제됨) ```javascript -// 삭제 대상 -getToken: () => get().token, // 직접 token 접근으로 대체 가능 -checkAuth: () => !!token, // isAuthenticated로 대체 가능 +// 삭제 완료 +getToken: () => get().token, // 직접 token 접근으로 대체 +checkAuth: () => !!token, // isAuthenticated로 대체 ``` -#### useUIStore 미사용 코드 (약 30줄) +#### useUIStore 미사용 코드 (삭제됨, 약 25줄) ```javascript -// 삭제 대상 - 전체 confirmDialog 관련 코드 +// 삭제 완료 - 전체 confirmDialog 관련 코드 confirmDialog: null, - -showConfirm: (options) => - set({ - confirmDialog: { - isOpen: true, - title: options.title || '확인', - message: options.message || '', - confirmText: options.confirmText || '확인', - cancelText: options.cancelText || '취소', - onConfirm: options.onConfirm, - onCancel: options.onCancel, - variant: options.variant || 'default', - }, - }), - -closeConfirm: () => - set({ - confirmDialog: null, - }), +showConfirm: (options) => ..., +closeConfirm: () => ..., ``` --- diff --git a/docs/frontend-refactoring.md b/docs/frontend-refactoring.md index 07211c2..576077d 100644 --- a/docs/frontend-refactoring.md +++ b/docs/frontend-refactoring.md @@ -1557,7 +1557,8 @@ rm -rf frontend-backup | 8 | Album 페이지 | ✅ 완료 | | 9 | 기타 Public 페이지 (Home, Members) | ✅ 완료 | | 9-1 | NotFound 페이지 | ✅ 완료 | -| 9-2 | 코드 품질 개선 (Critical/High) | 🔄 진행 중 | +| 9-2 | 코드 품질 개선 - Critical | ✅ 완료 | +| 9-3 | 코드 품질 개선 - High | ✅ 완료 | | 10 | Admin 페이지 | ⬜ 대기 | | 11 | 최종 검증 및 교체 | ⬜ 대기 | @@ -1638,16 +1639,17 @@ rm -rf frontend-backup | 항목 | 파일 | 상태 | |-----|------|------| -| useAdminAuth 무한 루프 위험 | `hooks/useAdminAuth.js` | ⬜ 대기 | -| queryKey 충돌 | `hooks/useAdminAuth.js` | ⬜ 대기 | -| 카드 컴포넌트 memo 적용 | `components/*/ScheduleCard.jsx` 등 | ⬜ 대기 | -| 접근성(a11y) 개선 | 모든 컴포넌트 | ⬜ 대기 | +| useAdminAuth 무한 루프 위험 | `hooks/useAdminAuth.js` | ✅ 완료 | +| queryKey 충돌 | `hooks/useAdminAuth.js` | ✅ 완료 | +| 카드 컴포넌트 memo 적용 | `components/*/ScheduleCard.jsx` 등 | ✅ 완료 | +| 접근성(a11y) 개선 | 모든 컴포넌트 | ✅ 완료 | +| 스토어 미사용 코드 삭제 | `stores/useAuthStore.js`, `stores/useUIStore.js` | ✅ 완료 | ### High (높은 우선순위) | 항목 | 파일 | 상태 | |-----|------|------| -| 중복 함수 유틸리티화 | `utils/youtube.js`, `hooks/useLightbox.js` | ⬜ 대기 | -| 에러 상태 처리 | 모든 페이지 | ⬜ 대기 | -| 로딩 스피너 통일 | 모든 페이지 | ⬜ 대기 | -| 스토어 미사용 코드 삭제 | `stores/useAuthStore.js`, `stores/useUIStore.js` | ⬜ 대기 | +| 중복 함수 유틸리티화 | `utils/youtube.js`, `utils/format.js` | ✅ 완료 | +| useLightbox 훅 생성 | `hooks/useLightbox.js` | ✅ 완료 | +| ErrorMessage 컴포넌트 | `components/common/ErrorMessage.jsx` | ✅ 완료 | +| Loading 컴포넌트 size prop | `components/common/Loading.jsx` | ✅ 완료 | diff --git a/frontend-temp/src/components/common/ErrorMessage.jsx b/frontend-temp/src/components/common/ErrorMessage.jsx new file mode 100644 index 0000000..54ba915 --- /dev/null +++ b/frontend-temp/src/components/common/ErrorMessage.jsx @@ -0,0 +1,36 @@ +import { motion } from 'framer-motion'; +import { AlertCircle, RefreshCw } from 'lucide-react'; + +/** + * 에러 메시지 컴포넌트 + * @param {string} message - 에러 메시지 + * @param {function} onRetry - 재시도 콜백 함수 + * @param {string} className - 추가 CSS 클래스 + */ +function ErrorMessage({ + message = '데이터를 불러오는데 실패했습니다.', + onRetry, + className = '', +}) { + return ( + + + ); +} + +export default ErrorMessage; diff --git a/frontend-temp/src/components/common/Loading.jsx b/frontend-temp/src/components/common/Loading.jsx index 2ca5896..b99c515 100644 --- a/frontend-temp/src/components/common/Loading.jsx +++ b/frontend-temp/src/components/common/Loading.jsx @@ -1,10 +1,20 @@ /** * 로딩 컴포넌트 + * @param {string} size - 크기 ('sm' | 'md' | 'lg') + * @param {string} className - 추가 CSS 클래스 */ -function Loading({ className = '' }) { +function Loading({ size = 'md', className = '' }) { + const sizeClasses = { + sm: 'h-6 w-6 border-2', + md: 'h-8 w-8 border-3', + lg: 'h-12 w-12 border-4', + }; + return (
-
+
); } diff --git a/frontend-temp/src/hooks/useLightbox.js b/frontend-temp/src/hooks/useLightbox.js new file mode 100644 index 0000000..63c18c9 --- /dev/null +++ b/frontend-temp/src/hooks/useLightbox.js @@ -0,0 +1,125 @@ +import { useState, useEffect, useCallback } from 'react'; + +/** + * 라이트박스 상태 및 동작 관리 훅 + * @param {Object} options + * @param {Array<{url: string, thumb_url?: string}|string>} options.images - 이미지 배열 + * @param {Function} options.onClose - 닫기 콜백 (optional) + * @returns {Object} 라이트박스 상태 및 메서드 + */ +export function useLightbox({ images = [], onClose } = {}) { + const [isOpen, setIsOpen] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + + // 라이트박스 열기 + const open = useCallback((index = 0) => { + setCurrentIndex(index); + setIsOpen(true); + window.history.pushState({ lightbox: true }, ''); + }, []); + + // 라이트박스 닫기 + const close = useCallback(() => { + setIsOpen(false); + onClose?.(); + }, [onClose]); + + // 이전 이미지 + const goToPrev = useCallback(() => { + if (images.length <= 1) return; + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1)); + }, [images.length]); + + // 다음 이미지 + const goToNext = useCallback(() => { + if (images.length <= 1) return; + setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0)); + }, [images.length]); + + // 특정 인덱스로 이동 + const goToIndex = useCallback( + (index) => { + if (index >= 0 && index < images.length) { + setCurrentIndex(index); + } + }, + [images.length] + ); + + // 뒤로가기 처리 + useEffect(() => { + if (!isOpen) return; + + const handlePopState = () => { + close(); + }; + + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, [isOpen, close]); + + // body 스크롤 방지 (Lightbox 컴포넌트에서 처리하므로 여기선 생략) + + // 이미지 프리로딩 + useEffect(() => { + if (!isOpen || images.length === 0) return; + + // 현재, 이전, 다음 이미지 프리로드 + const indicesToPreload = [ + currentIndex, + (currentIndex + 1) % images.length, + (currentIndex - 1 + images.length) % images.length, + ]; + + indicesToPreload.forEach((index) => { + const img = new Image(); + const imageItem = images[index]; + img.src = typeof imageItem === 'string' ? imageItem : imageItem?.url || imageItem; + }); + }, [isOpen, currentIndex, images]); + + // 이미지 다운로드 + const downloadImage = useCallback(async () => { + const imageItem = images[currentIndex]; + const imageUrl = typeof imageItem === 'string' ? imageItem : imageItem?.url || imageItem; + if (!imageUrl) return; + + try { + const response = await fetch(imageUrl); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `image_${currentIndex + 1}.jpg`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error('이미지 다운로드 실패:', error); + } + }, [images, currentIndex]); + + // 현재 이미지 URL 가져오기 + const getCurrentImageUrl = useCallback(() => { + const imageItem = images[currentIndex]; + return typeof imageItem === 'string' ? imageItem : imageItem?.url || imageItem; + }, [images, currentIndex]); + + return { + isOpen, + currentIndex, + currentImage: images[currentIndex], + currentImageUrl: getCurrentImageUrl(), + totalCount: images.length, + open, + close, + goToPrev, + goToNext, + goToIndex, + downloadImage, + setCurrentIndex, + }; +} + +export default useLightbox; diff --git a/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx b/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx index 4ef8495..854987e 100644 --- a/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx +++ b/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx @@ -18,7 +18,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'; import { Virtual } from 'swiper/modules'; import 'swiper/css'; import { getAlbumByName } from '@/api/albums'; -import { formatDate } from '@/utils'; +import { formatDate, calculateTotalDuration } from '@/utils'; import { LightboxIndicator } from '@/components/common'; /** @@ -105,19 +105,7 @@ function MobileAlbumDetail() { }, [lightbox.open, showDescriptionModal]); // 총 재생 시간 계산 - const getTotalDuration = () => { - if (!album?.tracks) return ''; - let totalSeconds = 0; - album.tracks.forEach((track) => { - if (track.duration) { - const parts = track.duration.split(':'); - totalSeconds += parseInt(parts[0]) * 60 + parseInt(parts[1]); - } - }); - const mins = Math.floor(totalSeconds / 60); - const secs = totalSeconds % 60; - return `${mins}:${String(secs).padStart(2, '0')}`; - }; + const totalDuration = calculateTotalDuration(album?.tracks); if (loading) { return ( @@ -186,7 +174,7 @@ function MobileAlbumDetail() {
- {getTotalDuration()} + {totalDuration}
diff --git a/frontend-temp/src/pages/album/mobile/TrackDetail.jsx b/frontend-temp/src/pages/album/mobile/TrackDetail.jsx index 9354e8c..fa68988 100644 --- a/frontend-temp/src/pages/album/mobile/TrackDetail.jsx +++ b/frontend-temp/src/pages/album/mobile/TrackDetail.jsx @@ -4,28 +4,17 @@ import { useQuery } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { Clock, User, Music, Mic2, ChevronDown, ChevronUp } from 'lucide-react'; import { getTrack } from '@/api/albums'; +import { getYoutubeVideoId, parseCredits } from '@/utils'; /** - * 유튜브 URL에서 비디오 ID 추출 - */ -function getYoutubeVideoId(url) { - if (!url) return null; - const patterns = [/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/]; - for (const pattern of patterns) { - const match = url.match(pattern); - if (match) return match[1]; - } - return null; -} - -/** - * 쉼표 기준 줄바꿈 처리 + * 크레딧 텍스트를 줄바꿈으로 렌더링 */ function formatCredit(text) { - if (!text) return null; - return text.split(',').map((item, index) => ( + const credits = parseCredits(text); + if (credits.length === 0) return null; + return credits.map((item, index) => ( - {item.trim()} + {item} )); } diff --git a/frontend-temp/src/pages/album/pc/AlbumDetail.jsx b/frontend-temp/src/pages/album/pc/AlbumDetail.jsx index 5e52890..6570f80 100644 --- a/frontend-temp/src/pages/album/pc/AlbumDetail.jsx +++ b/frontend-temp/src/pages/album/pc/AlbumDetail.jsx @@ -14,7 +14,7 @@ import { FileText, } from 'lucide-react'; import { getAlbumByName } from '@/api/albums'; -import { formatDate } from '@/utils'; +import { formatDate, calculateTotalDuration } from '@/utils'; import { LightboxIndicator } from '@/components/common'; /** @@ -164,19 +164,7 @@ function PCAlbumDetail() { }, [lightbox.open, lightbox.index, lightbox.images, preloadedImages]); // 총 재생 시간 계산 - const getTotalDuration = () => { - if (!album?.tracks) return ''; - let totalSeconds = 0; - album.tracks.forEach((track) => { - if (track.duration) { - const parts = track.duration.split(':'); - totalSeconds += parseInt(parts[0]) * 60 + parseInt(parts[1]); - } - }); - const mins = Math.floor(totalSeconds / 60); - const secs = totalSeconds % 60; - return `${mins}:${String(secs).padStart(2, '0')}`; - }; + const totalDuration = calculateTotalDuration(album?.tracks); // 타이틀곡 찾기 const getTitleTrack = () => { @@ -302,7 +290,7 @@ function PCAlbumDetail() {
- {getTotalDuration()} + {totalDuration}
diff --git a/frontend-temp/src/pages/album/pc/TrackDetail.jsx b/frontend-temp/src/pages/album/pc/TrackDetail.jsx index b8b2be4..845ae59 100644 --- a/frontend-temp/src/pages/album/pc/TrackDetail.jsx +++ b/frontend-temp/src/pages/album/pc/TrackDetail.jsx @@ -4,28 +4,17 @@ import { useQuery } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { Clock, User, Music, Mic2, ChevronRight } from 'lucide-react'; import { getTrack } from '@/api/albums'; +import { getYoutubeVideoId, parseCredits } from '@/utils'; /** - * 유튜브 URL에서 비디오 ID 추출 - */ -function getYoutubeVideoId(url) { - if (!url) return null; - const patterns = [/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/]; - for (const pattern of patterns) { - const match = url.match(pattern); - if (match) return match[1]; - } - return null; -} - -/** - * 쉼표 기준 줄바꿈 처리 + * 크레딧 텍스트를 줄바꿈으로 렌더링 */ function formatCredit(text) { - if (!text) return null; - return text.split(',').map((item, index) => ( + const credits = parseCredits(text); + if (credits.length === 0) return null; + return credits.map((item, index) => ( - {item.trim()} + {item} )); } diff --git a/frontend-temp/src/utils/format.js b/frontend-temp/src/utils/format.js index bba1881..031600a 100644 --- a/frontend-temp/src/utils/format.js +++ b/frontend-temp/src/utils/format.js @@ -92,3 +92,35 @@ export const truncateText = (text, maxLength) => { if (text.length <= maxLength) return text; return `${text.slice(0, maxLength)}...`; }; + +/** + * 크레딧 텍스트를 배열로 분리 + * @param {string} text - 쉼표로 구분된 크레딧 텍스트 + * @returns {string[]} 크레딧 배열 + */ +export const parseCredits = (text) => { + if (!text) return []; + return text.split(',').map(item => item.trim()).filter(Boolean); +}; + +/** + * 트랙 목록의 총 재생 시간 계산 + * @param {Array<{duration?: string}>} tracks - 트랙 배열 (duration: "MM:SS" 형식) + * @returns {string} 총 재생 시간 ("MM:SS" 형식) + */ +export const calculateTotalDuration = (tracks) => { + if (!tracks || !Array.isArray(tracks)) return ''; + + const totalSeconds = tracks.reduce((acc, track) => { + if (!track.duration) return acc; + const parts = track.duration.split(':'); + if (parts.length !== 2) return acc; + return acc + parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + }, 0); + + if (totalSeconds === 0) return ''; + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; diff --git a/frontend-temp/src/utils/index.js b/frontend-temp/src/utils/index.js index f7b3625..02f9ee2 100644 --- a/frontend-temp/src/utils/index.js +++ b/frontend-temp/src/utils/index.js @@ -31,8 +31,17 @@ export { formatFileSize, formatDuration, truncateText, + parseCredits, + calculateTotalDuration, } from './format'; +// YouTube 관련 +export { + getYoutubeVideoId, + getYoutubeThumbnail, + getYoutubeEmbedUrl, +} from './youtube'; + // 스케줄 관련 export { getCategoryId, diff --git a/frontend-temp/src/utils/youtube.js b/frontend-temp/src/utils/youtube.js new file mode 100644 index 0000000..5eefc96 --- /dev/null +++ b/frontend-temp/src/utils/youtube.js @@ -0,0 +1,48 @@ +/** + * YouTube 관련 유틸리티 함수 + */ + +/** + * YouTube URL에서 비디오 ID 추출 + * @param {string} url - YouTube URL + * @returns {string|null} 비디오 ID 또는 null + */ +export function getYoutubeVideoId(url) { + if (!url) return null; + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + ]; + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) return match[1]; + } + return null; +} + +/** + * YouTube 썸네일 URL 생성 + * @param {string} videoId - YouTube 비디오 ID + * @param {string} quality - 썸네일 품질 (default, hqdefault, mqdefault, sddefault, maxresdefault) + * @returns {string} 썸네일 URL + */ +export function getYoutubeThumbnail(videoId, quality = 'hqdefault') { + if (!videoId) return ''; + return `https://img.youtube.com/vi/${videoId}/${quality}.jpg`; +} + +/** + * YouTube 임베드 URL 생성 + * @param {string} videoId - YouTube 비디오 ID + * @param {Object} options - 임베드 옵션 + * @param {boolean} options.autoplay - 자동 재생 여부 + * @param {boolean} options.controls - 컨트롤 표시 여부 + * @returns {string} 임베드 URL + */ +export function getYoutubeEmbedUrl(videoId, options = {}) { + if (!videoId) return ''; + const params = new URLSearchParams(); + if (options.autoplay) params.set('autoplay', '1'); + if (options.controls === false) params.set('controls', '0'); + const queryString = params.toString(); + return `https://www.youtube.com/embed/${videoId}${queryString ? `?${queryString}` : ''}`; +}