자동완성 버그 픽스 .
parent
6d89a28982
commit
e997b2b5af
85
index.css
85
index.css
|
|
@ -2447,7 +2447,8 @@
|
||||||
border-bottom: none;
|
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);
|
background-color: rgba(148, 251, 224, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2810,7 +2811,8 @@
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-autocomplete-item:hover {
|
.hero-autocomplete-item:hover,
|
||||||
|
.hero-autocomplete-item.highlighted {
|
||||||
background-color: #F9FAFB;
|
background-color: #F9FAFB;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6336,3 +6338,82 @@
|
||||||
gap: 12px;
|
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 AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
|
||||||
import LoginSection from './pages/Login/LoginSection';
|
import LoginSection from './pages/Login/LoginSection';
|
||||||
import GenerationFlow from './pages/Dashboard/GenerationFlow';
|
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';
|
import { CrawlingResponse } from './types/api';
|
||||||
|
|
||||||
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
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 () => {
|
const handleToLogin = async () => {
|
||||||
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
|
|
@ -337,6 +361,7 @@ const App: React.FC = () => {
|
||||||
<section className="landing-section">
|
<section className="landing-section">
|
||||||
<HeroSection
|
<HeroSection
|
||||||
onAnalyze={handleStartAnalysis}
|
onAnalyze={handleStartAnalysis}
|
||||||
|
onAutocomplete={handleAutocomplete}
|
||||||
onNext={() => scrollToSection(1)}
|
onNext={() => scrollToSection(1)}
|
||||||
error={error}
|
error={error}
|
||||||
scrollProgress={scrollProgress}
|
scrollProgress={scrollProgress}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||||
const [hasNext, setHasNext] = useState(false);
|
const [hasNext, setHasNext] = useState(false);
|
||||||
const [hasPrev, setHasPrev] = useState(false);
|
const [hasPrev, setHasPrev] = useState(false);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
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;
|
const pageSize = 12;
|
||||||
|
|
||||||
|
|
@ -80,15 +83,36 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (taskId: string) => {
|
const handleDeleteClick = (taskId: string) => {
|
||||||
if (!confirm('이 콘텐츠를 삭제하시겠습니까?')) return;
|
setDeleteTargetId(taskId);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTargetId) return;
|
||||||
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
await deleteVideo(taskId);
|
await deleteVideo(deleteTargetId);
|
||||||
// 삭제 성공 후 목록 새로고침
|
|
||||||
fetchVideos();
|
// 삭제 성공 시 로컬 상태에서 즉시 제거 (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) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err);
|
console.error('Delete failed:', err);
|
||||||
alert('삭제에 실패했습니다.');
|
alert('삭제에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -176,7 +200,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="content-delete-btn"
|
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">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||||
|
|
@ -210,6 +234,32 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
||||||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -66,6 +67,37 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
||||||
setSelectedItem(item); // 선택된 업체 정보 저장
|
setSelectedItem(item); // 선택된 업체 정보 저장
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setAutocompleteResults([]);
|
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 [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
|
||||||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||||
const [showAutocomplete, setShowAutocomplete] = 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 orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const animationRefs = useRef<number[]>([]);
|
const animationRefs = useRef<number[]>([]);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -107,20 +109,49 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
}
|
}
|
||||||
}, [searchType]);
|
}, [searchType]);
|
||||||
|
|
||||||
// 자동완성 항목 선택
|
// 자동완성 항목 선택 - 업체 정보 저장
|
||||||
const handleSelectAutocomplete = async (item: AccommodationSearchItem) => {
|
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
||||||
const request: AutocompleteRequest = {
|
|
||||||
address: item.address,
|
|
||||||
roadAddress: item.roadAddress,
|
|
||||||
title: item.title,
|
|
||||||
};
|
|
||||||
|
|
||||||
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
||||||
|
setSelectedItem(item); // 선택된 업체 정보 저장
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setAutocompleteResults([]);
|
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('');
|
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);
|
onAnalyze(inputValue, searchType);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -312,6 +353,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
|
setHighlightedIndex(-1); // 입력 시 하이라이트 초기화
|
||||||
if (localError) setLocalError('');
|
if (localError) setLocalError('');
|
||||||
|
|
||||||
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
||||||
|
|
@ -331,6 +373,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={getPlaceholder()}
|
placeholder={getPlaceholder()}
|
||||||
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -345,11 +388,12 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
type="button"
|
||||||
className="hero-autocomplete-item"
|
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelectAutocomplete(item);
|
handleSelectAutocomplete(item);
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
>
|
>
|
||||||
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
||||||
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
|
<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
|
// 가사 생성 API
|
||||||
export async function generateLyric(request: LyricGenerateRequest): Promise<LyricGenerateResponse> {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
});
|
});
|
||||||
|
|
@ -77,11 +76,8 @@ export async function generateLyric(request: LyricGenerateRequest): Promise<Lyri
|
||||||
|
|
||||||
// 가사 상태 조회 API
|
// 가사 상태 조회 API
|
||||||
export async function getLyricStatus(taskId: string): Promise<LyricStatusResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -93,11 +89,8 @@ export async function getLyricStatus(taskId: string): Promise<LyricStatusRespons
|
||||||
|
|
||||||
// 가사 상세 조회 API
|
// 가사 상세 조회 API
|
||||||
export async function getLyricDetail(taskId: string): Promise<LyricDetailResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -149,11 +142,10 @@ export async function waitForLyricComplete(
|
||||||
|
|
||||||
// 노래 생성 API (task_id는 URL 경로에 포함)
|
// 노래 생성 API (task_id는 URL 경로에 포함)
|
||||||
export async function generateSong(taskId: string, request: SongGenerateRequest): Promise<SongGenerateResponse> {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
});
|
});
|
||||||
|
|
@ -167,11 +159,8 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
|
||||||
|
|
||||||
// 노래 상태 조회 API (Suno Polling)
|
// 노래 상태 조회 API (Suno Polling)
|
||||||
export async function getSongStatus(songId: string): Promise<SongStatusResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -183,11 +172,8 @@ export async function getSongStatus(songId: string): Promise<SongStatusResponse>
|
||||||
|
|
||||||
// 노래 다운로드 API
|
// 노래 다운로드 API
|
||||||
export async function downloadSong(taskId: string): Promise<SongDownloadResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -248,11 +234,8 @@ export async function waitForSongComplete(
|
||||||
|
|
||||||
// 영상 생성 API
|
// 영상 생성 API
|
||||||
export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -264,11 +247,8 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h
|
||||||
|
|
||||||
// 영상 상태 확인 API
|
// 영상 상태 확인 API
|
||||||
export async function getVideoStatus(taskId: string): Promise<VideoStatusResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -296,10 +276,11 @@ export async function getVideoStatus(taskId: string): Promise<VideoStatusRespons
|
||||||
|
|
||||||
// 비디오 목록 조회 API
|
// 비디오 목록 조회 API
|
||||||
export async function getVideosList(page: number = 1, pageSize: number = 10): Promise<VideosListResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
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
|
// 비디오 삭제 API
|
||||||
export async function deleteVideo(taskId: string): Promise<void> {
|
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',
|
method: 'DELETE',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -348,11 +326,8 @@ export async function uploadImages(
|
||||||
const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT);
|
const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/image/upload/blob`, {
|
const response = await authenticatedFetch(`${API_URL}/image/upload/blob`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
body: formData,
|
body: formData,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
@ -449,6 +424,54 @@ function getAuthHeader(): HeadersInit {
|
||||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
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 획득
|
// 카카오 로그인 URL 획득
|
||||||
export async function getKakaoLoginUrl(): Promise<KakaoLoginUrlResponse> {
|
export async function getKakaoLoginUrl(): Promise<KakaoLoginUrlResponse> {
|
||||||
const response = await fetch(`${API_URL}/user/auth/kakao/login`, {
|
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> {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 응답과 관계없이 로컬 토큰 삭제
|
// 응답과 관계없이 로컬 토큰 삭제
|
||||||
|
|
@ -550,11 +570,8 @@ export async function logout(): Promise<void> {
|
||||||
|
|
||||||
// 모든 기기에서 로그아웃
|
// 모든 기기에서 로그아웃
|
||||||
export async function logoutAll(): 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',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 응답과 관계없이 로컬 토큰 삭제
|
// 응답과 관계없이 로컬 토큰 삭제
|
||||||
|
|
@ -567,11 +584,8 @@ export async function logoutAll(): Promise<void> {
|
||||||
|
|
||||||
// 현재 사용자 정보 조회
|
// 현재 사용자 정보 조회
|
||||||
export async function getUserMe(): Promise<UserMeResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -610,11 +624,8 @@ export interface AutocompleteRequest {
|
||||||
|
|
||||||
// 숙소 검색 API (업체명 자동완성용)
|
// 숙소 검색 API (업체명 자동완성용)
|
||||||
export async function searchAccommodation(query: string): Promise<AccommodationSearchResponse> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
|
||||||
...getAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -630,7 +641,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
|
||||||
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/autocomplete`, {
|
const response = await authenticatedFetch(`${API_URL}/autocomplete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue