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 (
+
+
+ {message}
+ {onRetry && (
+
+ )}
+
+ );
+}
+
+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}` : ''}`;
+}