브랜드 분석 페이지 수정, ado2 컨텐츠 추가, header jwt 인증 작업 .

main
hbyang 2026-01-29 13:27:33 +09:00
parent 7d4f3c7e05
commit 7ee911f0aa
7 changed files with 865 additions and 139 deletions

305
index.css
View File

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

View File

@ -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 (
<strong key={idx} className="text-white">
<strong key={idx} className="text-white font-semibold">
{part.slice(2, -2)}
</strong>
);
@ -245,12 +245,6 @@ const buildTargets = (sectionText: string, tags: string[]) => {
});
};
const ArrowLeftIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
);
const SparklesIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 2l2.4 6.8L22 12l-7.6 3.2L12 22l-2.4-6.8L2 12l7.6-3.2L12 2z" />
@ -280,12 +274,6 @@ const UsersIcon = () => (
</svg>
);
const CrownIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M3 7l4 4 5-6 5 6 4-4v10H3z" />
</svg>
);
const TrendingUpIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 17l6-6 4 4 7-7" />
@ -327,12 +315,11 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
return (
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
<div className="p-6">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 rounded-full border border-brand-muted/30 text-brand-muted hover:text-brand-accent hover:border-brand-accent transition-all text-sm group"
>
<ArrowLeftIcon />
<div className="asset-header">
<button onClick={onBack} className="btn-back-new">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
<span></span>
</button>
</div>
@ -345,7 +332,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</div>
<h1 className="text-4xl font-bold mb-3 tracking-tight text-white"> </h1>
<p className="text-brand-muted text-lg max-w-xl mx-auto">
<span className="text-brand-accent font-semibold">AI </span> .
<span className="text-brand-accent font-semibold">AI </span> {processed_info.customer_name} .
</p>
</div>
@ -390,11 +377,11 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">
</span>
<span className="font-bold text-lg text-white">{positioningCategory}</span>
<div className="font-bold text-lg text-white">{renderMarkdown(positioningCategory)}</div>
</div>
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
<span className="block text-xs text-brand-accent mb-1 font-semibold"> (Core Value)</span>
<span className="font-semibold text-white">{positioningCore}</span>
<div className="font-semibold text-white">{renderMarkdown(positioningCore)}</div>
</div>
</div>
</div>
@ -411,7 +398,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
>
<div className="min-w-[120px]">
<div className="font-bold text-white group-hover:text-brand-accent transition-colors">
{target.segment}
{renderInlineMarkdown(target.segment)}
</div>
{target.age && <div className="text-xs text-brand-muted">{target.age}</div>}
</div>
@ -423,14 +410,14 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ 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)}
</span>
))}
</div>
)}
{target.triggers.length > 0 && (
<p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2">
<span className="text-brand-muted">Trigger:</span> {target.triggers.join(', ')}
<span className="text-brand-muted">Trigger:</span> {target.triggers.map((t: string, i: number) => <span key={i}>{i > 0 && ', '}{renderInlineMarkdown(t)}</span>)}
</p>
)}
</div>
@ -444,10 +431,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden">
<div className="flex justify-between items-center mb-2 z-10">
<h3 className="text-xl font-bold text-white"> (USP)</h3>
<div className="flex items-center gap-1 text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded">
<TrendingUpIcon />
<span>AI Data Analysis</span>
</div>
</div>
<div className="flex-1 flex items-center justify-center relative z-10 -my-4">
@ -455,54 +438,59 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</div>
{topUSP && (
<div className="mt-2 mb-6 p-4 rounded-xl bg-gradient-to-r from-brand-accent/10 to-transparent border border-brand-accent/20 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 opacity-10">
<CrownIcon className="w-16 h-16 text-brand-accent" />
</div>
<div className="relative z-10">
<div className="text-xs text-brand-accent font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<CrownIcon className="w-3 h-3" /> Core Competitiveness
</div>
<div className="flex justify-between items-end">
<div className="mt-2 mb-6 p-4 rounded-xl bg-brand-card border border-brand-muted/20 relative overflow-hidden">
<div className="relative z-10 flex justify-between items-center">
<div>
<div className="text-lg font-bold text-white">{topUSP.label}</div>
<div className="text-xs text-brand-accent/80 font-mono tracking-wider">{topUSP.subLabel}</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-brand-accent font-bold uppercase tracking-wider">{topUSP.subLabel}</span>
<span className="text-xs bg-brand-muted/30 text-white px-2 py-0.5 rounded font-semibold">CORE</span>
</div>
<div className="text-lg font-bold text-white mb-1">{topUSP.label}</div>
<div className="text-sm text-brand-muted">{topUSP.description}</div>
</div>
<div className="text-4xl font-bold text-brand-muted">{topUSP.score}</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 z-10">
<div className="space-y-3 z-10">
{usps
.filter((usp) => usp.label !== topUSP?.label)
.slice(0, 4)
.map((usp, idx) => (
<div
key={idx}
className="p-3 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-colors"
className="p-4 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-colors flex justify-between items-center"
>
<div className="flex justify-between items-start mb-1">
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight">{usp.subLabel}</div>
<div>
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight mb-1">{usp.subLabel}</div>
<div className="text-base font-bold text-white mb-1">{usp.label}</div>
<div className="text-sm text-gray-400">{usp.description}</div>
</div>
<div className="text-sm font-bold text-white mb-1">{usp.label}</div>
<div className="text-xs text-gray-400 leading-tight truncate">{usp.description}</div>
<div className="text-3xl font-bold text-brand-muted ml-4">{usp.score}</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 md:px-8 mt-8">
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden">
<h3 className="text-xl font-bold mb-6 text-center text-white"> </h3>
<div className="flex flex-wrap justify-center gap-3 relative z-10">
<h3 className="text-xl font-bold mb-6 text-white"> </h3>
<div className="flex flex-wrap gap-3 relative z-10">
{tags.length === 0 && <span className="text-sm text-brand-muted"> </span>}
{tags.map((keyword, idx) => (
<KeywordBubble key={idx} text={keyword} />
<span
key={idx}
className="px-5 py-2.5 rounded-full bg-brand-card border border-brand-muted/30 text-white text-sm hover:border-brand-accent/50 transition-colors"
>
# {keyword}
</span>
))}
</div>
</div>
</div>
</div>
<div className="fixed bottom-8 left-0 right-0 flex justify-center z-50 pointer-events-none">
<button

View File

@ -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<ADO2ContentsPageProps> = ({ 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 (

View File

@ -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<UrlInputContentProps> = ({ onAnalyze, error }) => {
const [url, setUrl] = useState('');
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, error }) => {
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('url');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<NaverLocalSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const autocompleteRef = useRef<HTMLDivElement>(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 (
<div className="url-input-container">
<div className="url-input-content">
{/* 아이콘 */}
<div className="url-input-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l2.4 7.2L22 12l-7.6 2.4L12 22l-2.4-7.2L2 12l7.6-2.4z" />
</svg>
{/* 로고 */}
<div className="url-input-logo">
<img src="/assets/images/ado2-logo.svg" alt="ADO2" />
</div>
{/* 제목 */}
<h1 className="url-input-title"> </h1>
<p className="url-input-subtitle">
, .
</p>
{/* URL 입력 폼 */}
<form onSubmit={handleSubmit} className="url-input-form">
<div className="url-input-wrapper">
{/* 드롭다운 */}
<div className="url-input-dropdown-container">
<button
type="button"
className="url-input-dropdown-trigger"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`url-input-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="url-input-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
setSearchType(option.value);
setIsDropdownOpen(false);
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
{/* 입력 필드 */}
<div className="url-input-field-container" ref={autocompleteRef}>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="네이버 지도 URL을 입력하세요"
type={searchType === 'url' ? 'url' : 'text'}
value={inputValue}
onChange={(e) => {
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' && (
<div className="url-input-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="url-input-autocomplete-loading"> ...</div>
) : (
autocompleteResults.map((item, index) => (
<button
type="submit"
disabled={!url.trim()}
className="url-input-button"
key={index}
type="button"
className="url-input-autocomplete-item"
onMouseDown={(e) => {
e.preventDefault();
handleSelectAutocomplete(item);
}}
>
<div className="url-input-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="url-input-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
</div>
{/* 에러 메시지 */}
@ -59,7 +195,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, error }) =
{/* 안내 텍스트 */}
<p className="url-input-guide">
URL .
{getGuideText()}
</p>
</div>
</div>

View File

@ -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<HeroSectionProps> = ({ onAnalyze, onNext, error: externalError, scrollProgress = 0 }) => {
const [url, setUrl] = useState('');
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onNext, error: externalError, scrollProgress = 0 }) => {
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('url');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<NaverLocalSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(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<HeroSectionProps> = ({ 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<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
{/* Input Form */}
<div className="hero-form">
<span className="hero-input-label">URL </span>
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${url && !isFocused ? 'filled' : ''}`}>
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
{/* 드롭다운 */}
<div className="hero-dropdown-container" ref={dropdownRef}>
<button
type="button"
className="hero-dropdown-trigger"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="hero-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
setSearchType(option.value);
setIsDropdownOpen(false);
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
<div className="hero-input-container" ref={autocompleteRef}>
<input
type="text"
value={url}
value={inputValue}
onChange={(e) => {
setUrl(e.target.value);
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);
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="https://www.castad.com"
className={`hero-input ${url ? 'has-value' : ''}`}
placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
{url && (
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="hero-autocomplete-loading"> ...</div>
) : (
autocompleteResults.map((item, index) => (
<button
key={index}
type="button"
className="hero-autocomplete-item"
onMouseDown={(e) => {
e.preventDefault();
handleSelectAutocomplete(item);
}}
>
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
{inputValue && (
<button
type="button"
className="hero-input-clear"
onClick={() => {
setUrl('');
setInputValue('');
setLocalError('');
setAutocompleteResults([]);
setShowAutocomplete(false);
}}
>
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
</button>
)}
</div>
<span className="hero-input-hint">URL .</span>
<span className="hero-input-hint">{getGuideText()}</span>
{error && (
<p className="hero-error">{error}</p>
)}

View File

@ -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<Lyri
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeader(),
},
body: JSON.stringify(request),
});
@ -76,6 +79,9 @@ export async function generateLyric(request: LyricGenerateRequest): Promise<Lyri
export async function getLyricStatus(taskId: string): Promise<LyricStatusResponse> {
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<LyricStatusRespons
export async function getLyricDetail(taskId: string): Promise<LyricDetailResponse> {
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<SongStatusResponse> {
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<SongStatusResponse>
export async function downloadSong(taskId: string): Promise<SongDownloadResponse> {
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<VideoGenerateResponse> {
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<VideoStatusResponse> {
const response = await fetch(`${API_URL}/video/status/${taskId}`, {
method: 'GET',
headers: {
...getAuthHeader(),
},
});
if (!response.ok) {
@ -257,22 +279,28 @@ export async function getVideoStatus(taskId: string): Promise<VideoStatusRespons
}
// 영상 다운로드(결과 조회) API
export async function downloadVideo(taskId: string): Promise<VideoDownloadResponse> {
const response = await fetch(`${API_URL}/video/download/${taskId}`, {
method: 'GET',
});
// export async function downloadVideo(taskId: string): Promise<VideoDownloadResponse> {
// const response = await fetch(`${API_URL}/video/download/${taskId}`, {
// method: 'GET',
// headers: {
// ...getAuthHeader(),
// },
// });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// if (!response.ok) {
// throw new Error(`HTTP error! status: ${response.status}`);
// }
return response.json();
}
// return response.json();
// }
// 비디오 목록 조회 API
export async function getVideosList(page: number = 1, pageSize: number = 10): Promise<VideosListResponse> {
const response = await fetch(`${API_URL}/videos/?page=${page}&page_size=${pageSize}`, {
const response = await fetch(`${API_URL}/archive/videos/?page=${page}&page_size=${pageSize}`, {
method: 'GET',
headers: {
...getAuthHeader(),
},
});
if (!response.ok) {
@ -282,6 +310,20 @@ export async function getVideosList(page: number = 1, pageSize: number = 10): Pr
return response.json();
}
// 비디오 삭제 API
export async function deleteVideo(taskId: string): Promise<void> {
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}`);
}
}
// 이미지 업로드 API (multipart/form-data)
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;
@ -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<KakaoLoginUrlResponse> {
}
// 카카오 콜백 처리 (인가 코드로 JWT 토큰 발급)
// 1. callback 호출 후 2. verify로 토큰 발급
export async function kakaoCallback(code: string): Promise<KakaoCallbackResponse> {
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<UserMeResponse> {
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<NaverLocalSearchResponse> {
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<CrawlingResponse> {
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;
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />