Compare commits

..

No commits in common. "7f1e2b83a9756722e5163ae9356bf2a7d7acd937" and "f3195628d2376101f03c58aeb8f4cbcf8e1e886c" have entirely different histories.

26 changed files with 323 additions and 3030 deletions

1029
index.css

File diff suppressed because it is too large Load Diff

View File

@ -36,7 +36,6 @@
} }
} }
</script> </script>
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">

View File

@ -1,92 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>개인정보처리방침 - ADO2</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
h2 { font-size: 19px; margin-top: 40px; color: #111; }
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
ul { padding-left: 20px; }
.google-policy { background: #f0f7ff; border-left: 4px solid #4285f4; padding: 16px 20px; margin: 24px 0; border-radius: 4px; }
.google-policy p { color: #1a1a2e; margin: 0; }
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
</style>
</head>
<body>
<h1>개인정보처리방침 (Privacy Policy)</h1>
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 &nbsp;|&nbsp; <strong>최종 수정일:</strong> 2026년 5월 27일</p>
<p>㈜에이아이오투오(이하 "회사")는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")를 제공함에 있어 사용자의 개인정보를 중요시하며, 「개인정보 보호법」 등 관련 법령을 성실히 준수합니다.</p>
<h2>1. 수집하는 개인정보 항목 및 수집 방법</h2>
<p>회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집합니다.</p>
<ul>
<li><strong>카카오 로그인을 통한 수집:</strong> 이름, 이메일 주소, 프로필 사진 — 서비스 회원 가입 및 로그인에 사용</li>
<li><strong>Google 사용자 프로필 :</strong> YouTube 채널 연동 시 연결된 구글 계정의 이름 및 프로필 사진 확인 — 채널 소유자 식별 목적으로만 사용</li>
<li><strong>YouTube Data API :</strong> 채널 정보, 동영상 목록, 재생목록 등 YouTube 계정 데이터 (읽기 전용)</li>
<li><strong>YouTube Data API :</strong> AI가 생성한 콘텐츠를 YouTube에 업로드하기 위한 동영상 관리 권한</li>
<li><strong>YouTube Analytics API :</strong> 채널 및 동영상의 조회수, 시청 시간 등 성과 지표 데이터 (읽기 전용)</li>
<li><strong>서비스 이용 과정에서 자동 수집:</strong> 접속 IP, 쿠키, 서비스 이용 기록</li>
</ul>
<h2>2. Google 사용자 데이터의 수집 및 이용 목적</h2>
<p>회사가 접근하는 Google 사용자 데이터는 아래 목적으로만 사용되며, 명시된 목적 외에는 사용하지 않습니다.</p>
<ul>
<li><strong>Google 사용자 프로필:</strong> YouTube 채널 연동 과정에서 연결 대상 구글 계정을 식별하는 용도로만 사용하며, 서비스 로그인에는 사용되지 않습니다.</li>
<li><strong>YouTube 계정 데이터 (읽기):</strong> 기존 채널 정보·동영상 현황을 분석하여 AI 콘텐츠 전략 수립에 활용합니다.</li>
<li><strong>YouTube 동영상 업로드:</strong> AI가 생성한 영상을 사용자의 YouTube 채널에 업로드합니다. 업로드는 반드시 사용자의 명시적 요청에 의해서만 실행됩니다.</li>
<li><strong>YouTube 분석 데이터:</strong> 채널 성과 지표를 분석하여 AI 마케팅 전략 수립 및 콘텐츠 최적화에 활용합니다.</li>
</ul>
<div class="google-policy">
<p><strong>[Google API 서비스 사용자 데이터 정책 준수]</strong><br><br>
㈜에이아이오투오가 운영하는 ADO2 서비스가 Google API로부터 수신한 정보의 사용 및 타 앱으로의 전송은,
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank">Google API 서비스 사용자 데이터 정책</a>
제한적 사용(Limited Use) 요건을 포함한 모든 정책을 엄격히 준수합니다.<br><br>
특히, Google API로부터 수신한 데이터는 <strong>AI·ML 모델 학습에 사용되지 않으며</strong>, 사용자가 명시적으로 요청한 서비스 기능 제공 목적 외에는 사용·전송·공유되지 않습니다.</p>
</div>
<h2>3. Google 사용자 데이터의 제3자 공유</h2>
<p>회사는 Google API로부터 수신한 사용자 데이터를 아래 경우를 제외하고 어떠한 제3자에게도 판매하거나 공유하지 않습니다.</p>
<ul>
<li><strong>AI 콘텐츠 생성 처리:</strong> 서비스 제공에 필수적인 AI 생성 기능 수행을 위해 처리 서버로 전달될 수 있으며, 해당 처리는 서비스 제공 목적으로만 사용됩니다.</li>
<li><strong>법적 요구:</strong> 관련 법령에 의거한 수사기관 등의 적법한 요청이 있는 경우</li>
</ul>
<p>Google 사용자 데이터는 광고, 마케팅, 프로파일링 목적으로 사용되지 않습니다.</p>
<h2>4. Google 사용자 데이터의 저장 및 보안</h2>
<ul>
<li>Google OAuth 액세스 토큰 및 리프레시 토큰은 암호화된 상태로 보관됩니다.</li>
<li>Google API를 통해 읽어온 데이터는 서비스 기능 처리에 필요한 최소한의 시간 동안만 임시 보유하며, 처리 완료 후 삭제됩니다.</li>
<li>서버와의 모든 통신은 TLS(HTTPS)를 통해 암호화됩니다.</li>
<li>데이터 접근 권한은 서비스 운영에 필요한 최소 인원에게만 부여됩니다.</li>
</ul>
<h2>5. 개인정보의 보유 및 이용 기간 / 데이터 삭제</h2>
<p>원칙적으로 회원 탈퇴 또는 개인정보 수집·이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.</p>
<ul>
<li>전자상거래 관련 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)</li>
<li>접속 로그 기록: 3개월 (통신비밀보호법)</li>
<li>Google 연동 토큰: 연동 해제 또는 회원 탈퇴 즉시 삭제</li>
</ul>
<p><strong>데이터 삭제 요청 방법:</strong> 아래 이메일(<a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a>)로 요청하시면 <strong>영업일 기준 7일 이내</strong>에 처리 결과를 안내해 드립니다. Google 계정 연동 해제는 서비스 내 계정 설정에서 직접 처리하실 수 있으며, 해제 즉시 관련 토큰이 삭제됩니다.</p>
<h2>6. 정보주체의 권리 및 행사 방법</h2>
<p>사용자는 언제든지 자신의 개인정보에 대한 열람, 수정, 삭제, 처리 정지를 요청할 수 있습니다. 서비스 내 계정 설정에서 직접 처리하거나 아래 문의처로 연락해 주시기 바랍니다.</p>
<p>또한, <a href="https://myaccount.google.com/permissions" target="_blank">Google 계정 권한 관리 페이지</a>에서 ADO2 앱의 Google 데이터 접근 권한을 언제든지 직접 취소하실 수 있습니다.</p>
<h2>7. 개인정보 보호책임자 및 문의처</h2>
<p>개인정보 보호와 관련된 불만 처리 및 피해 구제에 관한 사항은 아래로 문의해 주시기 바랍니다.</p>
<ul>
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
</ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
</body>
</html>

View File

@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>서비스 약관 - ADO2</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
h2 { font-size: 19px; margin-top: 40px; color: #111; }
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
ul { padding-left: 20px; }
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
</style>
</head>
<body>
<h1>서비스 이용약관 (Terms of Service)</h1>
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 &nbsp;|&nbsp; <strong>최종 수정일:</strong> 2026년 5월 21일</p>
<h2>제 1 조 (목적)</h2>
<p>본 약관은 ㈜에이아이오투오(이하 "회사")가 제공하는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")의 이용과 관련하여, 회사와 이용자(이하 "회원") 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.</p>
<h2>제 2 조 (용어의 정의)</h2>
<ul>
<li>"서비스"라 함은 회사가 제공하는 ADO2 AI 마케팅 자동화 플랫폼 및 관련 제반 기능을 의미합니다.</li>
<li>"회원"이라 함은 본 약관에 동의하고 회사와 이용계약을 체결하여 서비스를 이용하는 자를 말합니다.</li>
<li>"콘텐츠"라 함은 서비스 내에서 AI가 생성하거나 회원이 등록하는 텍스트, 이미지, 음악, 영상 등 일체의 자료를 말합니다.</li>
</ul>
<h2>제 3 조 (약관의 효력 및 변경)</h2>
<p>회사는 본 약관의 내용을 서비스 화면에 게시하며, 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있습니다. 약관이 변경되는 경우 시행일 7일 전부터 공지합니다.</p>
<h2>제 4 조 (서비스의 제공 및 변경)</h2>
<p>회사는 AI 기반 마케팅 콘텐츠(가사, 이미지, 영상 등) 자동 생성, Google·YouTube 등 외부 플랫폼 연동, SNS 자동 배포 등의 서비스를 제공합니다. 운영상·기술상의 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.</p>
<h2>제 5 조 (회원의 의무)</h2>
<ul>
<li>타인의 Google 계정 등 외부 서비스 계정을 무단으로 도용하여 서비스를 이용해서는 안 됩니다.</li>
<li>스팸 발송, API 한도 고의 초과, 허위 정보 등록 등 비정상적인 방법으로 서비스를 이용해서는 안 됩니다.</li>
<li>회사 또는 제3자의 지식재산권을 침해해서는 안 됩니다.</li>
</ul>
<h2>제 6 조 (외부 API 연동 및 데이터 활용)</h2>
<p>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API(<code>youtube.readonly</code>, <code>youtube.upload</code>) 및 YouTube Analytics API(<code>yt-analytics.readonly</code>)를 사용하며, 이를 통해 수집·처리되는 데이터는 <a href="/privacy.html">개인정보처리방침</a>에 따라 관리됩니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.</p>
<p>YouTube API 서비스 이용과 관련하여 <a href="https://www.youtube.com/t/terms" target="_blank">YouTube 이용약관</a><a href="https://policies.google.com/privacy" target="_blank">Google 개인정보처리방침</a>이 함께 적용됩니다.</p>
<h2>제 7 조 (AI 생성 콘텐츠의 권리)</h2>
<p>서비스 내에서 AI가 생성한 콘텐츠에 대한 권리 관계는 관련 법령 및 회사의 별도 정책에 따릅니다. 회원이 직접 입력한 정보(매장 URL, 상호명 등)를 기반으로 생성된 콘텐츠에 대한 책임은 회원에게 있습니다.</p>
<h2>제 8 조 (책임 제한)</h2>
<p>회사는 천재지변, 외부 플랫폼(Google, YouTube, Naver 등)의 장애, 통신 장애 등 불가항력으로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</p>
<h2>제 9 조 (준거법 및 재판관할)</h2>
<p>본 약관과 관련된 분쟁은 대한민국 법을 준거법으로 하며, 소송은 회사의 소재지를 관할하는 법원에 제소합니다.</p>
<h2>문의처</h2>
<ul>
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
</ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
</body>
</html>

View File

@ -12,10 +12,7 @@ import GenerationFlow from './pages/Dashboard/GenerationFlow';
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess'; import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
import SocialConnectError from './pages/Social/SocialConnectError'; import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback'; import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage'; import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
import VideoDetailPage from './components/VideoDetailPage';
import LoginPromptModal from './components/LoginPromptModal';
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { CrawlingResponse } from './types/api'; import { CrawlingResponse } from './types/api';
@ -147,12 +144,6 @@ const App: React.FC = () => {
localStorage.removeItem('castad_wizard_step'); localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item'); localStorage.removeItem('castad_active_item');
} }
const redirectPath = sessionStorage.getItem('castad_login_redirect');
sessionStorage.removeItem('castad_login_redirect');
if (redirectPath && redirectPath !== '/') {
window.location.href = redirectPath;
return;
}
setInitialTab('새 프로젝트 만들기'); setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow'); setViewMode('generation_flow');
} catch (err) { } catch (err) {
@ -314,30 +305,6 @@ const App: React.FC = () => {
} }
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address);
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis failed:', err);
setError(t('app.autocompleteError'));
setViewMode('landing');
}
};
// 테스트 데이터로 브랜드 분석 페이지 이동 // 테스트 데이터로 브랜드 분석 페이지 이동
const handleTestData = (data: CrawlingResponse) => { const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true }; const tagged = { ...data, _isTestData: true };
@ -420,13 +387,6 @@ const App: React.FC = () => {
return <SocialConnectError />; return <SocialConnectError />;
} }
// 영상 상세 페이지 (/video/{id}) — 인증 게이트는 VideoDetailPage 내부에서 처리
const videoDetailMatch = pathname.match(/^\/video\/([^/]+)$/);
if (videoDetailMatch) {
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
}
// 카카오 콜백 처리 중 로딩 화면 표시 // 카카오 콜백 처리 중 로딩 화면 표시
if (isProcessingCallback) { if (isProcessingCallback) {
return ( return (
@ -486,7 +446,6 @@ const App: React.FC = () => {
<HeroSection <HeroSection
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete} onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData} onTestData={handleTestData}
onNext={() => scrollToSection(1)} onNext={() => scrollToSection(1)}
error={error} error={error}

View File

@ -1,95 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface BusinessNameInputModalProps {
onClose: () => void;
onSubmit: (businessName: string, address: string) => void;
}
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
const { t } = useTranslation();
const [businessName, setBusinessName] = useState('');
const [address, setAddress] = useState('');
useEffect(() => {
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const isValid = businessName.trim().length > 0 && address.trim().length > 0;
const handleSubmit = () => {
if (!isValid) return;
onSubmit(businessName.trim(), address.trim());
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isValid) handleSubmit();
};
return (
<div className="manual-modal-backdrop" onClick={onClose}>
<div className="manual-modal" onClick={e => e.stopPropagation()}>
<div className="manual-modal-header">
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
<button className="manual-modal-close" onClick={onClose}></button>
</div>
<div className="manual-modal-body">
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelName')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderName')}
value={businessName}
onChange={e => setBusinessName(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={50}
autoFocus
/>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelAddress')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderAddress')}
value={address}
onChange={e => setAddress(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={100}
/>
</div>
<div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="manual-modal-submit"
onClick={handleSubmit}
disabled={!isValid}
>
{t('landing.hero.analyzeButton')}
</button>
</div>
</div>
</div>
</div>
);
};
export default BusinessNameInputModal;

View File

@ -1,145 +0,0 @@
import React, { useState, useEffect } from 'react';
const REGIONS: { label: string; cities: string[] }[] = [
{
label: '특별시 / 광역시',
cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'],
},
{
label: '경기도',
cities: [
'수원시', '성남시', '고양시', '용인시', '부천시', '안산시', '안양시', '남양주시',
'화성시', '평택시', '의정부시', '시흥시', '파주시', '김포시', '광주시', '광명시',
'군포시', '하남시', '오산시', '이천시', '안성시', '구리시', '양주시', '포천시',
'여주시', '동두천시', '과천시', '가평군', '양평군', '연천군',
],
},
{
label: '강원도',
cities: [
'춘천시', '원주시', '강릉시', '동해시', '태백시', '속초시', '삼척시',
'홍천군', '횡성군', '영월군', '평창군', '정선군', '철원군', '화천군',
'양구군', '인제군', '고성군', '양양군',
],
},
{
label: '충청북도',
cities: [
'청주시', '충주시', '제천시',
'보은군', '옥천군', '영동군', '증평군', '진천군', '괴산군', '음성군', '단양군',
],
},
{
label: '충청남도',
cities: [
'천안시', '공주시', '보령시', '아산시', '서산시', '논산시', '계룡시', '당진시',
'금산군', '부여군', '서천군', '청양군', '홍성군', '예산군', '태안군',
],
},
{
label: '전라북도',
cities: [
'전주시', '군산시', '익산시', '정읍시', '남원시', '김제시',
'완주군', '진안군', '무주군', '장수군', '임실군', '순창군', '고창군', '부안군',
],
},
{
label: '전라남도',
cities: [
'목포시', '여수시', '순천시', '나주시', '광양시',
'담양군', '곡성군', '구례군', '고흥군', '보성군', '화순군', '장흥군', '강진군',
'해남군', '영암군', '무안군', '함평군', '영광군', '장성군', '완도군', '진도군', '신안군',
],
},
{
label: '경상북도',
cities: [
'포항시', '경주시', '김천시', '안동시', '구미시', '영주시', '영천시', '상주시', '문경시', '경산시',
'의성군', '청송군', '영양군', '영덕군', '청도군', '고령군', '성주군', '칠곡군',
'예천군', '봉화군', '울진군', '울릉군',
],
},
{
label: '경상남도',
cities: [
'창원시', '진주시', '통영시', '사천시', '김해시', '밀양시', '거제시', '양산시',
'의령군', '함안군', '창녕군', '고성군', '남해군', '하동군', '산청군', '함양군', '거창군', '합천군',
],
},
{
label: '제주도',
cities: ['제주시', '서귀포시'],
},
];
interface CitySelectModalProps {
selected: string;
onSelect: (city: string) => void;
onClose: () => void;
}
const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, onClose }) => {
const [activeRegion, setActiveRegion] = useState<string | null>(
() => REGIONS.find(r => r.cities.includes(selected))?.label ?? null
);
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);
const cities = REGIONS.find(r => r.label === activeRegion)?.cities ?? [];
const handleCityClick = (city: string) => {
onSelect(city === selected ? '' : city);
onClose();
};
return (
<div className="city-modal-backdrop" onClick={onClose}>
<div className="city-modal" onClick={e => e.stopPropagation()}>
<div className="city-modal-header">
<span className="city-modal-title">
{activeRegion ? (
<>
<button className="city-modal-back" onClick={() => setActiveRegion(null)}></button>
{activeRegion}
</>
) : '지역 선택'}
</span>
<button className="city-modal-close" onClick={onClose}></button>
</div>
{activeRegion === null ? (
<div className="city-modal-grid">
{REGIONS.map(r => (
<button
key={r.label}
className={`city-modal-region-item ${r.cities.includes(selected) ? 'has-selected' : ''}`}
onClick={() => setActiveRegion(r.label)}
>
<span>{r.label}</span>
{r.cities.includes(selected) && <span className="city-modal-region-badge">{selected}</span>}
<span className="city-modal-arrow"></span>
</button>
))}
</div>
) : (
<div className="city-modal-item-wrap">
{cities.map(city => (
<button
key={city}
className={`city-modal-item ${selected === city ? 'active' : ''}`}
onClick={() => handleCityClick(city)}
>
{city}
</button>
))}
</div>
)}
</div>
</div>
);
};
export default CitySelectModal;

View File

@ -1,100 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl } from '../utils/api';
interface LoginPromptModalProps {
onClose: () => void;
}
const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
const { t } = useTranslation();
const handleLogin = async () => {
try {
sessionStorage.setItem('castad_login_redirect', window.location.pathname);
const response = await getKakaoLoginUrl();
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(255, 255, 255, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={onClose}
>
<div
style={{
background: '#01282A',
border: '1px solid rgba(155, 202, 204, 0.2)',
borderRadius: '16px',
padding: '40px 32px',
width: '360px',
maxWidth: '90vw',
textAlign: 'center',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
aria-label="닫기"
style={{
position: 'absolute',
top: '16px',
right: '16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#9BCACC',
padding: '4px',
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="4" y1="4" x2="16" y2="16"/>
<line x1="16" y1="4" x2="4" y2="16"/>
</svg>
</button>
<h2 style={{ color: '#FFFFFF', fontSize: '20px', fontWeight: 600, marginBottom: '12px' }}>
{t('loginPrompt.title')}
</h2>
<button
onClick={handleLogin}
style={{
width: '50%',
margin: '0 auto',
padding: '14px',
background: '#FEE500',
color: '#3C1E1E',
border: 'none',
borderRadius: '8px',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M10 2C5.582 2 2 4.91 2 8.5c0 2.26 1.37 4.25 3.44 5.44L4.6 17.1a.3.3 0 0 0 .44.33l4.03-2.67c.3.03.62.04.93.04 4.418 0 8-2.91 8-6.5S14.418 2 10 2z" fill="#3C1E1E"/>
</svg>
{t('loginPrompt.loginBtn')}
</button>
</div>
</div>
);
};
export default LoginPromptModal;

View File

@ -85,7 +85,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{ id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> }, { id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> }, { id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> }, { id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> }, { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
{ id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> }, { id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> },
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> }, { id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
]; ];

View File

@ -27,11 +27,7 @@ interface TutorialOverlayProps {
const PADDING = 8; const PADDING = 8;
function getTargetRect(selector: string): Rect | null { function getTargetRect(selector: string): Rect | null {
const els = Array.from(document.querySelectorAll(selector)); const el = document.querySelector(selector);
const el = els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0];
if (!el) return null; if (!el) return null;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom }; return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };

View File

@ -50,7 +50,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.hero-dropdown-trigger', targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.dropdown.title', titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.dropdown.desc', descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
noSpotlight: true, noSpotlight: true,
variant: 'bubble', variant: 'bubble',
@ -59,7 +59,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.hero-input-wrapper', targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title', titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc', descriptionKey: 'tutorial.landing.field.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
noSpotlight: true, noSpotlight: true,
variant: 'bubble', variant: 'bubble',
@ -68,7 +68,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.hero-button', targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title', titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc', descriptionKey: 'tutorial.landing.button.desc',
position: 'bottom', position: 'right',
clickToAdvance: true, clickToAdvance: true,
noSpotlight: true, noSpotlight: true,
variant: 'bubble', variant: 'bubble',
@ -86,17 +86,17 @@ export const tutorialSteps: TutorialStepDef[] = [
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn', targetSelector: '.asset-upload-zone',
titleKey: 'tutorial.asset.upload.title', titleKey: 'tutorial.asset.upload.title',
descriptionKey: 'tutorial.asset.upload.desc', descriptionKey: 'tutorial.asset.upload.desc',
position: 'bottom', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.asset-ratio-section', targetSelector: '.asset-ratio-section',
titleKey: 'tutorial.asset.ratio.title', titleKey: 'tutorial.asset.ratio.title',
descriptionKey: 'tutorial.asset.ratio.desc', descriptionKey: 'tutorial.asset.ratio.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
@ -129,7 +129,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.btn-generate-sound', targetSelector: '.btn-generate-sound',
titleKey: 'tutorial.sound.generate.title', titleKey: 'tutorial.sound.generate.title',
descriptionKey: 'tutorial.sound.generate.desc', descriptionKey: 'tutorial.sound.generate.desc',
position: 'top', position: 'right',
clickToAdvance: true, clickToAdvance: true,
}, },
], ],
@ -141,14 +141,14 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.lyrics-display', targetSelector: '.lyrics-display',
titleKey: 'tutorial.sound.lyrics.title', titleKey: 'tutorial.sound.lyrics.title',
descriptionKey: 'tutorial.sound.lyrics.desc', descriptionKey: 'tutorial.sound.lyrics.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.status-message-new', targetSelector: '.status-message-new',
titleKey: 'tutorial.sound.lyricsWait.title', titleKey: 'tutorial.sound.lyricsWait.title',
descriptionKey: 'tutorial.sound.lyricsWait.desc', descriptionKey: 'tutorial.sound.lyricsWait.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
}, },
], ],
@ -160,7 +160,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.audio-player', targetSelector: '.audio-player',
titleKey: 'tutorial.sound.audioPlayer.title', titleKey: 'tutorial.sound.audioPlayer.title',
descriptionKey: 'tutorial.sound.audioPlayer.desc', descriptionKey: 'tutorial.sound.audioPlayer.desc',
position: 'bottom', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
@ -220,21 +220,21 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.content-upload-btn', targetSelector: '.content-upload-btn',
titleKey: 'tutorial.ado2.download.title', titleKey: 'tutorial.ado2.download.title',
descriptionKey: 'tutorial.ado2.download.desc', descriptionKey: 'tutorial.ado2.download.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.content-delete-btn', targetSelector: '.content-delete-btn',
titleKey: 'tutorial.ado2.delete.title', titleKey: 'tutorial.ado2.delete.title',
descriptionKey: 'tutorial.ado2.delete.desc', descriptionKey: 'tutorial.ado2.delete.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.content-download-btn', targetSelector: '.content-download-btn',
titleKey: 'tutorial.ado2.upload.title', titleKey: 'tutorial.ado2.upload.title',
descriptionKey: 'tutorial.ado2.upload.desc', descriptionKey: 'tutorial.ado2.upload.desc',
position: 'top', position: 'right',
clickToAdvance: true, clickToAdvance: true,
}, },
], ],
@ -252,7 +252,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.comp2-video-section', targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.generating.title', titleKey: 'tutorial.completion.generating.title',
descriptionKey: 'tutorial.completion.generating.desc', descriptionKey: 'tutorial.completion.generating.desc',
position: 'top', position: 'right',
} }
] ]
}, },
@ -263,7 +263,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.comp2-video-section', targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.completion.title', titleKey: 'tutorial.completion.completion.title',
descriptionKey: 'tutorial.completion.completion.desc', descriptionKey: 'tutorial.completion.completion.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
@ -294,14 +294,14 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.social-posting-form', targetSelector: '.social-posting-form',
titleKey: 'tutorial.upload.required.title', titleKey: 'tutorial.upload.required.title',
descriptionKey: 'tutorial.upload.required.desc', descriptionKey: 'tutorial.upload.required.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.social-posting-radio-group', targetSelector: '.social-posting-radio-group',
titleKey: 'tutorial.upload.schedule.title', titleKey: 'tutorial.upload.schedule.title',
descriptionKey: 'tutorial.upload.schedule.desc', descriptionKey: 'tutorial.upload.schedule.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
@ -326,7 +326,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.yoy-chart-card', targetSelector: '.yoy-chart-card',
titleKey: 'tutorial.dashboard.chart.title', titleKey: 'tutorial.dashboard.chart.title',
descriptionKey: 'tutorial.dashboard.chart.desc', descriptionKey: 'tutorial.dashboard.chart.desc',
position: 'top', position: 'right',
}, },
{ {
targetSelector: '.tutorial-center-anchor', targetSelector: '.tutorial-center-anchor',

View File

@ -1,485 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
getVideoById,
getVideoComments,
postVideoComment,
deleteComment,
toggleVideoLike,
isLoggedIn,
} from '../utils/api';
import { VideoDetailItem, CommentItem } from '../types/api';
import LoginPromptModal from './LoginPromptModal';
interface VideoDetailContentProps {
videoId: string;
isModal?: boolean;
onClose?: () => void;
}
const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModal = false, onClose }) => {
const { t } = useTranslation();
const authed = isLoggedIn();
const [video, setVideo] = useState<VideoDetailItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [likeCount, setLikeCount] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [isLandscape, setIsLandscape] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [comments, setComments] = useState<CommentItem[]>([]);
const [commentsTotal, setCommentsTotal] = useState(0);
const [commentsPage, setCommentsPage] = useState(1);
const [commentsHasNext, setCommentsHasNext] = useState(false);
const [commentsLoading, setCommentsLoading] = useState(false);
const [commentInput, setCommentInput] = useState('');
const [commentSubmitting, setCommentSubmitting] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const [commentNickname, setCommentNickname] = useState('');
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
// 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
const handleChangeAvatar = useCallback(() => {
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
}, []);
const fetchComments = useCallback(async (page: number, append = false) => {
setCommentsLoading(true);
try {
const res = await getVideoComments(videoId, page, 20);
const sorted = [...res.items].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
setComments(prev => append ? [...prev, ...sorted] : sorted);
setCommentsTotal(res.total);
setCommentsHasNext(res.has_next);
setCommentsPage(page);
} catch (err) {
console.error('Failed to fetch comments:', err);
} finally {
setCommentsLoading(false);
}
}, [videoId]);
useEffect(() => {
const fetchVideo = async () => {
setLoading(true);
setError(null);
try {
const data = await getVideoById(videoId);
setVideo(data);
setLikeCount(data.like_count);
setIsLiked(data.is_liked_by_me);
} catch (err) {
console.error('Failed to fetch video:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
fetchVideo();
fetchComments(1);
}, [videoId, fetchComments]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
};
const formatCommentDate = (dateString: string) => {
const date = new Date(dateString);
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
};
const shareUrl = `${window.location.origin}/video/${videoId}`;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
// clipboard API 미지원 환경에서는 무시
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleKakaoShare = () => {
const kakao = window.Kakao;
if (kakao?.Share) {
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: video?.store_name ?? 'ADO2 영상',
description: `${video?.region ?? ''} · ADO2 AI 마케팅 영상`,
imageUrl: 'https://demo.castad.net/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: '영상 보기', link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
}
setShareMenuOpen(false);
};
const handleFacebookShare = () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleTwitterShare = () => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const shareMenuRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shareMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
setShareMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [shareMenuOpen]);
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLike = () => {
if (!authed) { setShowLoginModal(true); return; }
// 1. UI 즉시 업데이트 (Optimistic)
setIsLiked(prev => !prev);
setLikeCount(prev => isLiked ? prev - 1 : prev + 1);
// 2. 기존 debounce 타이머 취소 후 재설정
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
likeDebounceRef.current = setTimeout(async () => {
const prevLiked = isLiked;
const prevCount = likeCount;
try {
await toggleVideoLike(videoId);
} catch (err) {
// 3. 실패 시 롤백
console.error('Failed to toggle like:', err);
setIsLiked(prevLiked);
setLikeCount(prevCount);
}
}, 500);
};
const handleHeaderAction = () => {
if (!isModal && !authed) { setShowLoginModal(true); return; }
onClose?.();
};
const handleCommentFocus = () => {
if (!authed) setShowLoginModal(true);
};
const handleCommentSubmit = async () => {
if (!authed) { setShowLoginModal(true); return; }
if (!commentInput.trim() || commentSubmitting) return;
setCommentSubmitting(true);
try {
await postVideoComment(videoId, commentInput.trim(), commentNickname);
setCommentInput('');
if (commentTextareaRef.current) {
commentTextareaRef.current.style.height = 'auto';
}
setCommentNickname('');
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
await fetchComments(1);
} catch (err) {
console.error('Failed to post comment:', err);
} finally {
setCommentSubmitting(false);
}
};
const handleDeleteComment = async (commentId: number) => {
try {
await deleteComment(commentId);
await fetchComments(commentsPage);
} catch (err) {
console.error('Failed to delete comment:', err);
}
};
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>( .)</span>;
return content;
};
return (
<div className={isModal ? 'video-detail-modal-content' : 'video-detail-page-content'}>
{/* 헤더 */}
<div className="video-detail-header">
{isModal ? (
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label="닫기">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
) : (
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 4l-6 6 6 6"/>
</svg>
{t('sidebar.ado2Contents')}
</button>
)}
</div>
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
<p>{t('ado2Contents.loading')}</p>
</div>
) : error ? (
<div className="ado2-contents-error"><p>{error}</p></div>
) : video ? (
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<video
src={video.result_movie_url}
controls
autoPlay
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
/>
<div className="video-detail-info">
<h2 className="video-detail-store">{video.store_name}</h2>
<p className="video-detail-date">{formatDate(video.created_at)}</p>
{/* 좋아요 + 링크 복사 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className={`video-detail-like-btn ${isLiked ? 'liked' : ''}`}
onClick={handleLike}
disabled={false}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
{likeCount}
</button>
<div style={{ position: 'relative' }} ref={shareMenuRef}>
<button
className="video-detail-copy-btn"
onClick={() => setShareMenuOpen(v => !v)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
{copied ? '복사됨!' : '공유하기'}
</button>
{shareMenuOpen && (
<div className="video-detail-share-menu">
{/* 카카오톡 */}
<button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FEE500"/>
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
</svg>
</button>
{/* 페이스북 */}
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
</svg>
</button>
{/* X (트위터) */}
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
X ()
</button>
{/* URL 복사 */}
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{copied ? '복사됨!' : 'URL 복사'}
</button>
</div>
)}
</div>
</div>
{/* 댓글 섹션 */}
<div className="video-detail-comments">
<div className="video-detail-comments-header">
<h3 className="video-detail-comments-title"></h3>
<span className="video-detail-comments-count">{commentsTotal}</span>
</div>
{/* 댓글 작성자 프로필 선택 */}
{authed && (
<div className="video-detail-comment-profile">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
alt="아바타 변경"
className="video-detail-comment-avatar"
onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }}
title="클릭하여 아바타 변경"
/>
<input
className="video-detail-nickname-input"
type="text"
placeholder="작성자 이름"
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
/>
</div>
)}
<div className="video-detail-comment-input-wrap">
<textarea
ref={commentTextareaRef}
className="video-detail-comment-input"
placeholder={authed ? '댓글을 입력하세요...' : '로그인 후 댓글을 작성할 수 있습니다'}
maxLength={500}
rows={1}
value={commentInput}
onChange={(e) => {
setCommentInput(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onFocus={handleCommentFocus}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCommentSubmit();
}
}}
disabled={!authed}
/>
<button
className="video-detail-comment-submit"
onClick={handleCommentSubmit}
disabled={!authed || !commentInput.trim() || commentSubmitting}
>
{commentSubmitting ? '작성 중' : '작성'}
</button>
</div>
{comments.length === 0 && !commentsLoading ? (
<p className="video-detail-comments-empty"> .</p>
) : (
<ul className="video-detail-comment-list">
{comments.map((c) => (
<li key={c.id} className="video-detail-comment-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
alt="avatar"
className="video-detail-comment-avatar"
/>
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{c.nickname || '익명'}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(c.content, c.is_deleted)}
</p>
<div className="video-detail-comment-bottom">
<span className="video-detail-comment-date">{formatCommentDate(c.created_at)}</span>
{c.is_mine && !c.is_deleted && (
<button
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(c.id)}
>
</button>
)}
</div>
{/* 대댓글 (읽기 전용) */}
{c.replies && c.replies.length > 0 && (
<ul className="video-detail-reply-list">
{c.replies.map((r) => (
<li key={r.id} className="video-detail-reply-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
alt="avatar"
className="video-detail-comment-avatar small"
/>
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{r.nickname || '익명'}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(r.content, r.is_deleted)}
</p>
<div className="video-detail-comment-bottom">
<span className="video-detail-comment-date">{formatCommentDate(r.created_at)}</span>
{r.is_mine && !r.is_deleted && (
<button
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(r.id)}
>
</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
)}
{commentsHasNext && (
<button
className="video-detail-comments-more"
onClick={() => fetchComments(commentsPage + 1, true)}
disabled={commentsLoading}
>
{commentsLoading ? '불러오는 중...' : '댓글 더 보기'}
</button>
)}
</div>
</div>
</div>
) : null}
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}
</div>
);
};
export default VideoDetailContent;

View File

@ -1,56 +0,0 @@
import React, { useEffect } from 'react';
import VideoDetailContent from './VideoDetailContent';
interface VideoDetailModalProps {
videoId: string;
onClose: () => void;
}
const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose }) => {
useEffect(() => {
// 모달 열릴 때 배경 스크롤 잠금
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = prev;
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
zIndex: 2000,
display: 'flex',
overflowY: 'auto',
padding: '24px 16px',
}}
onClick={onClose}
>
<div
style={{
background: '#01282A',
borderRadius: '16px',
width: '100%',
maxWidth: '900px',
margin: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
</div>
</div>
);
};
export default VideoDetailModal;

View File

@ -1,21 +0,0 @@
import React from 'react';
import VideoDetailContent from './VideoDetailContent';
interface VideoDetailPageProps {
videoId: string;
}
const VideoDetailPage: React.FC<VideoDetailPageProps> = ({ videoId }) => {
const handleBack = () => {
localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
window.location.href = '/';
};
return (
<div className="video-detail-page">
<VideoDetailContent videoId={videoId} isModal={false} onClose={handleBack} />
</div>
);
};
export default VideoDetailPage;

View File

@ -4,17 +4,6 @@ import ReactDOM from 'react-dom/client';
import './i18n'; import './i18n';
import App from './App'; import App from './App';
declare global {
interface Window {
Kakao: any;
}
}
const kakaoKey = import.meta.env.VITE_KAKAO_JS_KEY as string | undefined;
if (kakaoKey && window.Kakao && !window.Kakao.isInitialized()) {
window.Kakao.init(kakaoKey);
}
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
throw new Error("Could not find root element to mount to"); throw new Error("Could not find root element to mount to");

View File

@ -95,8 +95,8 @@
"footer": { "footer": {
"company":"O2O Inc.", "company":"O2O Inc.",
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin", "businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
"headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea", "headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
"researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea", "researchCenter": "R&D : 13453 Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
"phone": "Tel : 070-4260-8310 | 010-2755-6463", "phone": "Tel : 070-4260-8310 | 010-2755-6463",
"email": "Email : o2oteam@o2o.kr", "email": "Email : o2oteam@o2o.kr",
"privacyPolicy": "Privacy Policy", "privacyPolicy": "Privacy Policy",
@ -169,13 +169,7 @@
"testDataLoading": "Loading...", "testDataLoading": "Loading...",
"testData": "Test Data", "testData": "Test Data",
"testDataLoadFailed": "Failed to load test data.", "testDataLoadFailed": "Failed to load test data.",
"searching": "Searching...", "searching": "Searching..."
"searchTypeManual": "Manual Input",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address"
}, },
"welcome": { "welcome": {
"title": "Welcome to ADO2.AI", "title": "Welcome to ADO2.AI",
@ -206,13 +200,7 @@
"searchButton": "Search", "searchButton": "Search",
"searching": "Searching...", "searching": "Searching...",
"testDataLoading": "Loading...", "testDataLoading": "Loading...",
"testData": "Test Data", "testData": "Test Data"
"searchTypeManual": "Manual Input",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address"
}, },
"assetManagement": { "assetManagement": {
"title": "Brand Assets", "title": "Brand Assets",
@ -483,14 +471,7 @@
"delete": "Delete", "delete": "Delete",
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"uploadToSocial": "Upload to social media", "uploadToSocial": "Upload to social media"
"sortLatest": "Latest",
"sortOldest": "Oldest",
"sortLikes": "Most Liked",
"sortComments": "Most Commented",
"regionPlaceholder": "Select Region",
"searchPlaceholder": "Search by name",
"searchBtn": "Search"
}, },
"businessSettings": { "businessSettings": {
"title": "Business Settings", "title": "Business Settings",
@ -565,10 +546,6 @@
"scheduledCount": "Sched {{count}}", "scheduledCount": "Sched {{count}}",
"failedCount": "Fail {{count}}" "failedCount": "Fail {{count}}"
}, },
"loginPrompt": {
"title": "Login Required",
"loginBtn": "Login with Kakao"
},
"app": { "app": {
"loginProcessing": "Processing login...", "loginProcessing": "Processing login...",
"loginFailed": "Login processing failed. Please try again.", "loginFailed": "Login processing failed. Please try again.",

View File

@ -95,8 +95,8 @@
"footer": { "footer": {
"company":"㈜에이아이오투오", "company":"㈜에이아이오투오",
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민", "businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호", "headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)", "researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
"phone": "전화 : 070-4260-8310 | 010-2755-6463", "phone": "전화 : 070-4260-8310 | 010-2755-6463",
"email": "이메일 : o2oteam@o2o.kr", "email": "이메일 : o2oteam@o2o.kr",
"privacyPolicy": "개인정보처리방침", "privacyPolicy": "개인정보처리방침",
@ -169,13 +169,7 @@
"testDataLoading": "로딩 중...", "testDataLoading": "로딩 중...",
"testData": "테스트 데이터", "testData": "테스트 데이터",
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.", "testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"searching": "검색 중...", "searching": "검색 중..."
"searchTypeManual": "직접 입력",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요."
}, },
"welcome": { "welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.", "title": "ADO2.AI에 오신 것을 환영합니다.",
@ -206,13 +200,7 @@
"searchButton": "검색하기", "searchButton": "검색하기",
"searching": "검색 중...", "searching": "검색 중...",
"testDataLoading": "로딩 중...", "testDataLoading": "로딩 중...",
"testData": "테스트 데이터", "testData": "테스트 데이터"
"searchTypeManual": "직접 입력",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요."
}, },
"assetManagement": { "assetManagement": {
"title": "브랜드 에셋", "title": "브랜드 에셋",
@ -483,14 +471,7 @@
"delete": "삭제", "delete": "삭제",
"previous": "이전", "previous": "이전",
"next": "다음", "next": "다음",
"uploadToSocial": "소셜 미디어에 업로드", "uploadToSocial": "소셜 미디어에 업로드"
"sortLatest": "최신순",
"sortOldest": "오래된순",
"sortLikes": "좋아요 많은순",
"sortComments": "댓글 많은순",
"regionPlaceholder": "지역 선택",
"searchPlaceholder": "업체명 검색",
"searchBtn": "검색"
}, },
"businessSettings": { "businessSettings": {
"title": "비즈니스 설정", "title": "비즈니스 설정",
@ -565,10 +546,6 @@
"scheduledCount": "예약 {{count}}", "scheduledCount": "예약 {{count}}",
"failedCount": "실패 {{count}}" "failedCount": "실패 {{count}}"
}, },
"loginPrompt": {
"title": "로그인이 필요합니다.",
"loginBtn": "로그인"
},
"app": { "app": {
"loginProcessing": "로그인 처리 중...", "loginProcessing": "로그인 처리 중...",
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.", "loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",

View File

@ -1,127 +1,226 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getAllVideos, isLoggedIn } from '../../utils/api'; import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api'; import { VideoListItem } from '../../types/api';
import LoginPromptModal from '../../components/LoginPromptModal'; import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import CitySelectModal from '../../components/CitySelectModal'; const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const isPlayingRef = useRef(false);
const pendingPlayRef = useRef(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
video.preload = 'auto';
} else {
video.preload = 'none';
}
},
{ threshold: 0.1 }
);
observer.observe(video);
return () => observer.disconnect();
}, []);
const safePlay = useCallback((video: HTMLVideoElement) => {
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise
.then(() => { isPlayingRef.current = true; })
.catch((error) => {
if (error.name !== 'AbortError') console.error(error);
});
}
}, []);
const handleCanPlay = useCallback(() => {
if (!pendingPlayRef.current || !videoRef.current) return;
pendingPlayRef.current = false;
safePlay(videoRef.current);
}, [safePlay]);
const handleMouseEnter = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.readyState >= 3) {
safePlay(video);
} else {
pendingPlayRef.current = true;
}
}, [safePlay]);
const handleMouseLeave = useCallback(() => {
const video = videoRef.current;
if (!video) return;
pendingPlayRef.current = false;
if (isPlayingRef.current) {
video.pause();
video.currentTime = 0;
isPlayingRef.current = false;
}
}, []);
return (
<video
ref={videoRef}
src={src}
className={className}
muted
playsInline
preload="none"
onCanPlay={handleCanPlay}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
};
interface ADO2ContentsPageProps { interface ADO2ContentsPageProps {
onBack?: () => void; onBack: () => void;
onNavigate?: (item: string) => void;
} }
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => { const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack, onNavigate }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const authed = isLoggedIn();
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const [videos, setVideos] = useState<VideoListItem[]>([]); const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(authed); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false); const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false); const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
const pageSize = 20; const pageSize = 12;
const [sortBy, setSortBy] = useState<'created_at' | 'like_count' | 'comment_count'>('created_at');
const [order, setOrder] = useState<'desc' | 'asc'>('desc');
const [searchInput, setSearchInput] = useState('');
const [storeName, setStoreName] = useState('');
const [region, setRegion] = useState('');
const [showCityModal, setShowCityModal] = useState(false);
useEffect(() => { useEffect(() => {
if (!authed) return;
fetchVideos(); fetchVideos();
}, [page, sortBy, order, storeName, region]); }, [page]);
const fetchVideos = async () => { const fetchVideos = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await getAllVideos(page, pageSize, sortBy, storeName, order, region); const response = await getVideosList(page, pageSize);
setVideos(response.items); console.log('[ADO2] API response:', response);
console.log('[ADO2] First video item:', response.items[0]);
// result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외)
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
setVideos(validVideos);
setTotal(response.total); setTotal(response.total);
setTotalPages(response.total_pages); setTotalPages(response.total_pages);
setHasNext(response.has_next); setHasNext(response.has_next);
setHasPrev(response.has_prev); setHasPrev(response.has_prev);
} catch (err) { } catch (err) {
console.error('Failed to fetch all videos:', err); console.error('Failed to fetch videos:', err);
setError(t('ado2Contents.loadFailed')); setError(t('ado2Contents.loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleCardClick = (videoId: number) => {
setSelectedVideoId(videoId);
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
return `${year}.${month}.${day}`; const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}${hours}:${minutes}`;
};
const formatTitle = (storeName: string, dateString: string) => {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
};
const handleDownload = async (videoUrl: string, storeName: string) => {
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${storeName}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download failed:', err);
alert(t('ado2Contents.downloadFailed'));
}
};
const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(videoId);
setDeleteModalOpen(true);
};
const handleUploadClick = (video: VideoListItem) => {
console.log('[ADO2] Upload clicked - video object:', video);
console.log('[ADO2] video.video_id:', video.video_id);
setUploadTargetVideo(video);
setUploadModalOpen(true);
};
const handleUploadModalClose = () => {
setUploadModalOpen(false);
setUploadTargetVideo(null);
};
const handleDeleteCancel = () => {
setDeleteModalOpen(false);
setDeleteTargetId(null);
};
const handleDeleteConfirm = async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
await deleteVideo(deleteTargetId);
// 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
// fetchVideos()를 호출하지 않음 - 서버 캐시 또는 동기화 지연으로 인해
// 삭제된 항목이 다시 나타날 수 있기 때문
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false);
setDeleteTargetId(null);
} catch (err) {
console.error('Delete failed:', err);
alert(t('ado2Contents.deleteFailed'));
} finally {
setIsDeleting(false);
}
}; };
return ( return (
<div className="ado2-contents-page"> <div className="ado2-contents-page">
{/* Header */}
<div className="ado2-contents-header"> <div className="ado2-contents-header">
<h1 className="ado2-contents-title">{t('sidebar.ado2Contents')}</h1> <h1 className="ado2-contents-title">{t('ado2Contents.title')}</h1>
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span> <span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
</div> </div>
<div className="ado2-contents-filters"> {/* Content Grid */}
<select
className="ado2-filter-select"
value={`${sortBy}__${order}`}
onChange={(e) => {
const [sb, ord] = e.target.value.split('__') as [typeof sortBy, typeof order];
setSortBy(sb);
setOrder(ord);
setPage(1);
}}
>
<option value="created_at__desc">{t('ado2Contents.sortLatest')}</option>
<option value="created_at__asc">{t('ado2Contents.sortOldest')}</option>
<option value="like_count__desc">{t('ado2Contents.sortLikes')}</option>
<option value="comment_count__desc">{t('ado2Contents.sortComments')}</option>
</select>
<button
type="button"
className={`ado2-region-pill ${region ? 'active' : ''}`}
onClick={() => setShowCityModal(true)}
>
{region || t('ado2Contents.regionPlaceholder')}
{region && (
<span className="ado2-region-clear" onClick={(e) => { e.stopPropagation(); setRegion(''); setPage(1); }}></span>
)}
</button>
<form
className="ado2-filter-search"
onSubmit={(e) => {
e.preventDefault();
setStoreName(searchInput);
setPage(1);
}}
>
<input
type="text"
className="ado2-filter-input"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder={t('ado2Contents.searchPlaceholder')}
/>
<button type="submit" className="ado2-filter-btn">
{t('ado2Contents.searchBtn')}
</button>
</form>
</div>
{loading ? ( {loading ? (
<div className="ado2-contents-loading"> <div className="ado2-contents-loading">
<div className="loading-spinner"></div> <div className="loading-spinner"></div>
@ -140,64 +239,82 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<> <>
<div className="ado2-contents-grid"> <div className="ado2-contents-grid">
{videos.map((video) => ( {videos.map((video) => (
<div <div key={video.task_id} className="ado2-content-card">
key={video.video_id} {/* Video Thumbnail */}
className="ado2-content-card" <div className="content-card-thumbnail">
style={{ cursor: 'pointer' }} {video.result_movie_url ? (
onClick={() => handleCardClick(video.video_id)} <VideoPreviewCard
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
>
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt={video.store_name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : video.result_movie_url ? (
<video
src={video.result_movie_url} src={video.result_movie_url}
className="content-video-preview" className="content-video-preview"
preload="metadata"
muted
playsInline
/> />
) : ( ) : (
<div className="content-no-video" /> <div className="content-no-video">
)} <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
{/* 호버 오버레이 */} <rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<div className="ado2-gallery-overlay"> <line x1="7" y1="2" x2="7" y2="22"/>
<div className="ado2-gallery-play-btn"> <line x1="17" y1="2" x2="17" y2="22"/>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"> <line x1="2" y1="12" x2="22" y2="12"/>
<polygon points="5,3 19,12 5,21"/> <line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg> </svg>
</div> </div>
</div> )}
</div> </div>
{/* Card Info */}
<div className="content-card-info"> <div className="content-card-info">
<div className="content-card-text"> <div className="content-card-text">
<h3 className="content-card-title">{video.store_name}</h3> <h3 className="content-card-title">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> {formatTitle(video.store_name, video.created_at)}
<p className="content-card-date">{formatDate(video.created_at)}</p> </h3>
<span className="content-card-like"> <p className="content-card-date">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2"> {formatDate(video.created_at)}
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/> </p>
</svg> </div>
{video.like_count ?? 0}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}> {/* Action Buttons */}
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> <div className="content-card-actions">
</svg> <button
{video.comment_count ?? 0} className="content-download-btn"
</span> onClick={() => handleUploadClick(video)}
</div> disabled={!video.result_movie_url}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
title={t('ado2Contents.download')}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
</button>
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video.video_id)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
{/* Pagination - 항상 표시 */}
<div className="ado2-contents-pagination"> <div className="ado2-contents-pagination">
<button <button
className="pagination-btn" className="pagination-btn"
@ -218,24 +335,39 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
</> </>
)} )}
{!authed && ( {/* 삭제 확인 모달 */}
<LoginPromptModal onClose={() => { window.location.href = '/'; }} /> {deleteModalOpen && (
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
<div className="delete-modal-actions">
<button
className="delete-modal-btn cancel"
onClick={handleDeleteCancel}
disabled={isDeleting}
>
{t('ado2Contents.cancel')}
</button>
<button
className="delete-modal-btn confirm"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
</button>
</div>
</div>
</div>
)} )}
{selectedVideoId !== null && ( {/* 소셜 미디어 업로드 모달 */}
<VideoDetailModal <SocialPostingModal
videoId={String(selectedVideoId)} isOpen={uploadModalOpen}
onClose={() => setSelectedVideoId(null)} onClose={handleUploadModalClose}
/> video={uploadTargetVideo}
)} onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
/>
{showCityModal && (
<CitySelectModal
selected={region}
onSelect={(city) => { setRegion(city); setPage(1); }}
onClose={() => setShowCityModal(false)}
/>
)}
</div> </div>
); );
}; };

View File

@ -500,10 +500,12 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>; return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
} }
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim()); const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
const intro = lines.slice(0, 1);
const outro = lines.slice(-1); const outro = lines.slice(-1);
const body = lines.slice(0, -1); const body = lines.slice(1, -1);
const half = Math.ceil(body.length / 2); const half = Math.ceil(body.length / 2);
const sections = [ const sections = [
{ tag: '[Intro]', lines: intro },
{ tag: '[Verse]', lines: body.slice(0, half) }, { tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) }, { tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro }, { tag: '[Outro]', lines: outro },
@ -555,8 +557,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
task_id: songTaskId || '', task_id: songTaskId || '',
result_movie_url: videoUrl, result_movie_url: videoUrl,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
like_count: 0,
comment_count: 0,
} : null} } : null}
/> />

View File

@ -9,13 +9,12 @@ import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent'; import BusinessSettingsContent from './BusinessSettingsContent';
import UrlInputContent from './UrlInputContent'; import UrlInputContent from './UrlInputContent';
import ADO2ContentsPage from './ADO2ContentsPage'; import ADO2ContentsPage from './ADO2ContentsPage';
import MyContentsPage from './MyContentsPage';
import MyInfoContent from './MyInfoContent'; import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent'; import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection'; import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api'; import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api'; import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial'; import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
@ -261,33 +260,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
} }
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
goToWizardStep(-1);
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
const data = await marketingAnalysis(businessName, address);
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
data.processed_info.region = data.processed_info.region || '';
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
}
data.image_list = data.image_list || [];
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis error:', err);
setAnalysisError(t('app.autocompleteError'));
goToWizardStep(-2);
}
};
// 테스트용 랜덤 m_id 생성 (99 ~ 300) // 테스트용 랜덤 m_id 생성 (99 ~ 300)
const generateRandomMId = (): number => { const generateRandomMId = (): number => {
return Math.floor(Math.random() * (300 - 99 + 1)) + 99; return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
@ -392,7 +364,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
if (tutorial.isActive) tutorial.skipTutorial(); if (tutorial.isActive) tutorial.skipTutorial();
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) { if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO); tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
} else if (activeItem === ' 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) { } else if (activeItem === 'ADO2 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS); tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
} else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) { } else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) {
tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD); tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD);
@ -438,7 +410,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<UrlInputContent <UrlInputContent
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete} onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData} onTestData={handleTestData}
error={analysisError} error={analysisError}
/> />
@ -538,10 +509,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case '비즈니스 설정': case '비즈니스 설정':
return <BusinessSettingsContent />; return <BusinessSettingsContent />;
case 'ADO2 콘텐츠': case 'ADO2 콘텐츠':
return <ADO2ContentsPage />;
case '내 콘텐츠':
return ( return (
<MyContentsPage <ADO2ContentsPage
onBack={() => setActiveItem('새 프로젝트 만들기')} onBack={() => setActiveItem('새 프로젝트 만들기')}
onNavigate={handleNavigate} onNavigate={handleNavigate}
/> />
@ -575,12 +544,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 브랜드 분석(0)일 때는 전체 페이지 스크롤 // 브랜드 분석(0)일 때는 전체 페이지 스크롤
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0; const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보 // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis; const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null) // 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
const getCurrentTutorialKey = (): string | null => { const getCurrentTutorialKey = (): string | null => {
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO; if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
if (activeItem === ' 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS; if (activeItem === 'ADO2 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD; if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR; if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
if (activeItem === '새 프로젝트 만들기') { if (activeItem === '새 프로젝트 만들기') {

View File

@ -1,399 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const isPlayingRef = useRef(false);
const pendingPlayRef = useRef(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
video.preload = 'auto';
} else {
video.preload = 'none';
}
},
{ threshold: 0.1 }
);
observer.observe(video);
return () => observer.disconnect();
}, []);
const safePlay = useCallback((video: HTMLVideoElement) => {
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise
.then(() => { isPlayingRef.current = true; })
.catch((error) => {
if (error.name !== 'AbortError') console.error(error);
});
}
}, []);
const handleCanPlay = useCallback(() => {
if (!pendingPlayRef.current || !videoRef.current) return;
pendingPlayRef.current = false;
safePlay(videoRef.current);
}, [safePlay]);
const handleMouseEnter = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.readyState >= 3) {
safePlay(video);
} else {
pendingPlayRef.current = true;
}
}, [safePlay]);
const handleMouseLeave = useCallback(() => {
const video = videoRef.current;
if (!video) return;
pendingPlayRef.current = false;
if (isPlayingRef.current) {
video.pause();
video.currentTime = 0;
isPlayingRef.current = false;
}
}, []);
return (
<video
ref={videoRef}
src={src}
className={className}
muted
playsInline
preload="none"
onCanPlay={handleCanPlay}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
};
interface MyContentsPageProps {
onBack?: () => void;
onNavigate?: (item: string) => void;
}
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const pageSize = 12;
useEffect(() => {
fetchVideos();
}, [page]);
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
}
}, []);
const fetchVideos = async () => {
setLoading(true);
setError(null);
try {
const response = await getVideosList(page, pageSize);
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
setVideos(validVideos);
setTotal(response.total);
setTotalPages(response.total_pages);
setHasNext(response.has_next);
setHasPrev(response.has_prev);
} catch (err) {
console.error('Failed to fetch videos:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}${hours}:${minutes}`;
};
const formatTitle = (storeName: string, dateString: string) => {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
};
const handleDownload = async (videoUrl: string, storeName: string) => {
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${storeName}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download failed:', err);
alert(t('ado2Contents.downloadFailed'));
}
};
const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(videoId);
setDeleteModalOpen(true);
};
const handleUploadClick = (video: VideoListItem) => {
setUploadTargetVideo(video);
setUploadModalOpen(true);
};
const handleUploadModalClose = () => {
setUploadModalOpen(false);
setUploadTargetVideo(null);
};
const handleDeleteCancel = () => {
setDeleteModalOpen(false);
setDeleteTargetId(null);
};
const handleDeleteConfirm = async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
await deleteVideo(deleteTargetId);
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false);
setDeleteTargetId(null);
} catch (err) {
console.error('Delete failed:', err);
alert(t('ado2Contents.deleteFailed'));
} finally {
setIsDeleting(false);
}
};
return (
<div className="ado2-contents-page">
{/* Header */}
<div className="ado2-contents-header">
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
</div>
{/* Content Grid */}
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
<p>{t('ado2Contents.loading')}</p>
</div>
) : error ? (
<div className="ado2-contents-error">
<p>{error}</p>
<button onClick={fetchVideos} className="retry-btn">{t('ado2Contents.retry')}</button>
</div>
) : videos.length === 0 ? (
<div className="ado2-contents-empty">
<p>{t('ado2Contents.noContent')}</p>
</div>
) : (
<>
<div className="ado2-contents-grid">
{videos.map((video) => (
<div key={video.task_id} className="ado2-content-card">
{/* Video Thumbnail */}
<div
className="content-card-thumbnail"
style={{ cursor: 'pointer' }}
onClick={() => setSelectedVideoId(video.video_id)}
>
{video.result_movie_url ? (
<VideoPreviewCard
src={video.result_movie_url}
className="content-video-preview"
/>
) : (
<div className="content-no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg>
</div>
)}
</div>
{/* Card Info */}
<div className="content-card-info">
<div className="content-card-text">
<h3 className="content-card-title">
{formatTitle(video.store_name, video.created_at)}
</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p className="content-card-date">
{formatDate(video.created_at)}
</p>
<span className="content-card-like">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
{video.like_count ?? 0}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
{video.comment_count ?? 0}
</span>
</div>
</div>
{/* Action Buttons */}
<div className="content-card-actions">
<button
className="content-download-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
title={t('ado2Contents.download')}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
</button>
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video.video_id)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="ado2-contents-pagination">
<button
className="pagination-btn"
onClick={() => setPage(p => p - 1)}
disabled={!hasPrev}
>
{t('ado2Contents.previous')}
</button>
<span className="pagination-info">{page} / {totalPages}</span>
<button
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={!hasNext}
>
{t('ado2Contents.next')}
</button>
</div>
</>
)}
{/* 삭제 확인 모달 */}
{deleteModalOpen && (
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
<div className="delete-modal-actions">
<button
className="delete-modal-btn cancel"
onClick={handleDeleteCancel}
disabled={isDeleting}
>
{t('ado2Contents.cancel')}
</button>
<button
className="delete-modal-btn confirm"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
</button>
</div>
</div>
</div>
)}
{selectedVideoId !== null && (
<VideoDetailModal
videoId={String(selectedVideoId)}
onClose={() => setSelectedVideoId(null)}
/>
)}
{/* 소셜 미디어 업로드 모달 */}
<SocialPostingModal
isOpen={uploadModalOpen}
onClose={handleUploadModalClose}
video={uploadTargetVideo}
onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
/>
</div>
);
};
export default MyContentsPage;

View File

@ -389,14 +389,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<> <>
<main className="sound-studio-page"> <main className="sound-studio-page">
{audioUrl && ( {audioUrl && (
<audio <audio ref={audioRef} src={audioUrl} preload="metadata" />
ref={audioRef}
src={audioUrl}
preload="metadata"
onLoadedMetadata={() => {
if (audioRef.current) setDuration(audioRef.current.duration);
}}
/>
)} )}
{/* Header */} {/* Header */}
@ -613,10 +606,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<div className="lyrics-display"> <div className="lyrics-display">
{lyrics ? (() => { {lyrics ? (() => {
const lines = lyrics.split('\n').filter(l => l.trim()); const lines = lyrics.split('\n').filter(l => l.trim());
const intro = lines.slice(0, 1);
const outro = lines.slice(-1); const outro = lines.slice(-1);
const body = lines.slice(0, -1); const body = lines.slice(1, -1);
const half = Math.ceil(body.length / 2); const half = Math.ceil(body.length / 2);
const sections = [ const sections = [
{ tag: '[Intro]', lines: intro },
{ tag: '[Verse]', lines: body.slice(0, half) }, { tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) }, { tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro }, { tag: '[Outro]', lines: outro },

View File

@ -4,9 +4,8 @@ import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } fro
import { CrawlingResponse } from '../../types/api'; import { CrawlingResponse } from '../../types/api';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory'; import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown'; import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual'; type SearchType = 'url' | 'name';
// 환경변수에서 테스트 모드 확인 // 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
@ -14,12 +13,11 @@ const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
interface UrlInputContentProps { interface UrlInputContentProps {
onAnalyze: (value: string, type?: SearchType) => void; onAnalyze: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void; onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onTestData?: (data: CrawlingResponse) => void; onTestData?: (data: CrawlingResponse) => void;
error: string | null; error: string | null;
} }
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => { const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name'); const [searchType, setSearchType] = useState<SearchType>('name');
@ -29,8 +27,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null); const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [isLoadingTest, setIsLoadingTest] = useState(false); const [isLoadingTest, setIsLoadingTest] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false); const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => { const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => {
closeHistory(); closeHistory();
@ -70,7 +67,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
const searchTypeOptions = [ const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' }, { value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') }, { value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') },
// { value: 'manual' as SearchType, label: t('urlInput.searchTypeManual') },
]; ];
const getPlaceholder = () => { const getPlaceholder = () => {
@ -219,13 +215,8 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
type="button" type="button"
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`} className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => { onClick={() => {
if (option.value === 'manual') { setSearchType(option.value);
setIsManualModalOpen(true); setIsDropdownOpen(false);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
}} }}
> >
{option.label} {option.label}
@ -338,13 +329,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')} {isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
</button> </button>
)} )}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div> </div>
); );
}; };

View File

@ -8,9 +8,8 @@ import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay'; import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory'; import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown'; import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual'; type SearchType = 'url' | 'name';
// 환경변수에서 테스트 모드 확인 // 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
@ -18,7 +17,6 @@ const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
interface HeroSectionProps { interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void; onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void; onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onTestData?: (data: CrawlingResponse) => void; onTestData?: (data: CrawlingResponse) => void;
onNext?: () => void; onNext?: () => void;
error?: string | null; error?: string | null;
@ -64,12 +62,11 @@ 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 }, { 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, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => { const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name'); const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false); const [isLoadingTest, setIsLoadingTest] = useState(false);
@ -89,7 +86,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setIsLoadingTest(false); setIsLoadingTest(false);
} }
}; };
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType); const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]); const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
@ -116,7 +113,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
const searchTypeOptions = [ const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' }, { value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') }, { value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
// { value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
]; ];
// 드롭다운 외부 클릭 감지 // 드롭다운 외부 클릭 감지
@ -381,13 +377,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
type="button" type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`} className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => { onClick={() => {
if (option.value === 'manual') { setSearchType(option.value);
setIsManualModalOpen(true); setIsDropdownOpen(false);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
}} }}
> >
{option.label} {option.label}
@ -564,13 +555,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
groupProgress={tutorial.groupProgress} groupProgress={tutorial.groupProgress}
/> />
)} )}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div> </div>
); );
}; };

View File

@ -267,29 +267,14 @@ export interface UserCreditsResponse {
credits: number; credits: number;
} }
// 비디오 목록 아이템 (갤러리용) // 비디오 목록 아이템
export interface VideoListItem { export interface VideoListItem {
video_id: number; video_id: number;
store_name: string; store_name: string;
region: string; region: string;
task_id: string; task_id: string;
result_movie_url: string; result_movie_url: string;
thumbnail_url?: string;
created_at: string; created_at: string;
like_count: number;
comment_count: number;
is_liked_by_me?: boolean;
}
// 비디오 상세 아이템
export interface VideoDetailItem {
video_id: number;
result_movie_url: string;
store_name: string;
region: string;
created_at: string;
like_count: number;
is_liked_by_me: boolean;
} }
// 비디오 목록 응답 // 비디오 목록 응답
@ -303,45 +288,6 @@ export interface VideosListResponse {
has_prev: boolean; has_prev: boolean;
} }
// 댓글 대댓글
export interface CommentReply {
id: number;
nickname: string;
content: string | null;
is_deleted: boolean;
is_mine: boolean;
created_at: string;
}
// 댓글 아이템
export interface CommentItem {
id: number;
nickname: string;
content: string | null;
is_deleted: boolean;
is_mine: boolean;
created_at: string;
replies: CommentReply[];
}
// 댓글 목록 응답
export interface CommentsResponse {
items: CommentItem[];
total: number;
page: number;
page_size: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
// 좋아요 토글 응답
export interface LikeToggleResponse {
video_id: number;
is_liked: boolean;
like_count: number;
}
// ============================================ // ============================================
// Social OAuth Types // Social OAuth Types
// ============================================ // ============================================

View File

@ -30,10 +30,6 @@ import {
YTAutoSeoRequest, YTAutoSeoRequest,
YTAutoSeoResponse, YTAutoSeoResponse,
UserCreditsResponse, UserCreditsResponse,
VideoDetailItem,
CommentsResponse,
CommentItem,
LikeToggleResponse,
} from '../types/api'; } from '../types/api';
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
@ -377,99 +373,6 @@ export async function deleteVideo(videoId: number): Promise<void> {
} }
} }
// 전체 사용자 영상 목록 조회 API (ADO2 콘텐츠 갤러리용)
export async function getAllVideos(
page: number = 1,
pageSize: number = 20,
sortBy: 'created_at' | 'like_count' | 'comment_count' = 'created_at',
storeName: string = '',
order: 'desc' | 'asc' = 'desc',
region: string = '',
): Promise<VideosListResponse> {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
sort_by: sortBy,
order,
});
if (storeName.trim()) params.set('store_name', storeName.trim());
if (region.trim()) params.set('region', region.trim());
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 단일 영상 상세 조회 API
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 목록 조회
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 작성
export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise<CommentItem> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 삭제
export async function deleteComment(commentId: number): Promise<void> {
const response = await authenticatedFetch(`${API_URL}/comment/${commentId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// 좋아요 토글
export async function toggleVideoLike(videoId: string): Promise<LikeToggleResponse> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 이미지 업로드 API (multipart/form-data) // 이미지 업로드 API (multipart/form-data)
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음) // 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000; const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;
@ -900,37 +803,6 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
} }
} }
// 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
try {
const response = await authenticatedFetch(`${API_URL}/marketing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ store_name: storeName, address }),
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;
}
}
// ============================================ // ============================================
// Social OAuth TOKEN_EXPIRED 처리 // Social OAuth TOKEN_EXPIRED 처리
// ============================================ // ============================================