자동완성 버그 픽스 .

main
hbyang 2026-01-29 17:34:12 +09:00
parent 6d89a28982
commit e997b2b5af
6 changed files with 319 additions and 76 deletions

View File

@ -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;
}

View File

@ -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}

View File

@ -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>
);
};

View File

@ -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;
}
};
// 폼 제출 처리

View File

@ -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>

View File

@ -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',