From e997b2b5af427478b73b79e1d02dceaa23321896 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 29 Jan 2026 17:34:12 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=94=BD=EC=8A=A4=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.css | 85 +++++++++++++++- src/App.tsx | 27 ++++- src/pages/Dashboard/ADO2ContentsPage.tsx | 62 ++++++++++-- src/pages/Dashboard/UrlInputContent.tsx | 32 ++++++ src/pages/Landing/HeroSection.tsx | 68 ++++++++++--- src/utils/api.ts | 121 ++++++++++++----------- 6 files changed, 319 insertions(+), 76 deletions(-) diff --git a/index.css b/index.css index c586663..fb67beb 100644 --- a/index.css +++ b/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; +} diff --git a/src/App.tsx b/src/App.tsx index ea670b0..59e8224 100755 --- a/src/App.tsx +++ b/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 = () => {
scrollToSection(1)} error={error} scrollProgress={scrollProgress} diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx index e1eb77c..8d6fd84 100644 --- a/src/pages/Dashboard/ADO2ContentsPage.tsx +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -16,6 +16,9 @@ const ADO2ContentsPage: React.FC = ({ 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(null); + const [isDeleting, setIsDeleting] = useState(false); const pageSize = 12; @@ -80,15 +83,36 @@ const ADO2ContentsPage: React.FC = ({ 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 = ({ onBack }) => { + + + + + )} ); }; diff --git a/src/pages/Dashboard/UrlInputContent.tsx b/src/pages/Dashboard/UrlInputContent.tsx index d75dbf3..284a9ff 100644 --- a/src/pages/Dashboard/UrlInputContent.tsx +++ b/src/pages/Dashboard/UrlInputContent.tsx @@ -18,6 +18,7 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false); const [selectedItem, setSelectedItem] = useState(null); + const [highlightedIndex, setHighlightedIndex] = useState(-1); const debounceRef = useRef(null); const autocompleteRef = useRef(null); @@ -66,6 +67,37 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp setSelectedItem(item); // 선택된 업체 정보 저장 setShowAutocomplete(false); setAutocompleteResults([]); + setHighlightedIndex(-1); + }; + + // 키보드 네비게이션 핸들러 + const handleKeyDown = (e: React.KeyboardEvent) => { + 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; + } }; // 폼 제출 처리 diff --git a/src/pages/Landing/HeroSection.tsx b/src/pages/Landing/HeroSection.tsx index 1a44d99..88146bf 100755 --- a/src/pages/Landing/HeroSection.tsx +++ b/src/pages/Landing/HeroSection.tsx @@ -60,6 +60,8 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on const [autocompleteResults, setAutocompleteResults] = useState([]); const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [highlightedIndex, setHighlightedIndex] = useState(-1); const orbRefs = useRef<(HTMLDivElement | null)[]>([]); const animationRefs = useRef([]); const dropdownRef = useRef(null); @@ -107,20 +109,49 @@ const HeroSection: React.FC = ({ 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) => { + // 자동완성이 표시될 때만 키보드 네비게이션 활성화 + 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 = ({ 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 = ({ 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 = ({ onAnalyze, onAutocomplete, on } }} onBlur={() => setIsFocused(false)} + onKeyDown={handleKeyDown} placeholder={getPlaceholder()} className={`hero-input ${inputValue ? 'has-value' : ''}`} /> @@ -345,11 +388,12 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on