자동완성 버그 픽스 .
parent
6d89a28982
commit
e997b2b5af
85
index.css
85
index.css
|
|
@ -2447,7 +2447,8 @@
|
|||
border-bottom: none;
|
||||
}
|
||||
|
||||
.url-input-autocomplete-item:hover {
|
||||
.url-input-autocomplete-item:hover,
|
||||
.url-input-autocomplete-item.highlighted {
|
||||
background-color: rgba(148, 251, 224, 0.1);
|
||||
}
|
||||
|
||||
|
|
@ -2810,7 +2811,8 @@
|
|||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hero-autocomplete-item:hover {
|
||||
.hero-autocomplete-item:hover,
|
||||
.hero-autocomplete-item.highlighted {
|
||||
background-color: #F9FAFB;
|
||||
}
|
||||
|
||||
|
|
@ -6336,3 +6338,82 @@
|
|||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Delete Confirmation Modal
|
||||
===================================================== */
|
||||
.delete-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.delete-modal {
|
||||
background: linear-gradient(180deg, #1A3A3E 0%, #0D2426 100%);
|
||||
border: 1px solid rgba(148, 251, 224, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 32px 40px;
|
||||
min-width: 360px;
|
||||
max-width: 90vw;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.delete-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.delete-modal-description {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0 0 28px 0;
|
||||
}
|
||||
|
||||
.delete-modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.delete-modal-btn {
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.delete-modal-btn.cancel {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: #FFFFFF;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.delete-modal-btn.cancel:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.delete-modal-btn.confirm {
|
||||
background-color: #EF4444;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.delete-modal-btn.confirm:hover {
|
||||
background-color: #DC2626;
|
||||
}
|
||||
|
||||
.delete-modal-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
|
|||
27
src/App.tsx
27
src/App.tsx
|
|
@ -8,7 +8,7 @@ import LoadingSection from './pages/Analysis/LoadingSection';
|
|||
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
|
||||
import LoginSection from './pages/Login/LoginSection';
|
||||
import GenerationFlow from './pages/Dashboard/GenerationFlow';
|
||||
import { crawlUrl, kakaoCallback, isLoggedIn, saveTokens } from './utils/api';
|
||||
import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
|
||||
import { CrawlingResponse } from './types/api';
|
||||
|
||||
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
||||
|
|
@ -251,6 +251,30 @@ const App: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// 업체명 자동완성으로 분석 시작
|
||||
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
||||
setViewMode('loading');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await autocomplete(request);
|
||||
|
||||
// 응답 유효성 검사
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error('업체 정보를 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||
setViewMode('analysis');
|
||||
} catch (err) {
|
||||
console.error('Autocomplete failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.';
|
||||
setError(errorMessage);
|
||||
setViewMode('landing');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToLogin = async () => {
|
||||
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||
if (isLoggedIn()) {
|
||||
|
|
@ -337,6 +361,7 @@ const App: React.FC = () => {
|
|||
<section className="landing-section">
|
||||
<HeroSection
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onNext={() => scrollToSection(1)}
|
||||
error={error}
|
||||
scrollProgress={scrollProgress}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
const [hasNext, setHasNext] = useState(false);
|
||||
const [hasPrev, setHasPrev] = useState(false);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const pageSize = 12;
|
||||
|
||||
|
|
@ -80,15 +83,36 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (taskId: string) => {
|
||||
if (!confirm('이 콘텐츠를 삭제하시겠습니까?')) return;
|
||||
const handleDeleteClick = (taskId: string) => {
|
||||
setDeleteTargetId(taskId);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteModalOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteVideo(taskId);
|
||||
// 삭제 성공 후 목록 새로고침
|
||||
fetchVideos();
|
||||
await deleteVideo(deleteTargetId);
|
||||
|
||||
// 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
|
||||
setVideos(prev => prev.filter(video => video.task_id !== deleteTargetId));
|
||||
setTotal(prev => Math.max(0, prev - 1));
|
||||
|
||||
setDeleteModalOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
|
||||
// 서버와 동기화를 위해 목록 새로고침
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
alert('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -176,7 +200,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
</button>
|
||||
<button
|
||||
className="content-delete-btn"
|
||||
onClick={() => handleDelete(video.task_id)}
|
||||
onClick={() => handleDeleteClick(video.task_id)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||
|
|
@ -210,6 +234,32 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{deleteModalOpen && (
|
||||
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
||||
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<h2 className="delete-modal-title">정말 콘텐츠를 삭제할까요?</h2>
|
||||
<p className="delete-modal-description">삭제한 파일은 복구할 수 없어요.</p>
|
||||
<div className="delete-modal-actions">
|
||||
<button
|
||||
className="delete-modal-btn cancel"
|
||||
onClick={handleDeleteCancel}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
className="delete-modal-btn confirm"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? '삭제 중...' : '삭제'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -66,6 +67,37 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||
setSelectedItem(item); // 선택된 업체 정보 저장
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteResults([]);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
// 키보드 네비게이션 핸들러
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!showAutocomplete || autocompleteResults.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev < autocompleteResults.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev > 0 ? prev - 1 : autocompleteResults.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
|
||||
e.preventDefault();
|
||||
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setShowAutocomplete(false);
|
||||
setHighlightedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 제출 처리
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
|
||||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationRefs = useRef<number[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -107,20 +109,49 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
}
|
||||
}, [searchType]);
|
||||
|
||||
// 자동완성 항목 선택
|
||||
const handleSelectAutocomplete = async (item: AccommodationSearchItem) => {
|
||||
const request: AutocompleteRequest = {
|
||||
address: item.address,
|
||||
roadAddress: item.roadAddress,
|
||||
title: item.title,
|
||||
};
|
||||
|
||||
// 자동완성 항목 선택 - 업체 정보 저장
|
||||
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
||||
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
||||
setSelectedItem(item); // 선택된 업체 정보 저장
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteResults([]);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
if (onAutocomplete) {
|
||||
onAutocomplete(request);
|
||||
// 키보드 네비게이션 핸들러
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// 자동완성이 표시될 때만 키보드 네비게이션 활성화
|
||||
if (showAutocomplete && autocompleteResults.length > 0) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev < autocompleteResults.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev > 0 ? prev - 1 : autocompleteResults.length - 1
|
||||
);
|
||||
return;
|
||||
case 'Enter':
|
||||
e.preventDefault(); // 항상 Enter 기본 동작 방지
|
||||
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
|
||||
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
|
||||
}
|
||||
return;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setShowAutocomplete(false);
|
||||
setHighlightedIndex(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 업체명 검색 모드에서 Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
|
||||
if (e.key === 'Enter' && searchType === 'name') {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -226,7 +257,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
}
|
||||
|
||||
setLocalError('');
|
||||
if (onAnalyze) {
|
||||
|
||||
if (searchType === 'name' && selectedItem && onAutocomplete) {
|
||||
// 업체명 검색인 경우 autocomplete API 호출
|
||||
const request: AutocompleteRequest = {
|
||||
address: selectedItem.address,
|
||||
roadAddress: selectedItem.roadAddress,
|
||||
title: selectedItem.title,
|
||||
};
|
||||
onAutocomplete(request);
|
||||
} else if (onAnalyze) {
|
||||
// URL 검색인 경우 기존 로직
|
||||
onAnalyze(inputValue, searchType);
|
||||
}
|
||||
};
|
||||
|
|
@ -312,6 +353,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setInputValue(value);
|
||||
setHighlightedIndex(-1); // 입력 시 하이라이트 초기화
|
||||
if (localError) setLocalError('');
|
||||
|
||||
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
||||
|
|
@ -331,6 +373,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
}
|
||||
}}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={getPlaceholder()}
|
||||
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
||||
/>
|
||||
|
|
@ -345,11 +388,12 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className="hero-autocomplete-item"
|
||||
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAutocomplete(item);
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
||||
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
|
||||
|
|
|
|||
121
src/utils/api.ts
121
src/utils/api.ts
|
|
@ -59,11 +59,10 @@ export async function crawlUrl(url: string): Promise<CrawlingResponse> {
|
|||
|
||||
// 가사 생성 API
|
||||
export async function generateLyric(request: LyricGenerateRequest): Promise<LyricGenerateResponse> {
|
||||
const response = await fetch(`${API_URL}/lyric/generate`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/lyric/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
|
@ -77,11 +76,8 @@ export async function generateLyric(request: LyricGenerateRequest): Promise<Lyri
|
|||
|
||||
// 가사 상태 조회 API
|
||||
export async function getLyricStatus(taskId: string): Promise<LyricStatusResponse> {
|
||||
const response = await fetch(`${API_URL}/lyric/status/${taskId}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/lyric/status/${taskId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -93,11 +89,8 @@ export async function getLyricStatus(taskId: string): Promise<LyricStatusRespons
|
|||
|
||||
// 가사 상세 조회 API
|
||||
export async function getLyricDetail(taskId: string): Promise<LyricDetailResponse> {
|
||||
const response = await fetch(`${API_URL}/lyric/${taskId}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/lyric/${taskId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -149,11 +142,10 @@ export async function waitForLyricComplete(
|
|||
|
||||
// 노래 생성 API (task_id는 URL 경로에 포함)
|
||||
export async function generateSong(taskId: string, request: SongGenerateRequest): Promise<SongGenerateResponse> {
|
||||
const response = await fetch(`${API_URL}/song/generate/${taskId}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/song/generate/${taskId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
|
@ -167,11 +159,8 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
|
|||
|
||||
// 노래 상태 조회 API (Suno Polling)
|
||||
export async function getSongStatus(songId: string): Promise<SongStatusResponse> {
|
||||
const response = await fetch(`${API_URL}/song/status/${songId}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/song/status/${songId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -183,11 +172,8 @@ export async function getSongStatus(songId: string): Promise<SongStatusResponse>
|
|||
|
||||
// 노래 다운로드 API
|
||||
export async function downloadSong(taskId: string): Promise<SongDownloadResponse> {
|
||||
const response = await fetch(`${API_URL}/song/download/${taskId}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/song/download/${taskId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -248,11 +234,8 @@ export async function waitForSongComplete(
|
|||
|
||||
// 영상 생성 API
|
||||
export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> {
|
||||
const response = await fetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -264,11 +247,8 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h
|
|||
|
||||
// 영상 상태 확인 API
|
||||
export async function getVideoStatus(taskId: string): Promise<VideoStatusResponse> {
|
||||
const response = await fetch(`${API_URL}/video/status/${taskId}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/video/status/${taskId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -296,10 +276,11 @@ export async function getVideoStatus(taskId: string): Promise<VideoStatusRespons
|
|||
|
||||
// 비디오 목록 조회 API
|
||||
export async function getVideosList(page: number = 1, pageSize: number = 10): Promise<VideosListResponse> {
|
||||
const response = await fetch(`${API_URL}/archive/videos/?page=${page}&page_size=${pageSize}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/archive/videos/?page=${page}&page_size=${pageSize}&_t=${Date.now()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -312,11 +293,8 @@ export async function getVideosList(page: number = 1, pageSize: number = 10): Pr
|
|||
|
||||
// 비디오 삭제 API
|
||||
export async function deleteVideo(taskId: string): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/archive/videos/delete/${taskId}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/archive/videos/delete/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -348,11 +326,8 @@ export async function uploadImages(
|
|||
const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/image/upload/blob`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/image/upload/blob`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -449,6 +424,54 @@ function getAuthHeader(): HeadersInit {
|
|||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// 토큰 갱신 중복 방지를 위한 플래그
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<TokenRefreshResponse> | null = null;
|
||||
|
||||
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
|
||||
async function authenticatedFetch(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
// 인증 헤더 추가
|
||||
const headers = {
|
||||
...options.headers,
|
||||
...getAuthHeader(),
|
||||
};
|
||||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
// 401 에러 시 토큰 갱신 시도
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
// 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지)
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
refreshPromise = refreshAccessToken();
|
||||
}
|
||||
|
||||
await refreshPromise;
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
|
||||
// 새 토큰으로 재요청
|
||||
const newHeaders = {
|
||||
...options.headers,
|
||||
...getAuthHeader(),
|
||||
};
|
||||
response = await fetch(url, { ...options, headers: newHeaders });
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
// 토큰 갱신 실패 시 로그인 페이지로 리다이렉트할 수 있음
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 카카오 로그인 URL 획득
|
||||
export async function getKakaoLoginUrl(): Promise<KakaoLoginUrlResponse> {
|
||||
const response = await fetch(`${API_URL}/user/auth/kakao/login`, {
|
||||
|
|
@ -533,11 +556,8 @@ export async function refreshAccessToken(): Promise<TokenRefreshResponse> {
|
|||
|
||||
// 로그아웃
|
||||
export async function logout(): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/user/auth/logout`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
// 응답과 관계없이 로컬 토큰 삭제
|
||||
|
|
@ -550,11 +570,8 @@ export async function logout(): Promise<void> {
|
|||
|
||||
// 모든 기기에서 로그아웃
|
||||
export async function logoutAll(): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/user/auth/logout/all`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/user/auth/logout/all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
// 응답과 관계없이 로컬 토큰 삭제
|
||||
|
|
@ -567,11 +584,8 @@ export async function logoutAll(): Promise<void> {
|
|||
|
||||
// 현재 사용자 정보 조회
|
||||
export async function getUserMe(): Promise<UserMeResponse> {
|
||||
const response = await fetch(`${API_URL}/user/auth/me`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/user/auth/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -610,11 +624,8 @@ export interface AutocompleteRequest {
|
|||
|
||||
// 숙소 검색 API (업체명 자동완성용)
|
||||
export async function searchAccommodation(query: string): Promise<AccommodationSearchResponse> {
|
||||
const response = await fetch(`${API_URL}/search/accommodation?query=${encodeURIComponent(query)}`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/search/accommodation?query=${encodeURIComponent(query)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -630,7 +641,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
|
|||
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/autocomplete`, {
|
||||
const response = await authenticatedFetch(`${API_URL}/autocomplete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
Loading…
Reference in New Issue