배경음악 기능 추가 및 예외 처리 수정
parent
cf27da30b4
commit
a155a98767
49
index.css
49
index.css
|
|
@ -8046,6 +8046,51 @@
|
|||
background: #379599;
|
||||
}
|
||||
|
||||
.lyrics-paragraphs {
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
scrollbar-color: #046266 transparent;
|
||||
}
|
||||
|
||||
.lyrics-paragraphs::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.lyrics-paragraphs::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.lyrics-paragraphs::-webkit-scrollbar-thumb {
|
||||
background: #046266;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.lyrics-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.lyrics-tag {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
color: #379599;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.lyrics-paragraph {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
color: #CEE5E6;
|
||||
line-height: 1.9;
|
||||
white-space: pre-line;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lyrics-placeholder {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
|
@ -10572,6 +10617,10 @@
|
|||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.social-posting-channel-placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.social-posting-channel-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
google-site-verification: google60b514c02fd6af4e.html
|
||||
|
|
@ -0,0 +1 @@
|
|||
naver-site-verification: naver33dfe258205b0af1416aa0ac18c8c0b3.html
|
||||
|
|
@ -142,6 +142,10 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const hasBeenOpenedRef = useRef(false);
|
||||
const loadedForTaskIdRef = useRef<string | null>(null);
|
||||
const loadedAtRef = useRef<number>(0);
|
||||
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
|
||||
const SEO_CACHE_TTL = 50 * 60 * 1000;
|
||||
|
||||
// Upload progress modal state
|
||||
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
||||
|
|
@ -207,11 +211,28 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
|
||||
// 소셜 계정 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (!isOpen) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
loadSocialAccounts();
|
||||
|
||||
const taskId = video?.task_id ?? null;
|
||||
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
|
||||
|
||||
if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) {
|
||||
loadedForTaskIdRef.current = taskId;
|
||||
loadedAtRef.current = now;
|
||||
loadAutocomplete();
|
||||
} else if (taskId) {
|
||||
const cached = seoCache.current.get(taskId);
|
||||
if (cached) {
|
||||
setTitle(cached.title);
|
||||
setDescription(cached.description);
|
||||
setTags(cached.tags);
|
||||
}
|
||||
}, [isOpen, video]);
|
||||
}
|
||||
}, [isOpen, video?.task_id]);
|
||||
|
||||
const loadSocialAccounts = async () => {
|
||||
setIsLoadingAccounts(true);
|
||||
|
|
@ -252,6 +273,11 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||
seoCache.current.set(video.task_id, {
|
||||
title: autoSeoResponse.title || '',
|
||||
description: autoSeoResponse.description || '',
|
||||
tags: autoSeoResponse.keywords?.join(',') || '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load autocomplete:', error);
|
||||
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
||||
|
|
@ -530,7 +556,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
|
||||
>
|
||||
<div className="social-posting-channel-selected">
|
||||
{selectedAccount && (
|
||||
{selectedAccount ? (
|
||||
<>
|
||||
<img
|
||||
src={getPlatformIcon(selectedAccount.platform)}
|
||||
|
|
@ -539,6 +565,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
/>
|
||||
<span>{selectedAccount.display_name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="social-posting-channel-placeholder">{t('social.selectChannel')}</span>
|
||||
)}
|
||||
</div>
|
||||
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@
|
|||
"imageAlt": "Image",
|
||||
"uploadBadge": "Uploaded",
|
||||
"imageUpload": "Image Upload",
|
||||
"dragAndDrop": "Drag and drop\nimages to upload",
|
||||
"dragAndDrop": "Drag & drop or\nclick to upload",
|
||||
"videoRatio": "Video Ratio",
|
||||
"minImages": "Min. 5 images",
|
||||
"youtubeShorts": "YouTube Shorts",
|
||||
|
|
@ -234,6 +234,7 @@
|
|||
"lyricsColumn": "Lyrics",
|
||||
"lyricsHint": "Select the lyrics area to edit",
|
||||
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
|
||||
"lyricsPlaceholderBGM": "Background music is generated without lyrics.",
|
||||
"generateButton": "Generate Sound",
|
||||
"regenerateButton": "Regenerate",
|
||||
"regenerateHint": "Press the regenerate button to create new lyrics and music.",
|
||||
|
|
@ -306,6 +307,7 @@
|
|||
"resolution": "Resolution",
|
||||
"lyrics": "Lyrics",
|
||||
"sampleLyrics": "Loading lyrics...",
|
||||
"noLyricsBGM": "Background music is generated without lyrics.",
|
||||
"downloading": "Downloading...",
|
||||
"download": "Download",
|
||||
"uploadToSocial": "Upload to Social"
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@
|
|||
"imageAlt": "이미지",
|
||||
"uploadBadge": "업로드",
|
||||
"imageUpload": "이미지 업로드",
|
||||
"dragAndDrop": "이미지를 드래그하여\n업로드",
|
||||
"dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드",
|
||||
"videoRatio": "영상 비율",
|
||||
"minImages": "최소 5장",
|
||||
"youtubeShorts": "유튜브 쇼츠",
|
||||
|
|
@ -234,6 +234,7 @@
|
|||
"lyricsColumn": "가사",
|
||||
"lyricsHint": "가사 영역을 선택해서 수정 가능해요",
|
||||
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
|
||||
"lyricsPlaceholderBGM": "배경음악은 가사 없이 생성됩니다.",
|
||||
"generateButton": "사운드 생성",
|
||||
"regenerateButton": "재생성 하기",
|
||||
"regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.",
|
||||
|
|
@ -306,6 +307,7 @@
|
|||
"resolution": "규격",
|
||||
"lyrics": "가사",
|
||||
"sampleLyrics": "가사를 불러오는 중...",
|
||||
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
|
||||
"downloading": "다운로드 중...",
|
||||
"download": "다운로드",
|
||||
"uploadToSocial": "소셜 채널 업로드"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,87 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getVideosList, deleteVideo } from '../../utils/api';
|
||||
import { VideoListItem } from '../../types/api';
|
||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||
|
||||
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const isPlayingRef = useRef(false);
|
||||
const pendingPlayRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
video.preload = 'auto';
|
||||
} else {
|
||||
video.preload = 'none';
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(video);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const safePlay = useCallback((video: HTMLVideoElement) => {
|
||||
const playPromise = video.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => { isPlayingRef.current = true; })
|
||||
.catch((error) => {
|
||||
if (error.name !== 'AbortError') console.error(error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCanPlay = useCallback(() => {
|
||||
if (!pendingPlayRef.current || !videoRef.current) return;
|
||||
pendingPlayRef.current = false;
|
||||
safePlay(videoRef.current);
|
||||
}, [safePlay]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.readyState >= 3) {
|
||||
safePlay(video);
|
||||
} else {
|
||||
pendingPlayRef.current = true;
|
||||
}
|
||||
}, [safePlay]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
pendingPlayRef.current = false;
|
||||
if (isPlayingRef.current) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
isPlayingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className={className}
|
||||
muted
|
||||
playsInline
|
||||
preload="none"
|
||||
onCanPlay={handleCanPlay}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ADO2ContentsPageProps {
|
||||
onBack: () => void;
|
||||
onNavigate?: (item: string) => void;
|
||||
|
|
@ -166,16 +243,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack, onNavigate
|
|||
{/* Video Thumbnail */}
|
||||
<div className="content-card-thumbnail">
|
||||
{video.result_movie_url ? (
|
||||
<video
|
||||
<VideoPreviewCard
|
||||
src={video.result_movie_url}
|
||||
className="content-video-preview"
|
||||
muted
|
||||
playsInline
|
||||
onMouseEnter={(e) => e.currentTarget.play()}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.pause();
|
||||
e.currentTarget.currentTime = 0;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="content-no-video">
|
||||
|
|
|
|||
|
|
@ -484,7 +484,9 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
<div className="comp2-lyrics-section">
|
||||
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
|
||||
<p className="comp2-lyrics-text">
|
||||
{songCompletionData?.lyrics || t('completion.sampleLyrics')}
|
||||
{songCompletionData
|
||||
? (songCompletionData.lyrics || t('completion.noLyricsBGM'))
|
||||
: t('completion.sampleLyrics')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -553,7 +553,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
|||
<PlatformIcon platform={item.platform} size={16} />
|
||||
<span style={{
|
||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
|
||||
color: '#9bcacc', lineHeight: 1,
|
||||
color: '#9bcacc', lineHeight: 1.4,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{item.platform_username || item.platform_user_id || item.channel_name}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
|
||||
try {
|
||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||
const isInstrumental = selectedType === '배경음악';
|
||||
|
||||
// 1. 가사 생성 요청
|
||||
console.log('[SoundStudio] Sending m_id:', mId);
|
||||
|
|
@ -287,12 +288,27 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
region: businessInfo.region,
|
||||
task_id: imageTaskId,
|
||||
orientation,
|
||||
...(isInstrumental && { instrumental: true }),
|
||||
});
|
||||
|
||||
if (!lyricResponse.success || !lyricResponse.task_id) {
|
||||
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
||||
}
|
||||
|
||||
const genreMap: Record<string, string> = {
|
||||
'자동 선택': 'pop',
|
||||
'K-POP': 'kpop',
|
||||
'발라드': 'ballad',
|
||||
'Hip-Hop': 'hip-hop',
|
||||
'R&B': 'rnb',
|
||||
'EDM': 'edm',
|
||||
'JAZZ': 'jazz',
|
||||
'ROCK': 'rock',
|
||||
};
|
||||
|
||||
let songLyrics: string | undefined;
|
||||
|
||||
if (!isInstrumental) {
|
||||
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||
const lyricDetailResponse = await waitForLyricComplete(
|
||||
|
|
@ -314,25 +330,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
}
|
||||
|
||||
setLyrics(lyricDetailResponse.lyric_result);
|
||||
songLyrics = lyricDetailResponse.lyric_result;
|
||||
}
|
||||
|
||||
setStatus('generating_song');
|
||||
setStatusMessage(t('soundStudio.generatingSong'));
|
||||
|
||||
const genreMap: Record<string, string> = {
|
||||
'자동 선택': 'pop',
|
||||
'K-POP': 'kpop',
|
||||
'발라드': 'ballad',
|
||||
'Hip-Hop': 'hip-hop',
|
||||
'R&B': 'rnb',
|
||||
'EDM': 'edm',
|
||||
'JAZZ': 'jazz',
|
||||
'ROCK': 'rock',
|
||||
};
|
||||
|
||||
const songResponse = await generateSong(imageTaskId, {
|
||||
genre: genreMap[selectedGenre] || 'pop',
|
||||
language,
|
||||
lyrics: lyricDetailResponse.lyric_result,
|
||||
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
|
||||
});
|
||||
|
||||
if (!songResponse.success) {
|
||||
|
|
@ -351,7 +357,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
setStatus('polling');
|
||||
setStatusMessage(t('soundStudio.generatingSong'));
|
||||
|
||||
await resumePolling(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 0);
|
||||
await resumePolling(songResponse.task_id, songResponse.song_id, songLyrics ?? '', 0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Music generation failed:', error);
|
||||
|
|
@ -412,7 +418,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
<div className="sound-type-grid">
|
||||
{[
|
||||
{ key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false },
|
||||
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: true },
|
||||
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: false },
|
||||
].map(({ key, label, disabled }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
|
@ -483,8 +489,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
{['한국어', 'English', '中文'].map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => !isGenerating && setSelectedLang(lang)}
|
||||
disabled={isGenerating}
|
||||
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
|
||||
disabled={isGenerating || selectedType === '배경음악'}
|
||||
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
||||
>
|
||||
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
||||
|
|
@ -495,8 +501,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => !isGenerating && setSelectedLang(lang)}
|
||||
disabled={isGenerating}
|
||||
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
|
||||
disabled={isGenerating || selectedType === '배경음악'}
|
||||
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
||||
>
|
||||
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
||||
|
|
@ -598,16 +604,28 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
|
||||
{/* Lyrics Display */}
|
||||
<div className="lyrics-display">
|
||||
{lyrics ? (
|
||||
<textarea
|
||||
value={lyrics}
|
||||
readOnly
|
||||
className="lyrics-textarea"
|
||||
placeholder={t('soundStudio.lyricsPlaceholder')}
|
||||
/>
|
||||
) : (
|
||||
{lyrics ? (() => {
|
||||
const lines = lyrics.split('\n').filter(l => l.trim());
|
||||
const size = Math.ceil(lines.length / 3);
|
||||
const outroLines = lines.slice(size * 2).slice(0, 2);
|
||||
const sections = [
|
||||
{ tag: '[Verse]', lines: lines.slice(0, size) },
|
||||
{ tag: '[Chorus]', lines: lines.slice(size, size * 2) },
|
||||
{ tag: '[Outro]', lines: outroLines },
|
||||
].filter(s => s.lines.length > 0);
|
||||
return (
|
||||
<div className="lyrics-paragraphs">
|
||||
{sections.map((section, i) => (
|
||||
<div key={i} className="lyrics-section">
|
||||
<span className="lyrics-tag">{section.tag}</span>
|
||||
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<div className="lyrics-placeholder">
|
||||
{t('soundStudio.lyricsPlaceholder')}
|
||||
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export interface LyricGenerateRequest {
|
|||
region: string;
|
||||
task_id: string;
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
instrumental?: boolean;
|
||||
}
|
||||
|
||||
// 가사 생성 응답
|
||||
|
|
@ -103,8 +104,9 @@ export interface LyricDetailResponse {
|
|||
// 노래 생성 요청
|
||||
export interface SongGenerateRequest {
|
||||
genre: string;
|
||||
language: string;
|
||||
lyrics: string;
|
||||
language?: string;
|
||||
lyrics?: string;
|
||||
instrumental?: boolean;
|
||||
}
|
||||
|
||||
// 노래 생성 응답
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => null);
|
||||
console.error('[generateSong] 422 detail:', JSON.stringify(errorBody));
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
|
|
@ -477,14 +479,20 @@ export async function authenticatedFetch(
|
|||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
// 401 에러 시 토큰 갱신 시도
|
||||
// 401 에러 시 에러 코드에 따라 처리
|
||||
if (response.status === 401) {
|
||||
const errorBody = await response.json().catch(() => null);
|
||||
const errorCode = errorBody?.detail?.code;
|
||||
|
||||
if (errorCode !== 'TOKEN_EXPIRED') {
|
||||
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
|
||||
redirectToLogin();
|
||||
throw new Error(errorCode ?? 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
// 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지)
|
||||
// refreshPromise가 존재하면 재사용, 없으면 새로 생성
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshAccessToken().finally(() => {
|
||||
// 성공/실패 상관없이 Promise 초기화 (다음 갱신을 위해)
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
|
@ -501,7 +509,6 @@ export async function authenticatedFetch(
|
|||
response = await fetch(url, { ...options, headers: newHeaders });
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
// 토큰 갱신 실패 시 로그인 페이지로 리다이렉트
|
||||
redirectToLogin();
|
||||
throw refreshError;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue