diff --git a/index.css b/index.css index 39db717..c586663 100644 --- a/index.css +++ b/index.css @@ -2230,6 +2230,15 @@ margin-bottom: 24px; } +.url-input-logo { + margin-bottom: 40px; +} + +.url-input-logo img { + height: 48px; + width: auto; +} + .url-input-title { font-family: 'Pretendard', sans-serif; font-size: 40px; @@ -2258,30 +2267,32 @@ .url-input-wrapper { display: flex; - gap: 12px; + align-items: stretch; width: 100%; + background-color: #01393B; + border: 1px solid #034A4D; + border-radius: 12px; + overflow: visible; + position: relative; } .url-input-field { flex: 1; + width: 100%; padding: 16px 20px; - background-color: #01393B; - border: 1px solid #034A4D; - border-radius: 12px; + background-color: transparent; + border: none; font-family: 'Pretendard', sans-serif; font-size: 16px; color: #E5F1F2; outline: none; - transition: border-color 0.2s ease; + text-align: left; } .url-input-field::placeholder { color: #6AB0B3; } -.url-input-field:focus { - border-color: #AE72F9; -} .url-input-button { padding: 16px 32px; @@ -2321,6 +2332,142 @@ margin: 24px 0 0 0; } +/* URL Input Dropdown */ +.url-input-dropdown-container { + position: relative; + flex-shrink: 0; + width: auto; +} + +.url-input-dropdown-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 16px; + background-color: transparent; + border: none; + font-family: 'Pretendard', sans-serif; + font-size: 14px; + color: #E5F1F2; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.2s ease; +} + +.url-input-dropdown-trigger:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.url-input-dropdown-arrow { + transition: transform 0.2s ease; +} + +.url-input-dropdown-arrow.open { + transform: rotate(180deg); +} + +.url-input-dropdown-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 100%; + background-color: #01393B; + border: 1px solid #034A4D; + border-radius: 12px; + overflow: hidden; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.url-input-dropdown-item { + display: block; + width: 100%; + padding: 12px 16px; + background: none; + border: none; + font-family: 'Pretendard', sans-serif; + font-size: 14px; + color: #9BCACC; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.url-input-dropdown-item:hover { + background-color: #034A4D; + color: #E5F1F2; +} + +.url-input-dropdown-item.active { + background-color: #034A4D; + color: #94FBE0; +} + +/* URL Input Autocomplete (Dashboard) */ +.url-input-field-container { + position: relative; + flex: 1; + display: flex; +} + +.url-input-autocomplete-dropdown { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + background-color: #002224; + border: 1px solid rgba(148, 251, 224, 0.2); + border-radius: 12px; + max-height: 300px; + overflow-y: auto; + z-index: 100; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.url-input-autocomplete-loading { + padding: 16px; + text-align: center; + color: rgba(255, 255, 255, 0.6); + font-size: 14px; +} + +.url-input-autocomplete-item { + display: block; + width: 100%; + padding: 12px 16px; + background: transparent; + border: none; + border-bottom: 1px solid rgba(148, 251, 224, 0.1); + text-align: left; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.url-input-autocomplete-item:last-child { + border-bottom: none; +} + +.url-input-autocomplete-item:hover { + background-color: rgba(148, 251, 224, 0.1); +} + +.url-input-autocomplete-title { + font-size: 14px; + font-weight: 500; + color: #E5F1F2; + margin-bottom: 4px; +} + +.url-input-autocomplete-title b { + color: #94FBE0; + font-weight: 600; +} + +.url-input-autocomplete-address { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + /* ===================================================== Landing Page Components ===================================================== */ @@ -2490,6 +2637,8 @@ gap: 10px; transition: box-shadow 0.2s ease; box-sizing: border-box; + overflow: visible; + position: relative; } .hero-input-wrapper.focused { @@ -2543,6 +2692,146 @@ height: 24px; } +/* Hero Dropdown */ +.hero-dropdown-container { + position: relative; + flex-shrink: 0; +} + +.hero-dropdown-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background-color: transparent; + border: none; + border-right: 1px solid #E5E7EB; + font-family: 'Pretendard', sans-serif; + font-size: 14px; + font-weight: 600; + color: #374151; + cursor: pointer; + white-space: nowrap; + transition: color 0.2s ease; + margin-right: 12px; +} + +.hero-dropdown-trigger:hover { + color: #111827; +} + +.hero-dropdown-arrow { + transition: transform 0.2s ease; +} + +.hero-dropdown-arrow.open { + transform: rotate(180deg); +} + +.hero-dropdown-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + min-width: 100px; + background-color: #ffffff; + border: 1px solid #E5E7EB; + border-radius: 12px; + overflow: hidden; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.hero-dropdown-item { + display: block; + width: 100%; + padding: 12px 16px; + background: none; + border: none; + font-family: 'Pretendard', sans-serif; + font-size: 14px; + color: #6B7280; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.hero-dropdown-item:hover { + background-color: #F3F4F6; + color: #111827; +} + +.hero-dropdown-item.active { + background-color: #F0FDF4; + color: #059669; +} + +/* Hero Autocomplete */ +.hero-input-container { + flex: 1; + position: relative; + min-width: 0; +} + +.hero-autocomplete-dropdown { + position: absolute; + top: calc(100% + 8px); + left: -24px; + right: -60px; + background-color: #ffffff; + border: 1px solid #E5E7EB; + border-radius: 12px; + overflow: hidden; + z-index: 200; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); + max-height: 300px; + overflow-y: auto; +} + +.hero-autocomplete-loading { + padding: 16px; + text-align: center; + color: #6B7280; + font-size: 14px; +} + +.hero-autocomplete-item { + display: block; + width: 100%; + padding: 12px 16px; + background: none; + border: none; + border-bottom: 1px solid #F3F4F6; + text-align: left; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.hero-autocomplete-item:last-child { + border-bottom: none; +} + +.hero-autocomplete-item:hover { + background-color: #F9FAFB; +} + +.hero-autocomplete-title { + font-family: 'Pretendard', sans-serif; + font-size: 14px; + font-weight: 600; + color: #111827; + margin-bottom: 4px; +} + +.hero-autocomplete-title b { + color: #059669; +} + +.hero-autocomplete-address { + font-family: 'Pretendard', sans-serif; + font-size: 12px; + color: #6B7280; +} + .hero-input-hint { font-size: 12px; font-weight: 400; diff --git a/src/pages/Analysis/AnalysisResultSection.tsx b/src/pages/Analysis/AnalysisResultSection.tsx index 5148a4b..50de724 100755 --- a/src/pages/Analysis/AnalysisResultSection.tsx +++ b/src/pages/Analysis/AnalysisResultSection.tsx @@ -2,7 +2,6 @@ import React, { useMemo } from 'react'; import { CrawlingResponse } from '../../types/api'; import GeometricChart, { USP } from './GeometricChart'; -import KeywordBubble from './KeywordBubble'; interface AnalysisResultSectionProps { onBack: () => void; @@ -74,11 +73,12 @@ const parseMarkdownBlocks = (text: string): MarkdownBlock[] => { }; const renderInlineMarkdown = (text: string): React.ReactNode[] => { - const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`|#[^\s#]+)/g).filter(Boolean); + // Use non-greedy .+? instead of [^*]+ to handle edge cases better + const parts = text.split(/(\*\*.+?\*\*|`[^`]+`|#[^\s#]+)/g).filter(Boolean); return parts.map((part, idx) => { if (part.startsWith('**') && part.endsWith('**')) { return ( - + {part.slice(2, -2)} ); @@ -245,12 +245,6 @@ const buildTargets = (sectionText: string, tags: string[]) => { }); }; -const ArrowLeftIcon = () => ( - - - -); - const SparklesIcon = ({ className = '' }: { className?: string }) => ( @@ -280,12 +274,6 @@ const UsersIcon = () => ( ); -const CrownIcon = ({ className = '' }: { className?: string }) => ( - - - -); - const TrendingUpIcon = () => ( @@ -327,12 +315,11 @@ const AnalysisResultSection: React.FC = ({ onBack, o return (
-
-
@@ -345,7 +332,7 @@ const AnalysisResultSection: React.FC = ({ onBack, o

브랜드 인텔리전스

- AI 데이터 분석을 통해 도출된 브랜드 전략입니다. + AI 데이터 분석을 통해 도출된 {processed_info.customer_name}의 핵심 전략입니다.

@@ -390,11 +377,11 @@ const AnalysisResultSection: React.FC = ({ onBack, o 카테고리 정의 - {positioningCategory} +
{renderMarkdown(positioningCategory)}
핵심 가치 (Core Value) - {positioningCore} +
{renderMarkdown(positioningCore)}
@@ -411,7 +398,7 @@ const AnalysisResultSection: React.FC = ({ onBack, o >
- {target.segment} + {renderInlineMarkdown(target.segment)}
{target.age &&
{target.age}
}
@@ -423,14 +410,14 @@ const AnalysisResultSection: React.FC = ({ onBack, o key={i} className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium" > - {need} + {renderInlineMarkdown(need)} ))} )} {target.triggers.length > 0 && (

- Trigger: {target.triggers.join(', ')} + Trigger: {target.triggers.map((t: string, i: number) => {i > 0 && ', '}{renderInlineMarkdown(t)})}

)} @@ -444,10 +431,6 @@ const AnalysisResultSection: React.FC = ({ onBack, o

주요 셀링 포인트 (USP)

-
- - AI Data Analysis -
@@ -455,51 +438,56 @@ const AnalysisResultSection: React.FC = ({ onBack, o
{topUSP && ( -
-
- -
-
-
- Core Competitiveness -
-
-
-
{topUSP.label}
-
{topUSP.subLabel}
+
+
+
+
+ {topUSP.subLabel} + CORE
+
{topUSP.label}
+
{topUSP.description}
+
{topUSP.score}
)} -
+
{usps .filter((usp) => usp.label !== topUSP?.label) - .slice(0, 4) .map((usp, idx) => (
-
-
{usp.subLabel}
+
+
{usp.subLabel}
+
{usp.label}
+
{usp.description}
-
{usp.label}
-
{usp.description}
+
{usp.score}
))}
-
-

추천 타겟 키워드

-
- {tags.length === 0 && 정보 없음} - {tags.map((keyword, idx) => ( - - ))} -
+
+
+ +
+
+

추천 타겟 키워드

+
+ {tags.length === 0 && 정보 없음} + {tags.map((keyword, idx) => ( + + # {keyword} + + ))}
diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx index 7919c8d..e1eb77c 100644 --- a/src/pages/Dashboard/ADO2ContentsPage.tsx +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { getVideosList } from '../../utils/api'; +import { getVideosList, deleteVideo } from '../../utils/api'; import { VideoListItem } from '../../types/api'; interface ADO2ContentsPageProps { @@ -82,8 +82,14 @@ const ADO2ContentsPage: React.FC = ({ onBack }) => { const handleDelete = async (taskId: string) => { if (!confirm('이 콘텐츠를 삭제하시겠습니까?')) return; - // TODO: 삭제 API 연동 - alert('삭제 기능은 아직 구현되지 않았습니다.'); + try { + await deleteVideo(taskId); + // 삭제 성공 후 목록 새로고침 + fetchVideos(); + } catch (err) { + console.error('Delete failed:', err); + alert('삭제에 실패했습니다.'); + } }; return ( diff --git a/src/pages/Dashboard/UrlInputContent.tsx b/src/pages/Dashboard/UrlInputContent.tsx index 78ce5dd..47d6c5b 100644 --- a/src/pages/Dashboard/UrlInputContent.tsx +++ b/src/pages/Dashboard/UrlInputContent.tsx @@ -1,54 +1,190 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useCallback } from 'react'; +import { searchNaverLocal, NaverLocalSearchItem, AutocompleteRequest } from '../../utils/api'; + +type SearchType = 'url' | 'name'; interface UrlInputContentProps { - onAnalyze: (url: string) => void; + onAnalyze: (value: string, type?: SearchType) => void; + onAutocomplete?: (data: AutocompleteRequest) => void; error: string | null; } -const UrlInputContent: React.FC = ({ onAnalyze, error }) => { - const [url, setUrl] = useState(''); +const UrlInputContent: React.FC = ({ onAnalyze, onAutocomplete, error }) => { + const [inputValue, setInputValue] = useState(''); + const [searchType, setSearchType] = useState('url'); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [autocompleteResults, setAutocompleteResults] = useState([]); + const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); + const [showAutocomplete, setShowAutocomplete] = useState(false); + const debounceRef = useRef(null); + const autocompleteRef = useRef(null); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (url.trim()) { - onAnalyze(url.trim()); + if (inputValue.trim()) { + onAnalyze(inputValue.trim(), searchType); + } + }; + + const searchTypeOptions = [ + { value: 'url' as SearchType, label: 'URL' }, + { value: 'name' as SearchType, label: '업체명' }, + ]; + + const getPlaceholder = () => { + return searchType === 'url' + ? 'https://www.castad.com' + : '업체명을 입력하세요'; + }; + + const getGuideText = () => { + return searchType === 'url' + ? 'URL에서 가져온 정보로 영상이 자동 생성됩니다.' + : '업체명으로 검색하여 정보를 가져옵니다.'; + }; + + // 업체명 검색 시 자동완성 (디바운스 적용) + const handleAutocompleteSearch = useCallback(async (query: string) => { + if (!query.trim() || searchType !== 'name') { + setAutocompleteResults([]); + setShowAutocomplete(false); + return; + } + + setIsAutocompleteLoading(true); + try { + const response = await searchNaverLocal(query); + setAutocompleteResults(response.items || []); + setShowAutocomplete(response.items && response.items.length > 0); + } catch (error) { + console.error('자동완성 검색 오류:', error); + setAutocompleteResults([]); + setShowAutocomplete(false); + } finally { + setIsAutocompleteLoading(false); + } + }, [searchType]); + + // 자동완성 항목 선택 + const handleSelectAutocomplete = (item: NaverLocalSearchItem) => { + const request: AutocompleteRequest = { + address: item.address, + roadAddress: item.roadAddress, + title: item.title, + }; + + setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거 + setShowAutocomplete(false); + setAutocompleteResults([]); + + if (onAutocomplete) { + onAutocomplete(request); } }; return (
- {/* 아이콘 */} -
- - - + {/* 로고 */} +
+ ADO2
- {/* 제목 */} -

브랜드 분석

-

- 쉽고 빠르게, 브랜드 소셜 미디어 캠페인을 만드세요. -

- {/* URL 입력 폼 */}
- setUrl(e.target.value)} - placeholder="네이버 지도 URL을 입력하세요" - className="url-input-field" - /> - + {/* 드롭다운 */} +
+ + {isDropdownOpen && ( +
+ {searchTypeOptions.map((option) => ( + + ))} +
+ )} +
+ + {/* 입력 필드 */} +
+ { + const value = e.target.value; + setInputValue(value); + + // 업체명 검색일 때 자동완성 검색 (디바운스) + if (searchType === 'name') { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + handleAutocompleteSearch(value); + }, 300); + } + }} + onFocus={() => { + if (searchType === 'name' && autocompleteResults.length > 0) { + setShowAutocomplete(true); + } + }} + placeholder={getPlaceholder()} + className="url-input-field" + /> + + {/* 자동완성 결과 */} + {showAutocomplete && searchType === 'name' && ( +
+ {isAutocompleteLoading ? ( +
검색 중...
+ ) : ( + autocompleteResults.map((item, index) => ( + + )) + )} +
+ )} +
{/* 에러 메시지 */} @@ -59,7 +195,7 @@ const UrlInputContent: React.FC = ({ onAnalyze, error }) = {/* 안내 텍스트 */}

- 네이버 지도에서 업체 URL을 복사하여 붙여넣기 해주세요. + {getGuideText()}

diff --git a/src/pages/Landing/HeroSection.tsx b/src/pages/Landing/HeroSection.tsx index 14ccd42..31c32b9 100755 --- a/src/pages/Landing/HeroSection.tsx +++ b/src/pages/Landing/HeroSection.tsx @@ -1,8 +1,12 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { searchNaverLocal, NaverLocalSearchItem, AutocompleteRequest } from '../../utils/api'; + +type SearchType = 'url' | 'name'; interface HeroSectionProps { - onAnalyze?: (url: string) => void; + onAnalyze?: (value: string, type?: SearchType) => void; + onAutocomplete?: (data: AutocompleteRequest) => void; onNext?: () => void; error?: string | null; scrollProgress?: number; // 0 ~ 1 (스크롤 진행률) @@ -47,12 +51,78 @@ const orbConfigs: OrbConfig[] = [ { id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 }, ]; -const HeroSection: React.FC = ({ onAnalyze, onNext, error: externalError, scrollProgress = 0 }) => { - const [url, setUrl] = useState(''); +const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, onNext, error: externalError, scrollProgress = 0 }) => { + const [inputValue, setInputValue] = useState(''); + const [searchType, setSearchType] = useState('url'); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [localError, setLocalError] = useState(''); const [isFocused, setIsFocused] = useState(false); + const [autocompleteResults, setAutocompleteResults] = useState([]); + const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); + const [showAutocomplete, setShowAutocomplete] = useState(false); const orbRefs = useRef<(HTMLDivElement | null)[]>([]); const animationRefs = useRef([]); + const dropdownRef = useRef(null); + const autocompleteRef = useRef(null); + const debounceRef = useRef(null); + + const searchTypeOptions = [ + { value: 'url' as SearchType, label: 'URL' }, + { value: 'name' as SearchType, label: '업체명' }, + ]; + + // 드롭다운 외부 클릭 감지 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) { + setShowAutocomplete(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // 업체명 검색 시 자동완성 (디바운스 적용) + const handleAutocompleteSearch = useCallback(async (query: string) => { + if (!query.trim() || searchType !== 'name') { + setAutocompleteResults([]); + setShowAutocomplete(false); + return; + } + + setIsAutocompleteLoading(true); + try { + const response = await searchNaverLocal(query); + setAutocompleteResults(response.items || []); + setShowAutocomplete(response.items && response.items.length > 0); + } catch (error) { + console.error('자동완성 검색 오류:', error); + setAutocompleteResults([]); + setShowAutocomplete(false); + } finally { + setIsAutocompleteLoading(false); + } + }, [searchType]); + + // 자동완성 항목 선택 + const handleSelectAutocomplete = async (item: NaverLocalSearchItem) => { + const request: AutocompleteRequest = { + address: item.address, + roadAddress: item.roadAddress, + title: item.title, + }; + + setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거 + setShowAutocomplete(false); + setAutocompleteResults([]); + + if (onAutocomplete) { + onAutocomplete(request); + } + }; // Random movement for orbs useEffect(() => { @@ -132,20 +202,32 @@ const HeroSection: React.FC = ({ onAnalyze, onNext, error: ext const error = externalError || localError; + const getPlaceholder = () => { + return searchType === 'url' + ? 'https://www.castad.com' + : '업체명을 입력하세요'; + }; + + const getGuideText = () => { + return searchType === 'url' + ? 'URL에서 가져온 정보로 영상이 자동 생성됩니다.' + : '업체명으로 검색하여 정보를 가져옵니다.'; + }; + const handleStart = () => { - if (!url.trim()) { - setLocalError('URL을 입력해주세요.'); + if (!inputValue.trim()) { + setLocalError(searchType === 'url' ? 'URL을 입력해주세요.' : '업체명을 입력해주세요.'); return; } - if (!isValidUrl(url.trim())) { + if (searchType === 'url' && !isValidUrl(inputValue.trim())) { setLocalError('올바른 URL 형식이 아닙니다. (예: https://example.com)'); return; } setLocalError(''); if (onAnalyze) { - onAnalyze(url); + onAnalyze(inputValue, searchType); } }; @@ -183,34 +265,116 @@ const HeroSection: React.FC = ({ onAnalyze, onNext, error: ext {/* Input Form */}
- URL 입력 -
- { - setUrl(e.target.value); - if (localError) setLocalError(''); - }} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - placeholder="https://www.castad.com" - className={`hero-input ${url ? 'has-value' : ''}`} - /> - {url && ( +
+ {/* 드롭다운 */} +
+ + {isDropdownOpen && ( +
+ {searchTypeOptions.map((option) => ( + + ))} +
+ )} +
+ +
+ { + const value = e.target.value; + setInputValue(value); + if (localError) setLocalError(''); + + // 업체명 검색일 때 자동완성 검색 (디바운스) + if (searchType === 'name') { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + handleAutocompleteSearch(value); + }, 300); + } + }} + onFocus={() => { + setIsFocused(true); + if (searchType === 'name' && autocompleteResults.length > 0) { + setShowAutocomplete(true); + } + }} + onBlur={() => setIsFocused(false)} + placeholder={getPlaceholder()} + className={`hero-input ${inputValue ? 'has-value' : ''}`} + /> + + {/* 자동완성 결과 */} + {showAutocomplete && searchType === 'name' && ( +
+ {isAutocompleteLoading ? ( +
검색 중...
+ ) : ( + autocompleteResults.map((item, index) => ( + + )) + )} +
+ )} +
+ {inputValue && ( )}
- URL에서 가져온 정보로 영상이 자동 생성됩니다. + {getGuideText()} {error && (

{error}

)} diff --git a/src/utils/api.ts b/src/utils/api.ts index c793fc0..2b571bf 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -21,6 +21,8 @@ import { } from '../types/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; +console.log('[API] API_URL:', API_URL); +console.log('[API] VITE_API_URL env:', import.meta.env.VITE_API_URL); // 크롤링 타임아웃: 5분 const CRAWL_TIMEOUT = 5 * 60 * 1000; @@ -61,6 +63,7 @@ export async function generateLyric(request: LyricGenerateRequest): Promise { const response = await fetch(`${API_URL}/lyric/status/${taskId}`, { method: 'GET', + headers: { + ...getAuthHeader(), + }, }); if (!response.ok) { @@ -89,6 +95,9 @@ export async function getLyricStatus(taskId: string): Promise { const response = await fetch(`${API_URL}/lyric/${taskId}`, { method: 'GET', + headers: { + ...getAuthHeader(), + }, }); if (!response.ok) { @@ -144,6 +153,7 @@ export async function generateSong(taskId: string, request: SongGenerateRequest) method: 'POST', headers: { 'Content-Type': 'application/json', + ...getAuthHeader(), }, body: JSON.stringify(request), }); @@ -159,6 +169,9 @@ export async function generateSong(taskId: string, request: SongGenerateRequest) export async function getSongStatus(songId: string): Promise { const response = await fetch(`${API_URL}/song/status/${songId}`, { method: 'GET', + headers: { + ...getAuthHeader(), + }, }); if (!response.ok) { @@ -172,6 +185,9 @@ export async function getSongStatus(songId: string): Promise export async function downloadSong(taskId: string): Promise { const response = await fetch(`${API_URL}/song/download/${taskId}`, { method: 'GET', + headers: { + ...getAuthHeader(), + }, }); if (!response.ok) { @@ -234,6 +250,9 @@ export async function waitForSongComplete( export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise { const response = await fetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, { method: 'GET', + headers: { + ...getAuthHeader(), + }, }); if (!response.ok) { @@ -247,6 +266,9 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h export async function getVideoStatus(taskId: string): Promise { const response = await fetch(`${API_URL}/video/status/${taskId}`, { method: 'GET', + headers: { + ...getAuthHeader(), + }, }); if (!response.ok) { @@ -257,9 +279,28 @@ export async function getVideoStatus(taskId: string): Promise { - const response = await fetch(`${API_URL}/video/download/${taskId}`, { +// export async function downloadVideo(taskId: string): Promise { +// const response = await fetch(`${API_URL}/video/download/${taskId}`, { +// method: 'GET', +// headers: { +// ...getAuthHeader(), +// }, +// }); + +// if (!response.ok) { +// throw new Error(`HTTP error! status: ${response.status}`); +// } + +// return response.json(); +// } + +// 비디오 목록 조회 API +export async function getVideosList(page: number = 1, pageSize: number = 10): Promise { + const response = await fetch(`${API_URL}/archive/videos/?page=${page}&page_size=${pageSize}`, { method: 'GET', + headers: { + ...getAuthHeader(), + }, }); if (!response.ok) { @@ -269,17 +310,18 @@ export async function downloadVideo(taskId: string): Promise { - const response = await fetch(`${API_URL}/videos/?page=${page}&page_size=${pageSize}`, { - method: 'GET', +// 비디오 삭제 API +export async function deleteVideo(taskId: string): Promise { + const response = await fetch(`${API_URL}/archive/videos/delete/${taskId}`, { + method: 'DELETE', + headers: { + ...getAuthHeader(), + }, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - - return response.json(); } // 이미지 업로드 API (multipart/form-data) @@ -308,6 +350,9 @@ export async function uploadImages( try { const response = await fetch(`${API_URL}/image/upload/blob`, { method: 'POST', + headers: { + ...getAuthHeader(), + }, body: formData, signal: controller.signal, }); @@ -401,6 +446,7 @@ export function clearTokens() { // 인증 헤더 생성 function getAuthHeader(): HeadersInit { const token = getAccessToken(); + console.log('[Auth] Token exists:', !!token, token ? `${token.substring(0, 20)}...` : 'null'); return token ? { 'Authorization': `Bearer ${token}` } : {}; } @@ -418,20 +464,39 @@ export async function getKakaoLoginUrl(): Promise { } // 카카오 콜백 처리 (인가 코드로 JWT 토큰 발급) +// 1. callback 호출 후 2. verify로 토큰 발급 export async function kakaoCallback(code: string): Promise { - const response = await fetch(`${API_URL}/user/auth/kakao/callback?code=${encodeURIComponent(code)}`, { + // 1단계: 콜백 처리 + const callbackResponse = await fetch(`${API_URL}/user/auth/kakao/callback?code=${encodeURIComponent(code)}`, { method: 'GET', }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + if (!callbackResponse.ok) { + throw new Error(`Callback HTTP error! status: ${callbackResponse.status}`); } - const data: KakaoCallbackResponse = await response.json(); - + // 2단계: 코드 검증 및 토큰 발급 + const verifyResponse = await fetch(`${API_URL}/user/auth/kakao/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + }); + + if (!verifyResponse.ok) { + throw new Error(`Verify HTTP error! status: ${verifyResponse.status}`); + } + + const data: KakaoCallbackResponse = await verifyResponse.json(); + console.log('[Auth] Kakao verify response:', { + hasAccessToken: !!data.access_token, + hasRefreshToken: !!data.refresh_token + }); // 토큰 저장 saveTokens(data.access_token, data.refresh_token); + console.log('[Auth] Tokens saved to localStorage'); return data; } @@ -525,3 +590,80 @@ export async function getUserMe(): Promise { export function isLoggedIn(): boolean { return !!getAccessToken(); } + +// ============================================ +// 네이버 지역 검색 & 자동완성 API +// ============================================ + +export interface NaverLocalSearchItem { + title: string; + link: string; + category: string; + description: string; + telephone: string; + address: string; + roadAddress: string; + mapx: string; + mapy: string; +} + +export interface NaverLocalSearchResponse { + lastBuildDate: string; + total: number; + start: number; + display: number; + items: NaverLocalSearchItem[]; +} + +export interface AutocompleteRequest { + address: string; + roadAddress: string; + title: string; +} + +// 네이버 지역 검색 API (백엔드 프록시 경유) +export async function searchNaverLocal(query: string): Promise { + const response = await fetch(`${API_URL}/naver/local/search?query=${encodeURIComponent(query)}`, { + method: 'GET', + headers: { + ...getAuthHeader(), + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 자동완성 API (업체 정보로 크롤링) +export async function autocomplete(request: AutocompleteRequest): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT); + + try { + const response = await fetch(`${API_URL}/autocomplete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('자동완성 요청 시간이 초과되었습니다. 다시 시도해주세요.'); + } + throw error; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +///